Skip to content

ADR-0005: Domain - Union Value Object-Based State Machine

After adding PartiallyShipped to the order status, 3 out of 20 switch(orderStatus) sites across the codebase missed the new state. The compiler raised no warning because a default branch was present, and orders in the partially shipped state silently fell through to the default branch, being processed as “unknown status” — a bug that made it all the way to production. Furthermore, with an enum-based approach, invalid transitions like Shipped to Draft could only be blocked by runtime if checks, and if those checks were omitted, domain invariants were violated. There was also no way to represent state-specific associated data (e.g., attaching a tracking number to the Shipped state).

A state machine pattern was needed that triggers compile errors at all processing sites when a new state is added, and that expresses only allowed transitions at the type level to structurally prevent invalid transitions.

  • Option 1: enum + switch statement
  • Option 2: SmartEnum library
  • Option 3: UnionValueObject<TSelf> + [UnionType] Source Generator
  • Option 4: OneOf library

Option 3: Adopt UnionValueObject<TSelf> + [UnionType] Source Generator.

The fundamental problem with enum + switch is that “the compiler stays silent when a new state is added.” The exhaustive Match/Switch generated by the Source Generator solves this problem at its root.

Each state is defined as a sealed record inheriting UnionValueObject<TSelf>, with the [UnionType] attribute attached. The Source Generator then automatically generates Match/Switch methods.

  • Match: Takes a function for every state as arguments and returns a result. If any state is missing, it immediately produces a compile error.
  • Switch: Same as Match but performs side effects without a return value.
  • State transitions: TransitionTo() methods defined on each state type express only allowed transitions at the type level. Invalid transitions like Shipped to Draft return DomainErrorType.InvalidTransition, and methods for disallowed transitions simply do not exist.

Since this is Value Object-based, immutability is guaranteed. State-specific associated data (e.g., a tracking number on the Shipped state) can be attached in a type-safe manner, and it integrates naturally with LINQ and collection operations.

  • Positive: When a new state like PartiallyShipped is added, compile errors fire at every Match/Switch call site across the codebase, finding every omission without exception. State-specific data — a tracking number on Shipped, a cancellation reason on Cancelled — can be attached type-safely, making per-state data modeling explicit. Transition rules are encoded in types, so the code itself serves as the state diagram without separate documentation. The Source Generator generates code at build time with zero runtime overhead.
  • Negative: A build dependency on the Source Generator is added, and IDE autocompletion and refactoring support depend on Source Generator integration quality. There is a learning curve for C# developers unfamiliar with functional Union type patterns.
  • Verify that compile errors occur at existing Match/Switch call sites after adding a new state.
  • Test that disallowed state transitions return an InvalidTransition error.
  • Verify that the Source Generator-generated code includes correct exhaustive checks.
  • Pros: Most familiar to all C# developers. Works with language built-in features without any additional library. JSON serialization/deserialization is straightforward.
  • Cons: Adding PartiallyShipped causes the compiler to stay silent at all 20 switch sites with a default branch, allowing omissions to reach production. Invalid transitions like Shipped to Draft can only be blocked by runtime if checks, and omitting those checks breaks domain invariants. There is no way to represent state-specific data like attaching a tracking number to the Shipped state.
  • Pros: Allows defining richer behavior than enums. Leverages polymorphism by overriding methods per state.
  • Cons: Adding a new state still lacks exhaustive checking to catch omissions in existing processing code. There is no mechanism to enforce transition rules at compile time. Immutability is not guaranteed by default, and state objects can be unintentionally mutated.

Option 3: UnionValueObject + [UnionType] Source Generator

Section titled “Option 3: UnionValueObject + [UnionType] Source Generator”
  • Pros: Adding a new state triggers immediate compile errors at every Match/Switch call site, structurally preventing omissions. A tracking number can be type-safely attached to Shipped, a cancellation reason to Cancelled. Sealed record-based, so immutability and value equality are provided by default. The Source Generator produces code at build time with zero runtime overhead. LINQ integration like orders.Where(o => o.Status.Match(...)) is natural.
  • Cons: A Source Generator build dependency is added. There is a learning curve for C# developers unfamiliar with functional Union types. Custom JSON converters may be needed for serialization to distinguish per-state types.
  • Pros: Installable directly from NuGet for lightweight Union type usage. Works without a separate Source Generator.
  • Cons: Positional arguments in Match(f0, f1, f2, f3) form make it unreadable which state corresponds to which position. Value Object immutability and equality comparison are not built in. There is no mechanism to express transition rules, sharing the same limitations as enum. LINQ integration is awkward.
  • Commits: 5c347e54, 3584b1db