Model Finite State Machines with C# Unions and Closed Classes

A surprising amount of application complexity comes from pretending that a finite set of states is open-ended.

Consider an order workflow. An order may be Draft, Submitted, Paid, Cancelled, or Fulfilled. A payment attempt may succeed, fail permanently, or require additional customer action. A background job may be queued, running, completed, or faulted.

These are not merely objects with a Status string attached. They are closed sets of meaningful possibilities. Each state carries different data, permits different transitions, and demands different behavior from the code that consumes it.

For years, C# developers have modeled this reality with enums, inheritance hierarchies, nullable fields, marker interfaces, or library-provided Result and OneOf types. Each works, but each leaves some responsibility with convention rather than the compiler.

C# unions and closed classes in the .NET 11 preview direction are compelling because they move finite-state modeling closer to the language itself. They offer a way to state two important facts directly:

  1. These are the only valid shapes of this value.
  2. Consumers must account for every shape.

That is a small syntactic shift with substantial design consequences.

The problem with status fields

A common model begins innocently:

csharp

This permits combinations that are structurally valid but semantically impossible:

  • A successful payment with a FailureReason.
  • A failed payment with a TransactionId that was never assigned.
  • A RequiresAction payment without an ActionUrl.
  • A status value added later that existing switch statements silently mishandle.

Validation can catch these problems, but validation happens after construction. The type itself does not communicate which properties belong to which state.

An inheritance-based alternative is more expressive:

csharp

This is already a strong model. Each subtype owns only the data it needs. Pattern matching gives consumers readable code. The gap is that a normal abstract base class is generally extensible unless the design actively prevents it. Code in another assembly may introduce another subtype, and the compiler cannot always treat a switch over the hierarchy as complete.

That is where closed classes and unions matter.

Closed means the state space is intentional

A closed class hierarchy communicates that all permitted derived types are known to the declaring model. In practical terms, it turns inheritance from an open extension mechanism into a bounded vocabulary.

The exact preview syntax and diagnostics may continue to evolve, so treat the following snippets as representative of the model rather than a promise about final syntax. The essential idea is a root type whose cases are deliberately declared and bounded:

csharp

The important contract is not the keyword. It is the compiler knowledge behind it:

  • PaymentResult has a finite set of cases.
  • New cases cannot appear accidentally from unrelated code.
  • A pattern match can be checked for exhaustiveness.
  • Refactoring a case becomes safer because the compiler can identify consumers that need attention.

This is especially useful at boundaries where ambiguity is expensive: payment providers, messaging consumers, workflow engines, parsers, authentication flows, and API result models.

A closed hierarchy says, “extension is not the goal here; correctness is.”

Unions describe alternatives, not just inheritance

A union is the natural expression of a value that is exactly one of several alternatives. Instead of emphasizing the inheritance mechanics, it emphasizes the domain truth: this value is A or B or C.

For a service operation, that might look conceptually like this:

csharp

The syntax may change as the preview evolves, but the design is immediately recognizable. CreateInvoiceResult is not a bag of optional properties. It is one of four outcomes, with payload data attached precisely where it belongs.

The consumer then has a natural decision point:

csharp

This is more than concise routing code. It creates a maintenance alarm. If the domain later adds DuplicateInvoiceReference, every exhaustive match is a place where the application must make a policy decision.

Without that pressure, new states often receive generic fallbacks: an accidental 500, a default response, or a branch that was never designed for the new outcome.

Exhaustiveness is a refactoring tool

The most valuable property of unions is often not their initial readability. It is what happens six months later.

Suppose a payment provider introduces a temporary PendingReview state. In a conventional model, a developer might add an enum member, deploy the change, and discover later that a reporting process classifies it as a failure while the customer-facing API treats it as success.

With a closed union, introducing the new case intentionally breaks incomplete decision points at compile time. That sounds disruptive, but it is productive disruption. Every compiler error asks a useful question:

What should this part of the system do when a payment is pending review?

Some components should retry. Some should display a waiting state. Some should avoid sending fulfillment messages. Some may genuinely not care and can explicitly use a discard arm after that choice has been reviewed.

The distinction is crucial: a fallback should represent deliberate domain policy, not missing knowledge.

Use unions at boundaries, not everywhere

Unions are not a replacement for ordinary classes. A rich entity with identity, long-lived behavior, persistence concerns, and evolving fields is often still best modeled as a class or record.

Unions shine when the alternatives have materially different meanings:

  • Command results: accepted, rejected, conflicted, unauthorized.
  • Parsing: valid document, syntax error, unsupported version.
  • Integration responses: delivered, retryable failure, permanent failure.
  • Authentication: authenticated, challenge required, access denied.
  • Workflow state: waiting, executing, completed, cancelled.

They are less helpful when cases merely encode a category that does not alter behavior. Replacing every enum with a union can create unnecessary types and ceremony. The test is simple: does each alternative have distinct data, distinct valid transitions, or distinct handling rules? If yes, a union is usually worth considering.

Persistence and API contracts need deliberate translation

A union is a strong in-memory domain representation, but databases and HTTP payloads still need stable contracts.

For a relational database, map the union to a discriminator plus case-specific columns, or normalize case-specific data into dependent tables when the payload is substantial. With EF Core, keep persistence concerns out of the union where possible: map a persistence record, then translate it to the domain union at the repository boundary.

For JSON APIs, use an explicit discriminator:

json

Avoid serializing CLR type names as public contract details. A stable kind field gives clients a clear dispatch point and gives your team room to rename internal types.

Versioning remains a design responsibility. Adding a union case is source-breaking for exhaustive consumers, which is desirable inside one solution. Across public APIs, it may be a breaking change for clients that assume the set is closed. Document that possibility, version the endpoint when necessary, and ensure generated clients have an intentional unknown-case strategy.

Start with one painful workflow

The best adoption path is not a broad rewrite. Find one area where status flags, nullable properties, and defensive validation have become difficult to reason about. Convert its result or state model into a closed hierarchy or union. Then let pattern matching reveal the places where the system had implicit assumptions.

That is the real promise of this direction in C#: not a new way to write type declarations, but a better way to make domain alternatives visible. When the set of valid states is finite, your code should be able to say so—and your compiler should be able to help protect it.

Comments (0)

No comments yet. Be the first to share your thoughts!

Model Finite State Machines with C# Unions and Closed Classes