# Closed Hierarchies in C# 15: Every Victory Condition Accounted For

> C# 15 closed hierarchies give you class hierarchies the compiler knows are complete. Learn the rules, the assembly boundary, and when to pick them over unions.

- URL: https://blog.nimblepros.com/blogs/closed-hierarchies-in-csharp-15-every-victory-condition-accounted-for/
- Author: [Sarah Dutkiewicz](https://blog.nimblepros.com/authors/sadukie/)
- Published: 2026-10-20
- Updated: 2026-11-06
- Series: [Tiles & Tokens](https://blog.nimblepros.com/series/tiles-and-tokens/)
- Categories: [Software Development](https://blog.nimblepros.com/categories/software-development/), [Domain-Driven Design](https://blog.nimblepros.com/categories/domain-driven-design/)
- Publisher: [NimblePros](https://blog.nimblepros.com/)

---

*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:

```csharp
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:

```csharp
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](../discriminated-unions-in-csharp-15-a-turn-is-exactly-one-thing/), 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](https://github.com/NimblePros/tiles-and-tokens-net-11/tree/post-3) 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:

```csharp
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:

```text
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 record` and `closed class` both work.**
- **`closed interface` does not:** *CS0106: The modifier 'closed' is not valid for this item.*
- **Generic closed types work:** `public closed record Tree<T>;` with `public 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:

```csharp
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:

```shell
dotnet build src/TilesAndTokens.Domain -p:DefineConstants=PROVE_CLOSED_EXHAUSTIVENESS
```

```text
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 a `null` arm: *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 `null` that sneaks in anyway (say, through `null!`) throws `SwitchExpressionException` at 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:

```csharp
/// <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:

```csharp
// 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";
}
```

```shell
dotnet build proofs/TilesAndTokens.Domain.Extensions
```

```text
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:

```csharp
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:

```csharp
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:

```csharp
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:

```csharp
/// <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:

```csharp
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:

- **`PlacementRule` puts the logic in the subtypes**, through an abstract `Check` method.
- **`VictoryCondition` puts the logic in a switch**, in `VictoryChecker`.

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:

```shell
dotnet run --project src/TilesAndTokens.Demo -- closed
```

```text
--- 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`, and `Card`. They're plain data with no shared behavior.
- **Closed hierarchies** for rules and win conditions: `PlacementRule` and `VictoryCondition`. 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:

1. Write `closed`, not `closed abstract`.
2. Seal the subtypes, or you've left a hole in the boundary.
3. Exhaustiveness only applies to switch expressions, and only fails the build if CS8509 is an error.
4. The biggest win is for logic that lives *outside* the hierarchy.

To try it yourself:

```shell
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.
