Most C# domain models begin with classes, inheritance, and enums. That combination is familiar, flexible, and often good enough. It also leaves an important gap: many business concepts are not merely values from a list, nor are they arbitrary points in an inheritance tree. They are a known set of shapes.
An order submission is accepted, rejected, or pending review. A payment is a card payment, bank transfer, or wallet transaction. A search response is a result, an empty response, or a failure with diagnostics. These are finite alternatives with different data, behavior, and invariants.
C# unions and closed classes, appearing in the .NET 11 preview-era language work, target that gap. Together, they offer a more direct way to tell both the compiler and the next maintainer: “this hierarchy has boundaries, and these are the valid cases.”
Preview note: language syntax and diagnostics can change before a final release. Treat the code shapes in this article as a design guide, then verify the current syntax and compiler behavior against the SDK preview you are using.
The problem with open inheritance
Classic inheritance is open by default. If a base type is public and inheritable, another developer can create a subtype later:
This looks harmless until application logic starts branching on concrete types:
The discard arm is doing more than satisfying the compiler. It is silently admitting that an unknown subtype may appear. If someone later adds CryptoPayment, the application might compile successfully while an important workflow fails at runtime.
An enum does create a finite set, but it cannot naturally carry case-specific data or behavior:
Once Declined needs a reason code, Pending needs an expiration time, and Authorized needs an authorization ID, the model usually becomes an enum plus a collection of nullable fields. That design permits invalid combinations: an authorized payment with a decline reason, or a pending payment with a settlement timestamp.
A union expresses the intended structure more honestly.
A union is a bounded family of data shapes
A discriminated union describes a value that is exactly one member of a fixed set of alternatives. Each alternative may carry its own data.
Conceptually, a payment authorization outcome can be written like this:
The important feature is not the keyword. It is the contract:
AuthorizationResultcan only be one of the declared cases.- Each case owns the fields relevant to that state.
- Consumers can pattern-match every valid possibility.
- Adding a new case becomes a deliberate compatibility event.
That makes consuming code substantially clearer:
In a fully closed union, this switch is exhaustive without an “impossible” default branch. That is not just cleaner syntax. It turns a class of runtime surprises into compiler-visible maintenance work.
When a future BlockedByRiskPolicy case is introduced, every exhaustive match that needs to understand it can be surfaced by diagnostics. The compiler becomes a map of the places where the business meaning of the model must be updated.
Closed classes protect an intentional hierarchy
Unions solve the strongest version of the problem: a finite set of alternatives that should be consumed exhaustively. Closed classes support a related design need.
A closed class represents a type whose inheritance boundary is intentionally restricted. Rather than exposing an extension point to any consumer, the model owner retains control over which derived types can exist within the permitted scope.
This matters whenever derived types participate in security decisions, serialization contracts, persistence mappings, or exhaustive business rules.
Consider an identity proofing workflow. An open VerificationDecision hierarchy sounds extensible, but it may be dangerous:
If another assembly can introduce TemporaryApproval : VerificationDecision, downstream authorization code may treat it as trusted simply because it inherits from the expected base type.
A closed hierarchy makes that extension impossible outside its declared boundary. The type designer controls the recognized subtypes, and application code can reason about the complete family. That is useful even when the hierarchy is not modeled as a union, such as framework-owned abstractions with internal implementation variants.
The distinction is practical:
| Tool | Primary intent |
| --- | --- |
| sealed class | No subtype is allowed. |
| Open base class | Subtypes may be introduced by anyone with access. |
| Closed class | Subtypes are constrained to an intended boundary. |
| Union | A closed family of named alternatives, designed for case analysis. |
Use a sealed class when there is only one concrete shape. Use a union when a value is one of several known shapes. Use a closed class when controlled inheritance itself is the central requirement.
Put invariants inside each case
The biggest modeling improvement does not come from replacing if statements with switch expressions. It comes from moving state-specific validity rules into the case that owns them.
A common anti-pattern is a single mutable record:
Nothing stops callers from creating a shipment whose status is Delivered but whose DeliveredAt is null. Nothing stops a failed shipment from retaining a tracking number that was never assigned.
A union-shaped model makes invalid state harder to represent:
The constructor of Delivered can reject an empty tracking number. Failed has no delivery timestamp because that field does not belong to its meaning. The object graph itself documents the workflow.
This does not eliminate all validation. Cross-entity rules, database constraints, and external API validation still matter. But it removes a large category of contradictory in-memory state before it reaches those boundaries.
Design for evolution, not just exhaustiveness
Closed models trade extension freedom for certainty. That trade is valuable, but it changes how teams should version APIs.
Adding a union case is often source-breaking for consumers with exhaustive switches. It can also be behavior-breaking for clients that deserialize a discriminator they do not recognize. Treat a new case like a meaningful contract change, not a minor implementation detail.
For public REST APIs, use an explicit discriminator such as kind and document unknown-value behavior. A client may need an Unknown representation at the transport boundary even if the domain union remains strict. This creates a useful separation:
- Transport model: resilient to future payloads and imperfect external data.
- Translation layer: validates the discriminator and logs or rejects unsupported variants.
- Domain union: contains only cases the application genuinely understands.
The same caution applies to persistence. Relational storage usually benefits from a discriminator column plus columns for shared and case-specific fields. Document which discriminator values are supported, and ensure migrations, indexes, and constraints are updated whenever a new case is added.
With EF Core, do not assume a sophisticated language model automatically produces the ideal table mapping. Inspect generated migrations and SQL, particularly when alternatives have different required properties. A clean union in application code still needs an intentional persistence strategy.
Start with the unstable conditional hotspots
The best candidates for unions are not every class with two subclasses. Start where the codebase repeatedly asks the same question:
- “What kind of result is this?”
- “Which status are we in?”
- “What failure occurred?”
- “Which command payload was received?”
- “Which authorization path applies?”
Search for long switch statements over enum values, nullable property clusters, type checks, and default branches that throw NotSupportedException. Those are often signs that a finite domain is being represented indirectly.
The .NET 11 preview work around unions and closed classes is compelling because it gives C# a more precise vocabulary for those domains. Instead of relying on conventions such as “please do not derive from this base class” or “these nullable fields are only valid in this status,” developers can encode the boundary in the type model.
That precision pays off in refactoring, testing, API design, and everyday readability. A finite business concept should look finite in code. Unions and closed classes make that intent much harder to ignore.
No comments yet. Be the first to share your thoughts!