This post is part 3 of a 6-part series on domain modeling with C# 15 and .NET 11, told through a small board game.
Here’s a bug that doesn’t look like a bug. In C# 14, Tiles & Tokens might model its victory conditions like this:
public abstract record VictoryCondition;
public sealed record MostPoints(int Rounds) : VictoryCondition;
public sealed record FirstToEdge : VictoryCondition;
public sealed record LastPlayerStanding : VictoryCondition;
public static bool IsGameOver(GameState state, VictoryCondition victory) => victory switch
{
MostPoints mostPoints => state.Round > mostPoints.Rounds,
FirstToEdge => /* ... */,
LastPlayerStanding => /* ... */,
_ => false,
};
That _ => false has to be there. The base class is open, so the compiler can’t know these three are the only subtypes, and without a discard arm you get a warning that the switch isn’t exhaustive.
Six months later, someone adds a new way to win:
public sealed record MostTerritory : VictoryCondition;
It compiles. The tests for the existing conditions pass. And every game played with MostTerritory runs forever, because IsGameOver quietly falls through to _ => false on every turn. No exception, no warning - the game just never ends.
In part 2, unions fixed this kind of problem for game actions. But victory conditions aren’t quite like actions. They share things: every condition has a name to show at the start of the game, and they all answer the same question, “is there a winner yet?” That’s what class hierarchies are for. We just need the compiler to know the hierarchy is complete.
That’s what closed hierarchies do in C# 15.
The code for this post is at the post-3 tag of the companion repo. Everything here was verified on the .NET 11 RC1 SDK (11.0.100-rc.1.26425.128).
Declaring a Closed Hierarchy
Add closed to the base type:
public closed record VictoryCondition
{
public abstract string Name { get; }
}
public sealed record MostPoints(int Rounds) : VictoryCondition
{
public override string Name => $"Most points after {Rounds} rounds";
}
public sealed record FirstToEdge : VictoryCondition
{
public override string Name => "First to connect opposite edges";
}
public sealed record LastPlayerStanding : VictoryCondition
{
public override string Name => "Last player standing";
}
closed means only code in this assembly can derive from this type. Since the compiler sees the whole assembly, it knows the complete list of subtypes, and it can check a switch for exhaustiveness the same way it does for a union.
Notice what’s not there: abstract. That’s not an oversight. A closed type is always implicitly abstract, and saying so is an error:
error CS9384: 'A': a closed type cannot be marked abstract because it is always implicitly abstract.
You can still declare abstract members on it, like Name above. Under the hood, the compiler emits an abstract type with protected constructors, marked with [System.Runtime.CompilerServices.IsClosedType].
A few more ground rules, all verified on RC1:
closed recordandclosed classboth work.closed interfacedoes not: CS0106: The modifier ‘closed’ is not valid for this item.- Generic closed types work:
public closed record Tree<T>;withpublic sealed record Leaf<T>(T Value) : Tree<T>;. - It needs C# 15. In a C# 14 project you get CS9327: Feature ‘closed classes’ is not available in C# 14.0. (The compiler calls the feature “closed classes,” even for records.) Like unions, there’s no preview flag needed on .NET 11 RC1.
Exhaustive Pattern Matching
Now the victory check can drop its discard arm:
public static class VictoryChecker
{
/// <returns>The winner, or null if the game isn't over yet.</returns>
public static PlayerId? Check(GameState state, VictoryCondition victory) => victory switch
{
MostPoints mostPoints => CheckMostPoints(state, mostPoints.Rounds),
FirstToEdge => CheckFirstToEdge(state),
LastPlayerStanding => CheckLastPlayerStanding(state),
};
// ...
}
No _ =>, no warning. The compiler knows those are the only three.
So let’s add MostTerritory again. The repo has a build constant for it:
dotnet build src/TilesAndTokens.Domain -p:DefineConstants=PROVE_CLOSED_EXHAUSTIVENESS
VictoryChecker.cs(10,89): error CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'TilesAndTokens.Domain.MostTerritory' is not covered.
It’s the same diagnostic you get from unions, and the same caveat applies: CS8509 is a warning, and it only stops the build because the repo turns on TreatWarningsAsErrors. The game that never ends is now a build that doesn’t pass.
What about null?
A closed type is a reference type, so a variable of that type can be null. Nullable reference types handle that the way you’d hope:
- If the input is nullable (
VictoryCondition?), the compiler asks for anullarm: CS8655: The switch expression does not handle some null inputs (it is not exhaustive). For example, the pattern ’null’ is not covered. - If the input is non-nullable, there’s no warning, and a
nullthat sneaks in anyway (say, throughnull!) throwsSwitchExpressionExceptionat runtime.
That’s a nicer story than unions. Remember the empty default union from part 2, which matches no case and is never flagged? Closed types don’t have that problem. Their “none of the above” value is plain old null, and the compiler already tracks it.
Switch expressions, not statements
Same rule as unions: only switch expressions are checked. This milestone added a third case to the TurnOutcome union from part 2, for when an action ends the game:
/// <summary>The action was legal and it ended the game.</summary>
public sealed record GameOver(GameState State, string Summary, PlayerId Winner);
public union TurnOutcome(ActionApplied, ActionInvalid, GameOver);
That broke the build in a test that used a switch expression, which is exactly what we want. It did not flag the switch statement in the part 2 demo. That one compiled, and it would have hit its fallback throw at runtime the first time someone won. If you want the compiler on your side, switch expressions are the way to get it.
Rules and Boundaries
Where can subtypes live?
The boundary is the assembly - not the file, not the namespace:
| Where the derived type is | Result |
|---|---|
| Same file | Allowed |
| Another file in the same assembly | Allowed |
| Another namespace in the same assembly | Allowed, and it joins the exhaustiveness check |
| Another assembly | CS9382 |
The repo has a proof project, proofs/TilesAndTokens.Domain.Extensions, that tries to extend the game from outside. It’s deliberately left out of the solution so the normal build stays green:
// error CS9382: cannot use a closed type 'VictoryCondition' from another assembly as a base type.
public sealed record MostTerritory : VictoryCondition
{
public override string Name => "Most tiles on the board";
}
dotnet build proofs/TilesAndTokens.Domain.Extensions
MostTerritory.cs(6,22): error CS9382: 'MostTerritory': cannot use a closed type 'VictoryCondition' from another assembly as a base type.
MostTerritory.cs(12,22): error CS9382: 'NoDesertRule': cannot use a closed type 'PlacementRule' from another assembly as a base type.
Also worth knowing: setting the other project to LangVersion=14 doesn’t get around it. The consuming compiler still enforces the boundary.
That boundary is the trade-off to think about. If you ship a library and want consumers to add their own subtypes, closed is the wrong tool - an open abstract base is still the right call there. closed is for domain types where “we own the complete list” is part of the design.
closed vs. sealed vs. abstract
sealed: nobody can derive from it.abstract: anybody can derive from it, anywhere.closed: only this assembly can derive from it, and it’s implicitly abstract.
Do the subtypes have to be sealed?
No - and this is a subtle one. A direct subtype of a closed type can be left open:
public record OpenChild : VictoryCondition { /* ... */ }
That compiles, and exhaustiveness still holds: a switch over VictoryCondition must handle OpenChild, and that arm covers anything derived from it. But OpenChild is a hole in the boundary. Another assembly can derive from OpenChild, because OpenChild isn’t closed.
Every subtype in the repo is sealed. Unless you have a reason not to, I’d do the same.
Nested hierarchies
Intermediate types can be closed too:
public closed record Timed : VictoryCondition;
public sealed record Sprint(int Turns) : Timed
{
public override string Name => $"Sprint: {Turns} turns";
}
A switch over VictoryCondition is now exhaustive when it handles Sprint - all of Timed’s subtypes - instead of Timed itself. And another assembly that tries to derive from Timed gets CS9382 as well. This lets you group related cases without giving up the guarantee.
Placement Validation: Closed Rules, Union Results
In part 2, placement validation was one method with a series of if checks. In this milestone, each rule becomes its own type in a closed hierarchy:
public closed record PlacementRule
{
public abstract PlacementResult Check(Board board, Tile candidate);
protected static Placed Pass(Tile candidate) => new(candidate, PlacementValidator.PointsPerTile);
protected static List<Tile> NeighborsOf(Board board, Position position) =>
position.Neighbors()
.Select(board.TileAt)
.OfType<Tile>()
.ToList();
}
Each rule overrides Check, and uses the shared helpers from the base:
/// <summary>After a player's first tile, each new tile must touch one of their own.</summary>
public sealed record AdjacencyRule : PlacementRule
{
public override PlacementResult Check(Board board, Tile candidate)
{
var isFirstTile = !board.HasTilesOwnedBy(candidate.Owner);
var touchesOwnTile = NeighborsOf(board, candidate.Position).Any(t => t.Owner == candidate.Owner);
if (!isFirstTile && !touchesOwnTile)
{
return new Rejected($"{candidate.Position} is not next to any of your tiles.", PlacementRuleId.Adjacency);
}
return Pass(candidate);
}
}
InBoundsRule, CellEmptyRule, and TerrainCompatibilityRule follow the same pattern. Here’s where the two features meet: every rule returns the PlacementResult union from part 2 - Placed, Rejected, or Blocked. The validator runs them in order and stops at the first one that doesn’t pass:
public static class PlacementValidator
{
public const int PointsPerTile = 1;
/// <summary>Checked in this order. The first rule that doesn't return Placed decides the result.</summary>
public static IReadOnlyList<PlacementRule> Rules { get; } =
[
new InBoundsRule(),
new CellEmptyRule(),
new AdjacencyRule(),
new TerrainCompatibilityRule(),
];
public static PlacementResult Validate(Board board, Tile candidate)
{
foreach (var rule in Rules)
{
var result = rule.Check(board, candidate);
if (result is not Placed)
{
return result;
}
}
return new Placed(candidate, PointsPerTile);
}
}
The closed hierarchy carries the behavior (each rule’s check). The union carries the outcome (what happened). Neither one is trying to do the other’s job.
Two ways to use a closed hierarchy
You might have noticed that PlacementRule and VictoryCondition are used differently:
PlacementRuleputs the logic in the subtypes, through an abstractCheckmethod.VictoryConditionputs the logic in a switch, inVictoryChecker.
Both are fine, and it’s worth being honest about what closed adds to each. With an abstract method, a new subtype that forgets to implement it is already a compile error, closed or not. That’s been true of abstract classes forever.
Where closed really earns its keep is the second style: logic that lives outside the hierarchy. Victory checking needs the whole game state and a breadth-first search across the board for FirstToEdge. That doesn’t belong on a small record whose job is to describe a rule. And later in this series, other code will switch over VictoryCondition too - persistence in part 5 flattens it into database columns with a switch that has no discard arm, so a new victory condition breaks the build there as well. You can’t put that kind of code on the base type without dragging persistence into your domain model. A closed hierarchy lets every one of those switches be exhaustive.
See It Run
The closed demo tries one placement per rule, then plays a short game to a FirstToEdge win:
dotnet run --project src/TilesAndTokens.Demo -- closed
--- One placement per rule ---
Rules, in order: InBoundsRule -> CellEmptyRule -> AdjacencyRule -> TerrainCompatibilityRule
Plains at (8,2) (off the board)
!! Rejected by InBounds: (8,2) is off the board.
Plains at (5,5) (on bob's tile)
!! Blocked: (5,5) is occupied
Plains at (6,6) (far from alice's tiles)
!! Rejected by Adjacency: (6,6) is not next to any of your tiles.
Water at (2,3) (water next to mountain)
!! Rejected by TerrainCompatibility: Water can't be placed next to Mountain.
Forest at (2,3) (forest next to mountain)
-> Placed (+1 pt)
--- A short game: First to connect opposite edges ---
alice holds 8 Plains tiles and builds across row 3. bob just rolls dice.
-> alice placed Plains at (0,3) (+1 pt).
-> bob rolled 1+2 (+3 pts).
...
-> alice placed Plains at (7,3) (+1 pt).
-> Game over: alice wins (First to connect opposite edges).
. . . . . . . .
. . . . . . . .
. . . . . . . .
A A A A A A A A
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
Bob racked up 55 points rolling dice, and it didn’t matter. The game was played for FirstToEdge, and Alice connected the left edge to the right.
Union or Closed Hierarchy? A Checklist
After building both into the same game, here’s how I’d decide:
| Question | Union | Closed hierarchy |
|---|---|---|
Do the cases already exist as unrelated types (including int, string, or types you don’t own)? | ✅ | ❌ They must derive from the base. |
Do the cases share behavior or data (an abstract Check, a Name)? | Only through a member that switches over this | ✅ Virtual and abstract members |
| Can a value be “none of the above”? | ⚠️ default is an empty union, never flagged | ⚠️ null, flagged for nullable inputs |
| Allocation | Struct wrapper; value-type cases are boxed | One object per value, as usual |
| Who can add cases? | Only the union declaration | Any type in the same assembly |
In Tiles & Tokens, that worked out to:
- Unions for actions and outcomes:
GameAction,PlacementResult,TurnOutcome, andCard. They’re plain data with no shared behavior. - Closed hierarchies for rules and win conditions:
PlacementRuleandVictoryCondition. Each case carries its own behavior or description.
Or, in one line each: use a union when you’re describing what happened; use a closed hierarchy when you’re describing how something works.
Takeaway
closed gives you a class hierarchy with a complete, compiler-known list of subtypes. You keep everything hierarchies are good at - shared members, abstract methods, helpers on the base - and gain the exhaustiveness checking that unions have. The boundary is the assembly, which makes it a great fit for domain models and the wrong fit for extension points you want others to implement.
A few things to remember:
- Write
closed, notclosed abstract. - Seal the subtypes, or you’ve left a hole in the boundary.
- Exhaustiveness only applies to switch expressions, and only fails the build if CS8509 is an error.
- The biggest win is for logic that lives outside the hierarchy.
To try it yourself:
git checkout post-3
dotnet test
dotnet run --project src/TilesAndTokens.Demo -- closed
dotnet build src/TilesAndTokens.Domain -p:DefineConstants=PROVE_CLOSED_EXHAUSTIVENESS
dotnet build proofs/TilesAndTokens.Domain.Extensions
So far, everything has lived in memory. Next up in part 4, we’ll save a game mid-play with System.Text.Json and load it back - and find out how much of this union and closed hierarchy state it can handle out of the box.

