EF Core 11: Querying Deeply Nested Game State Without Schema Friction

October 27, 2026EF Core 11: Querying Deeply Nested Game State Without Schema Friction
Sarah Dutkiewicz

Sarah Dutkiewicz, Senior Trainer

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’s Terrain and OwnerId), stored as one jsonb column.
  • Summary → Leader and Summary → 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):

QueryPlanMedian
LINQ over JSONSeq Scan + jsonb_to_recordset53.4 ms
Containment, planner’s choiceParallel Seq Scan75.1 ms
Containment, GIN forced (enable_seqscan = off)BitmapOr of GIN scans3.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 mapResult 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. VictoryCondition becomes a VictorySnapshot with a Kind and 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 VictoryCondition were 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 the GameAction turn log, is stored in StateJson using the part 4 serializer, and Restore() reads it back. It’s labeled FALLBACK in 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:

ChangeMigration EF generatesExisting 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 schemaOld 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-build right after migrations add uses 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 to jsonb, not json.
  • The column indexes are ordinary B-trees. GIN with jsonb_path_ops only 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:

  1. Part 2 - unions. The player’s choice is a GameAction: exactly one of RollDice, PlayCard, or Pass. The turn engine switches over it with no discard arm.
  2. Part 3 - closed hierarchies. A PlayCard with a tile runs through the closed PlacementRule hierarchy, and comes back as a PlacementResult union: Placed, Rejected, or Blocked. After each turn, VictoryChecker switches over the closed VictoryCondition.
  3. Part 4 - System.Text.Json. The whole GameState - turn log of unions included - is serialized into StateJson.
  4. Part 5 - EF Core 11. The board goes into a jsonb column, 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.md records 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.