Skip to content

Use Typestate pattern for UMessageBuilder#341

Open
sophokles73 wants to merge 2 commits into
eclipse-uprotocol:mainfrom
etas-contrib:refactor_message_builder
Open

Use Typestate pattern for UMessageBuilder#341
sophokles73 wants to merge 2 commits into
eclipse-uprotocol:mainfrom
etas-contrib:refactor_message_builder

Conversation

@sophokles73

Copy link
Copy Markdown
Contributor

Introduced the Typestate pattern for the UMessageBuilder to enforce the correct sequence of method calls when building a UMessage of a particular type.

This change ensures that all required (and only appropriate) fields are set before the message can be built, improving code safety and reducing the likelihood of runtime errors.

@sophokles73 sophokles73 requested a review from PLeVasseur June 22, 2026 13:44
@sophokles73 sophokles73 added the enhancement New feature or request label Jun 22, 2026
@sophokles73 sophokles73 force-pushed the refactor_message_builder branch 2 times, most recently from e532bfc to e9e5b44 Compare June 22, 2026 14:13
Introduced the Typestate pattern for the UMessageBuilder to enforce the correct
sequence of method calls when building a UMessage of a particular type.

This change ensures that all required (and only appropriate) fields are set before
the message can be built, improving code safety and reducing the likelihood of
runtime errors.
@sophokles73 sophokles73 force-pushed the refactor_message_builder branch from e9e5b44 to 0157870 Compare June 24, 2026 07:42

@PLeVasseur PLeVasseur left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for putting this together. The typestate direction is a nice fit for the builder: it moves the request-only and response-only attributes out of runtime panics and into the type system, while still keeping the normal call sites readable.

I found two things worth tightening before merge.

  • The new public UMessageBuilder<S> shape exposes the state type in the public API, but the state marker types and BuilderState trait are not reachable from the public crate API. Chained constructor usage works, but external users cannot conveniently name, store, or return a typed builder from their own APIs.
  • The request/response-specific method docs still describe runtime panics for using the method on the wrong message type. With this PR those methods are simply not available for the wrong state, so the docs should be updated to describe the compile-time restriction instead.

///
/// For each type of message there is a dedicated builder which ensures that only attributes
/// relevant to that message type are set.
pub struct UMessageBuilder<S: BuilderState> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes the builder's state type part of the public API, but only UMessageBuilder itself is re-exported from the crate root. The state marker types and BuilderState trait are not publicly reachable.

That may be the right choice if we want to support only immediate fluent construction:

UMessageBuilder::publish(topic).build()?;

Could we make that choice explicit, though? If downstream crates are expected to compose with partially configured builders, then the unexported state types make a few reasonable patterns hard to express.

For example, a wrapper crate may want to centralize project defaults but still let the caller decide the payload:

fn telemetry_event(topic: UUri) -> UMessageBuilder<PublishBuilderState> {
    let mut builder = UMessageBuilder::publish(topic);
    builder.with_priority(UPriority::CS2);
    builder.with_traceparent(current_traceparent());
    builder
}

Or middleware/test utilities may want to apply common metadata to any builder state, similar to apply_common_options in this crate:

fn apply_observability<S: BuilderState>(builder: &mut UMessageBuilder<S>) {
    builder.with_traceparent(current_traceparent());
}

If those patterns are intentionally out of scope, it would be good to document that UMessageBuilder is intended for direct fluent use and not as a type callers should name in their own APIs. If they are in scope, could we expose that intentionally, perhaps via public aliases such as PublishMessageBuilder, RequestMessageBuilder, and ResponseMessageBuilder instead of requiring users to depend directly on the marker type names?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great finding. IMHO we should not constrain ourselves and users to fluent invocation only. I have several cases in the code base where I also need to invoke builder functions in a non-fluent way. I will export the BuilderState traits at the lib.rs level to mitigate this problem. Or do you see an issue with doing that apart from the fact that users might need to pull these traits into scope in order to use the builder efficiently? Maybe we should start thinking about creating a prelude?

Comment thread src/umessage/umessagebuilder.rs Outdated
///
/// The builder.
///
/// # Panics

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These docs still describe the old runtime behavior: with_token, with_permission_level, and with_comm_status say they panic when used with the wrong message type.

with_permission_level also says it panics if the value is greater than i32::MAX, but the method takes u32 and validation now reports inconsistent attributes as a UMessageError from build().

With the typestate API, those methods are no longer available on the wrong builder state, so misuse fails at compile time instead of panicking. Could we update these sections to describe that compile-time restriction and update or remove the stale panic bullets? That will make the docs match the main value of this PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Absolutely, I seem to have forgotten to remove these

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have removed the corresponding sections from the Rust doc comments.

@sophokles73 sophokles73 force-pushed the refactor_message_builder branch from 9ae5c67 to 7b4086d Compare June 26, 2026 08:50
@sophokles73 sophokles73 force-pushed the refactor_message_builder branch from 7b4086d to 4a6be69 Compare June 26, 2026 08:52
@sophokles73

Copy link
Copy Markdown
Contributor Author

@PLeVasseur I have pushed corresponding changes. Would you mind taking another look?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants