This post is part 5 of a 6-part series on domain modeling with C# 15 and .NET 11, told through a small board game.
“Find every saved game where player-042 has a tile on the edge of the board.”
In part 4, we got Tiles & Tokens saving and loading as JSON files. That’s fine for one player’s save folder. But once saved games live in a database, people start asking questions across them: which games is this player leading, which games use a given victory condition, who’s close to the edge.
A board holds tiles, a tile has a position, and a position has an X and a Y. The classic relational answer is a Tiles table with a foreign key back to the game, plus a join for every query. But tiles don’t have an identity of their own - a tile at (0,3) is just a value inside a board. That’s what EF Core’s complex types are for, and EF Core 11 lets you index properties nested inside them.
This post covers what worked, what didn’t on the RC1 bits, and where the plan had to change. Then we’ll tie parts 2 through 5 together by following one turn all the way from a union to a database row.
The code for this post is at the post-5 tag of the companion repo, in src/TilesAndTokens.Persistence. It was verified against EF Core 11.0.0-rc.1.26425.128 and the Npgsql provider 11.0.0-rc.1.1, with PostgreSQL started by Aspire 13.6.
A Quick Recap of Complex Types
Complex types have been growing for a few releases:
- EF Core 8 introduced complex types: value objects with no key and no identity, mapped into the owning entity’s table as ordinary columns. They had to be non-nullable, and collections weren’t supported.
- EF Core 10 filled in big gaps: optional complex types, mapping a complex type to a single JSON column with
ToJson(), and complex collections stored as JSON. - EF Core 11 adds the piece this post is about: indexes on properties of nested complex types.
Since EF Core 8, you could store Summary.Leader.PlayerId as a column. Indexing it from the model is what EF Core 11 adds.
The Persistence Shape
Here’s the entity:
public sealed class SavedGame
{
public Guid Id { get; private set; }
public DateTimeOffset SavedAt { get; private set; }
/// <summary>JSON column. Queried by the edge query.</summary>
public BoardSnapshot Board { get; private set; } = null!;
/// <summary>Ordinary columns, with indexes on nested properties.</summary>
public GameSummary Summary { get; private set; } = null!;
/// <summary>
/// FALLBACK for unions: the whole GameState (including the GameAction turn log) as written by
/// the Post 4 serializer. EF Core 11 RC1 can't map union-typed properties.
/// </summary>
public string StateJson { get; private set; } = null!;
// Capture(GameState) and Restore() ...
}
It holds two nested shapes, mapped two different ways:
Board → Tiles → Position(plus each tile’sTerrainandOwnerId), stored as onejsonbcolumn.Summary → LeaderandSummary → Victory, stored as ordinary columns - this is called table splitting.
And here’s the configuration:
protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.Entity<SavedGame>(game =>
{
game.HasKey(g => g.Id);
// Board -> Tiles -> Position: nested complex types in one jsonb column.
game.ComplexProperty(g => g.Board, board =>
{
board.ToJson();
board.ComplexCollection(b => b.Tiles, tile =>
{
tile.ComplexProperty(t => t.Position);
tile.Property(t => t.Terrain).HasConversion<string>();
});
});
// Summary -> Leader / Victory: nested complex types in ordinary columns (table splitting).
game.ComplexProperty(g => g.Summary, summary =>
{
summary.ComplexProperty(s => s.Leader);
summary.ComplexProperty(s => s.Victory);
});
game.Property(g => g.StateJson).HasColumnType("jsonb");
// EF Core 11: indexes on properties of nested complex types.
game.HasIndex(g => g.Summary.Leader.PlayerId);
game.HasIndex(g => new { g.Summary.Victory.Kind, g.Summary.Leader.Score });
});
Why two different mappings? Because the plan was to put everything in columns, and EF Core said no.
A complex collection must be mapped to JSON. Tiles is a list, and EF Core requires complex collections to use ToJson(). And once a collection is JSON, its root has to be JSON too:
Complex property 'SavedGame.Board#BoardSnapshot.Tiles' is mapped to JSON but its containing type 'SavedGame.Board#BoardSnapshot' is not. Map the root complex type to JSON.
So the board is one jsonb column, and table splitting isn’t an option for tiles. The summary has no collections, so it can live in columns - and that turns out to matter a lot for indexing.
What’s New in EF Core 11: Indexes on Nested Complex Properties
Those two HasIndex calls are the headline feature. They reach two levels into complex types, using plain lambda syntax:
game.HasIndex(g => g.Summary.Leader.PlayerId);
game.HasIndex(g => new { g.Summary.Victory.Kind, g.Summary.Leader.Score });
The generated migration is ordinary EF Core:
migrationBuilder.CreateIndex(
name: "IX_SavedGames_Summary_Leader_PlayerId",
table: "SavedGames",
column: "Summary_Leader_PlayerId");
migrationBuilder.CreateIndex(
name: "IX_SavedGames_Summary_Victory_Kind_Summary_Leader_Score",
table: "SavedGames",
columns: new[] { "Summary_Victory_Kind", "Summary_Leader_Score" });
And so is the DDL from dotnet ef migrations script:
CREATE TABLE "SavedGames" (
"Id" uuid NOT NULL,
"SavedAt" timestamp with time zone NOT NULL,
"StateJson" jsonb NOT NULL,
"Summary_Round" integer NOT NULL,
"Summary_TurnCount" integer NOT NULL,
"Summary_Leader_PlayerId" text NOT NULL,
"Summary_Leader_Score" integer NOT NULL,
"Summary_Victory_Kind" text NOT NULL,
"Summary_Victory_Rounds" integer,
"Board" jsonb NOT NULL,
CONSTRAINT "PK_SavedGames" PRIMARY KEY ("Id")
);
CREATE INDEX "IX_SavedGames_Summary_Leader_PlayerId" ON "SavedGames" ("Summary_Leader_PlayerId");
CREATE INDEX "IX_SavedGames_Summary_Victory_Kind_Summary_Leader_Score" ON "SavedGames" ("Summary_Victory_Kind", "Summary_Leader_Score");
Summary.Leader.PlayerId becomes the column Summary_Leader_PlayerId, and the index lands right on it. (Those quoted, mixed-case identifiers are Postgres being Postgres. If they’ve ever tripped you up, I covered that in Using EF Core with Postgres.) Three levels deep works too: HasIndex(x => x.Standings.Leader.Player.Id) produced an index on "Standings_Leader_Player_Id" in a scratch model.
Indexes into the JSON column: accepted, but not useful on Npgsql RC1
The original plan for this post was to index into the board: Tiles[].OwnerId and Tiles[].Position.X. EF Core 11 does accept that:
game.HasIndex(g => g.Board.Tiles[0].OwnerId);
There’s also a new metadata API, IMutableEntityType.AddIndex(properties, collectionIndices), where null means “every element.” But on Npgsql 11 RC1, both produce this:
CREATE INDEX "IX_SavedGames_Board_Tiles_OwnerId" ON "SavedGames" ("Board");
That’s a B-tree over the whole Board column, not over the owner IDs inside it. The edge query can’t use it, so the repo doesn’t configure one. The same thing happens for a nested property inside a JSON-mapped complex type that isn’t a collection. Indexing into JSON is a provider job, so this is worth re-checking as the provider moves toward GA.
So the plan changed: nested complex properties mapped to columns get real, usable EF Core indexes. Properties inside the JSON column don’t, yet.
Querying
The repo has three queries, and each one returns IQueryable<SavedGame> so the demo can print the SQL with ToQueryString(). The plans below are real EXPLAIN ANALYZE output, captured against 1,000 seeded games after running ANALYZE.
Games by victory condition, ordered by leader score
public IQueryable<SavedGame> UsingVictory(string victoryKind) =>
db.SavedGames.AsNoTracking()
.Where(g => g.Summary.Victory.Kind == victoryKind)
.OrderByDescending(g => g.Summary.Leader.Score);
SELECT s."Id", s."SavedAt", s."StateJson", s."Board", s."Summary_Round", ...
FROM "SavedGames" AS s
WHERE s."Summary_Victory_Kind" = @victoryKind
ORDER BY s."Summary_Leader_Score" DESC
LIMIT @p
Limit (actual rows=5.00 loops=1)
Buffers: shared hit=7
-> Index Scan Backward using "IX_SavedGames_Summary_Victory_Kind_Summary_Leader_Score" on "SavedGames" s (actual rows=5.00 loops=1)
Index Cond: ("Summary_Victory_Kind" = 'firstToEdge'::text)
Index Searches: 1
Buffers: shared hit=7
The composite index on two nested properties handles both the filter and the sort - it scans the index backward and stops after five rows.
Games a player is leading
public IQueryable<SavedGame> LedBy(PlayerId player)
{
var playerId = player.Value;
return db.SavedGames.AsNoTracking().Where(g => g.Summary.Leader.PlayerId == playerId);
}
Bitmap Heap Scan on "SavedGames" s (actual rows=5.00 loops=1)
Recheck Cond: ("Summary_Leader_PlayerId" = 'player-042'::text)
Heap Blocks: exact=5
Buffers: shared hit=7
-> Bitmap Index Scan on "IX_SavedGames_Summary_Leader_PlayerId" (actual rows=5.00 loops=1)
Index Cond: ("Summary_Leader_PlayerId" = 'player-042'::text)
Index Searches: 1
Buffers: shared hit=2
Postgres picks both nested-property indexes even at 1,000 rows. That’s the part of EF Core 11 that just works.
The edge query
Now the question from the top of this post:
public IQueryable<SavedGame> WithTileOnEdge(PlayerId player)
{
var playerId = player.Value;
return db.SavedGames.AsNoTracking().Where(g => g.Board.Tiles.Any(t =>
t.OwnerId == playerId
&& (t.Position.X == 0 || t.Position.X == LastIndex || t.Position.Y == 0 || t.Position.Y == LastIndex)));
}
The good news: EF Core 11 translates LINQ over a JSON complex collection, nested Position and all:
SELECT s."Id", s."SavedAt", s."StateJson", s."Board", ...
FROM "SavedGames" AS s
WHERE EXISTS (
SELECT 1
FROM ROWS FROM (jsonb_to_recordset(s."Board" -> 'Tiles') AS (
"OwnerId" text,
"Position" jsonb
)) WITH ORDINALITY AS t
WHERE t."OwnerId" = @playerId AND ((CAST(t."Position" ->> 'X' AS integer)) = 0 OR (CAST(t."Position" ->> 'X' AS integer)) = 7 OR (CAST(t."Position" ->> 'Y' AS integer)) = 0 OR (CAST(t."Position" ->> 'Y' AS integer)) = 7))
The catch is the plan:
Seq Scan on "SavedGames" s (actual rows=9.00 loops=1)
Filter: EXISTS(SubPlan 1)
Rows Removed by Filter: 991
Buffers: shared hit=213
SubPlan 1
-> Function Scan on jsonb_to_recordset t (actual rows=0.01 loops=1000)
...
A sequential scan, unpacking every board’s tiles row by row. No index can serve jsonb_to_recordset. It’s correct, and fine at this size, but it’s not going to scale.
A Postgres-native option: GIN and containment
Postgres can index JSON for containment queries (@>) with a GIN index. That’s not something the EF model can express on RC1, so the repo adds it in a hand-written migration that’s clearly labeled:
/// <summary>
/// POSTGRES-NATIVE, hand-written: a GIN index over the Board jsonb column for containment (@>)
/// queries. Not expressible in the EF Core model ...
/// </summary>
public partial class _20261002161723_AddBoardGinIndex : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""CREATE INDEX "IX_SavedGames_Board_Gin" ON "SavedGames" USING GIN ("Board" jsonb_path_ops);""");
}
// Down drops it ...
}
The edge query is then rewritten as four containment checks, one per edge, in raw SQL that selects IDs and gets composed back into LINQ. Two more RC1 limits shaped that design: FromSql on SavedGame throws a NullReferenceException, and EF.Functions.JsonContains doesn’t translate for a complex JSON property. Selecting IDs with Database.SqlQuery<Guid> and composing with ids.Contains(g.Id) works.
Did it help? Here are the same queries at 20,000 games, as the median of 9 runs (a separate run against a scratch PostgreSQL 17 container):
| Query | Plan | Median |
|---|---|---|
| LINQ over JSON | Seq Scan + jsonb_to_recordset | 53.4 ms |
| Containment, planner’s choice | Parallel Seq Scan | 75.1 ms |
Containment, GIN forced (enable_seqscan = off) | BitmapOr of GIN scans | 3.5 ms |
When the GIN index is used, it’s about 15× faster. But Postgres doesn’t choose it on its own. It keeps no statistics for how selective a jsonb @> condition is, so it guesses - and here it guessed a parallel sequential scan that was slower than the LINQ version. At 1,000 games it scanned the table, too.
That’s an honest result, and a useful one. If this query matters in production, consider skipping both JSON approaches and project the edge information into columns - say, a computed “touches an edge” summary per player, which is exactly the kind of thing EF Core 11 can index well. The repo’s docs/post-5-sql-and-plans.md has every query, plan, and timing.
Unions and Closed Hierarchies in EF Core
You may have noticed the persistence model uses BoardSnapshot, TileSnapshot, and GameSummary - not the domain records from parts 2 and 3. That’s not a style choice. Here’s what happened trying to map the domain types directly on RC1:
| Tried to map | Result on RC1 |
|---|---|
Domain Board(IReadOnlyList<Tile> Tiles) as a JSON complex type | ❌ No suitable constructor was found for the type ‘Board’ |
VictoryCondition (closed hierarchy) as a complex type | ❌ Mapped as the base type only: Complex type … has no properties defined. Complex types have no inheritance. |
GameAction (union) as a property | ❌ The ‘GameAction’ property … could not be mapped because the database provider does not support this type. |
GameAction as a complex property | ❌ The type ‘Row.LastAction#GameAction (GameAction)’ cannot be mapped because union types are not supported. See https://github.com/dotnet/efcore/issues/36375 for more information. |
That last one is worth noticing: EF Core 11 recognizes unions and rejects them on purpose, with a link to the tracking issue. Support is on the radar; it’s just not in this release.
So the repo maps around them:
The closed hierarchy is flattened.
VictoryConditionbecomes aVictorySnapshotwith aKindand the only case-specific value,Rounds:// Exhaustive, no discard arm: a new VictoryCondition breaks the build here too. public static VictorySnapshot From(VictoryCondition victory) => victory switch { Domain.MostPoints mostPoints => new() { Kind = MostPoints, Rounds = mostPoints.Rounds }, Domain.FirstToEdge => new() { Kind = FirstToEdge }, Domain.LastPlayerStanding => new() { Kind = LastPlayerStanding }, };Here’s the payoff from part 3: this switch has no discard arm, so adding a victory condition breaks the build in the persistence layer too, not just in the game rules. That’s exactly the “logic outside the hierarchy” case closed types are best at.
If
VictoryConditionwere an entity with its own table, EF Core’s table-per-hierarchy mapping with a discriminator column would be the usual route. Here it’s a value inside a saved game, so it stays a complex type - and complex types don’t do inheritance. (TPH wasn’t tried in this repo.)Unions fall back to JSON. The full
GameState, including theGameActionturn log, is stored inStateJsonusing the part 4 serializer, andRestore()reads it back. It’s labeledFALLBACKin the code.
One small surprise there: jsonb doesn’t preserve key order, so the "kind" property from part 4 doesn’t stay first. It still round-trips, because the part 4 classifier looks for "kind" anywhere in the object, and a test (SaveAndLoad_RestoresTheWholeGameState) proves it.
Gotchas
Changing the nested shape
Two shape changes were tested on a scratch copy of the model:
| Change | Migration EF generates | Existing rows |
|---|---|---|
Add LeaderSnapshot.TileCount (column-mapped) | AddColumn<int>("Summary_Leader_TileCount", nullable: false, defaultValue: 0) | Normal |
Add TileSnapshot.Rotation (inside the Board JSON) | Nothing - the JSON column has no schema | Old rows load: int Rotation reads 0, int? Rotation reads null. No error. |
The second one is the one to watch. Adding a property inside a JSON column is frictionless - no migration at all - but that also means nothing reminds you that old rows don’t have it. If “0” is a meaningful value, make the property nullable so “missing” and “zero” stay different.
Nullable nested complex types
An optional complex property mapped to columns (Leader? Leader) is accepted, and every one of its columns becomes nullable - "Leader_Score" integer with no NOT NULL, even though Score is a non-nullable int in C#. Only the DDL was checked; a round trip of a null Leader wasn’t tested.
Migrations on RC1
- Migration class names include the timestamp:
public partial class _20261002155612_InitialCreate : Migration. Harmless, but surprising the first time. - Build before scripting. Running
dotnet ef migrations script --no-buildright aftermigrations adduses the old assembly and silently leaves the new migration out. - The repo applies migrations at demo startup with
Database.MigrateAsync(). That’s a dev-only shortcut - don’t do that in production.
Provider differences
Everything here is Npgsql-specific in the details:
ToJson()complex types map tojsonb, notjson.- The column indexes are ordinary B-trees. GIN with
jsonb_path_opsonly helps containment. - Indexing into JSON is where providers differ most, and on Npgsql RC1 it isn’t there yet. If you’re on SQL Server, check what your provider generates before relying on it.
Aspire
Postgres, plus a pgweb UI, runs through an Aspire AppHost - no Docker Compose file:
var builder = DistributedApplication.CreateBuilder(args);
var postgres = builder.AddPostgres("postgres")
.WithPgWeb();
var database = postgres.AddDatabase("tilesandtokens");
builder.AddProject<Projects.TilesAndTokens_Demo>("demo")
.WithArgs("all")
.WithReference(database)
.WaitFor(database);
builder.Build().Run();
The persistence tests use Aspire.Hosting.Testing against the same AppHost, so tests and demos hit a real Postgres. Two notes: the installed dotnet new aspire-apphost template was stale on the dev machine (it generated Aspire 9.5.1 on net9.0), so the AppHost was written by hand for 13.6. And the Aspire Npgsql EF integration, which targets EF Core 10, worked fine alongside the EF Core 11 RC1 provider.
Putting It Together: One Turn, End to End
Let’s follow one turn through everything we’ve built so far. The efcore demo ends by doing exactly this:
var state = GameSetup.NewGame([alice, bob], StandardDeck.Create(), new SeededDeckShuffler(Seed), new FirstToEdge());
// Post 2 + 3: union actions, validated by the closed placement rules.
for (var turn = 0; turn < 6; turn++)
{
var action = SimpleStrategy.ChooseAction(state);
if (engine.Apply(state, action) is ActionApplied applied)
{
state = applied.State;
}
}
// Post 4 + 5: STJ snapshot inside an EF Core entity.
var saved = SavedGame.Capture(state, TimeProvider.System);
db.SavedGames.Add(saved);
await db.SaveChangesAsync();
var found = await queries.WithTileOnEdge(alice).AnyAsync(g => g.Id == saved.Id);
var loaded = (await db.SavedGames.AsNoTracking().SingleAsync(g => g.Id == saved.Id)).Restore();
Here’s what happens to one tile placement along the way:
- Part 2 - unions. The player’s choice is a
GameAction: exactly one ofRollDice,PlayCard, orPass. The turn engine switches over it with no discard arm. - Part 3 - closed hierarchies. A
PlayCardwith a tile runs through the closedPlacementRulehierarchy, and comes back as aPlacementResultunion:Placed,Rejected, orBlocked. After each turn,VictoryCheckerswitches over the closedVictoryCondition. - Part 4 - System.Text.Json. The whole
GameState- turn log of unions included - is serialized intoStateJson. - Part 5 - EF Core 11. The board goes into a
jsonbcolumn, the summary into indexed columns, and the edge query finds the game in the database.Restore()brings back the full game, unions and all.
The repo’s AppHost_RunsTheWholeSeries test runs the real AppHost, which runs every demo from part 1 to part 5, and checks that the edge query at the end finds Alice’s game.
To run every demo so far yourself, with a container runtime (Docker or Podman) running:
git checkout post-5
dotnet run --project src/TilesAndTokens.AppHost
Takeaway
EF Core 11’s nested complex type indexes are the real deal when the nested data lives in columns: HasIndex(g => g.Summary.Leader.PlayerId) is one line, the migration is boring in the best way, and Postgres uses the index even on small tables. Inside JSON columns, the story on RC1 is “query, yes; index, not yet” - and C# 15 unions aren’t mappable at all, though EF Core knows they exist and says so clearly.
The pattern that has held up across the series so far: put the rules in the type system, and let the edges of your app - JSON files, database rows - adapt to it. Unions and closed hierarchies made the domain model safer. Serialization and persistence needed some help on RC1, but every workaround in the repo is labeled, tested, and easy to delete when GA and later releases catch up. When .NET 11 ships, the companion repo will be re-verified against the GA bits.
Where to Go from Here
- The code: NimblePros/tiles-and-tokens-net-11, with a tag for every post.
docs/feature-status.mdrecords exactly what was verified on which SDK. - The talk: this series is based on my 30-minute session on C# 15 and .NET 11 domain modeling. If you’d like it at your company or event, let us know.
- Training and consulting: if your team is planning a move to .NET 11, wants to adopt unions and closed hierarchies, or is wrestling with domain modeling and EF Core in a real codebase, NimblePros can help. Contact us today!
We thought this would be the last post in the series, but the demo had one more surprise. While checking that the save files from part 4 were trimming-friendly, we took the whole app through a Native AOT publish. Next up in part 6, we’ll look at what it took, which parts of the series compiled cleanly ahead of time, and which ones didn’t.

