Chapter 10 · Loading Related Data: Eager, Explicit, Lazy, Filtered Include, and Split Queries

Include and ThenInclude: Loading Graphs Without Losing Sight of Result Cardinality

Use Include and ThenInclude intentionally: correlate object graphs with relational joins, row multiplication, identity resolution, and projection alternatives in the ServiceHub lab.

Intermediate120–150 minuteseager-loading + cardinality labEF Core 10.0.11 · .NET 10.0.11SQLite provider 10.0.11 baselineSDK checkpoint: 10.0.400Last reviewed: August 2026

Learning outcomes

A ServiceHub API now needs a work-order detail graph: assigned technician, SLA snapshot, tags, notes, and attachments. The tempting answer is “add Include until everything is present.” That can return the correct object graph while transferring far more relational rows than expected. This lesson treats eager loading as a query-shape decision: decide the graph, inspect the SQL, predict row cardinality, then choose Include/ThenInclude or projection deliberately.

01

Explain eager loading, reference navigation, collection navigation, Include, ThenInclude, and multiple include paths before using them.

02

Extend existing Chapter 05 note/attachment relationships with principal-side navigations without changing their foreign-key schema.

03

Inspect representative SQL and predict duplicated principal rows before materialization.

04

Distinguish database row multiplication from EF identity resolution in tracking queries.

05

Load nested and derived navigations without assuming object syntax removes relational joins.

06

Repair an over-eager graph by projecting only the shape an endpoint actually needs.

Reproducible baseline

Mandatory labs use .NET 10 SDK 10.0.400, .NET runtime 10.0.11, EF Core/SQLite 10.0.11, the disposable servicehub-lab.db, deterministic seed data, and no paid tooling. SQL Server/PostgreSQL notes are comparative only unless explicitly labeled.

1. Loading strategy is part of the data contract

Eager loading asks EF to load related entities as part of the query. A reference navigation points to zero/one related entity; a collection navigation represents many. Include selects the first navigation edge; ThenInclude continues from an already included edge. These operators do not mean “one SQL statement forever”—split queries can execute eager loading as multiple statements—but they do declare what related entity graph EF should assemble.

Need Better first tool Reason
Entity graph must be tracked and edited Include / ThenInclude Preserves entity instances and tracking semantics.
Read-only API needs a DTO Select projection Selects only required columns and avoids accidental graph serialization.
Relationship is needed only after a condition is known Explicit loading Defers the extra query intentionally.
Navigation access should trigger I/O implicitly Lazy loading — use cautiously Convenient but hides round trips and context dependency.

2. Deliberate Chapter 10 navigation evolution: expose existing collections

Chapter 05 already introduced work_order_notes and work_order_attachments as required dependents, but used unidirectional WithMany() mappings. Chapter 10 adds principal-side collections so loading behavior can be studied. The foreign keys and tables do not change; only the navigability of the EF model changes.

csharp · principal-side navigations over existing relationships
public sealed partial class WorkOrder{    private readonly List<WorkOrderNote> _notes = new();    private readonly List<WorkOrderAttachment> _attachments = new();    public IReadOnlyCollection<WorkOrderNote> Notes => _notes;    public IReadOnlyCollection<WorkOrderAttachment> Attachments => _attachments;}modelBuilder.Entity<WorkOrderNote>()    .HasOne(x => x.WorkOrder)    .WithMany(x => x.Notes)    .HasForeignKey(x => x.WorkOrderId)    .IsRequired();modelBuilder.Entity<WorkOrderAttachment>()    .HasOne(x => x.WorkOrder)    .WithMany(x => x.Attachments)    .HasForeignKey("WorkOrderId")    .IsRequired();
Migration check

Run dotnet ef migrations add Chapter10NavigationsOnly and inspect the generated migration. Because only navigations changed, the expected schema operations are none. If DDL appears, stop and determine what other model metadata drifted before applying anything.

3. Include a reference and a collection

csharp · work-order detail graph
var query = db.WorkOrders    .Where(w => w.WorkOrderNumber == requestedNumber)    .Include(w => w.AssignedTechnician)    .Include(w => w.WorkOrderTags)        .ThenInclude(link => link.Tag)    .TagWith("Chapter10/Lesson1 detail graph");Console.WriteLine(query.ToQueryString());var workOrder = await query.SingleAsync(ct);

For a relational provider, the SQL normally contains left joins for optional/reference edges and joins for collection paths. The exact aliases and selected columns are provider/version details; the relational mechanism is not. A work order with four tag links can appear in four database rows even though the tracking result is one WorkOrder instance with four WorkOrderTag instances.

sql · representative SQLite join shape
SELECT w..., t..., wt..., tag...FROM work_orders AS wLEFT JOIN technicians AS t  ON w.assigned_technician_id = t.idLEFT JOIN work_order_tags AS wt  ON w.work_order_id = wt.work_order_idLEFT JOIN tags AS tag  ON wt.tag_id = tag.idWHERE w.work_order_number = @__requestedNumber_0;

4. Identity resolution does not cancel transferred rows

Tracking queries perform identity resolution: if the same primary key occurs repeatedly in the result, the context reuses one tracked entity instance. That protects graph identity, but it happens after the database produced and transferred rows. If one work order has 5 tags, 4 notes, and 3 attachments, three sibling collection joins can yield up to 5 × 4 × 3 = 60 relational rows for that principal. EF may return one work-order object, but the database/network/materializer still handled the multiplied row set.

csharp · observe identity and tracker state
var graph = await db.WorkOrders    .Include(w => w.WorkOrderTags)    .Include(w => w.Notes)    .Include(w => w.Attachments)    .SingleAsync(w => w.Id == id, ct);Console.WriteLine($"Tags={graph.WorkOrderTags.Count}");Console.WriteLine($"Notes={graph.Notes.Count}");Console.WriteLine($"Attachments={graph.Attachments.Count}");Console.WriteLine(db.ChangeTracker.DebugView.ShortView);

The tracker view proves which entity instances EF is tracking. It does not reveal bytes transferred or the raw joined-row count; use command logging/database tools for that evidence.

5. Multiple paths and derived navigations

When two include paths share a starting navigation, repeat the root include and continue with different ThenInclude chains. EF combines what it can; do not infer the SQL shape from the number of Include calls alone. For an inheritance hierarchy, a derived navigation can be included after narrowing with OfType<TDerived> or by using a cast-supported include form when the model actually contains that navigation.

csharp · derived-type loading pattern
// Chapter 07's hierarchy remains isolated in ServiceHub.MappingLab.// If EquipmentTarget owns a mapped collection such as CalibrationRecords:var equipment = await mappingDb.Set<ServiceTarget>()    .OfType<EquipmentTarget>()    .Include(e => e.CalibrationRecords)    .AsNoTracking()    .ToListAsync(ct);
Do not invent a navigation in production

The CalibrationRecords edge is a focused derived-navigation pattern. Add it only if the real model owns that relationship. The point is that OfType changes the SQL discriminator predicate first; Include then loads an actual mapped navigation on that derived type.

6. Deliberately wrong: eager-load the domain instead of the use case

csharp · wrong: graph by convenience
var rows = await db.WorkOrders    .Include(w => w.AssignedTechnician)    .Include(w => w.SlaSnapshot)    .Include(w => w.WorkOrderTags).ThenInclude(x => x.Tag)    .Include(w => w.Notes)    .Include(w => w.Attachments)    .ToListAsync(ct);

This may be semantically correct and still be a bad endpoint query: wide principal columns are duplicated across collection rows, multiple collections can multiply rows, tracking allocates state for every entity, and serialization can expose bidirectional cycles or internal fields. The repair is to define the response contract first.

csharp · repair: endpoint-shaped projection
var dto = await db.WorkOrders    .Where(w => w.Id == id)    .Select(w => new WorkOrderDetailDto(        w.Id,        w.WorkOrderNumber,        w.CustomerName,        w.AssignedTechnician == null ? null : w.AssignedTechnician.DisplayName,        w.WorkOrderTags            .OrderBy(x => x.DisplayOrder)            .Select(x => x.Tag.Name)            .ToArray(),        w.Notes.Count,        w.Attachments.Count))    .SingleAsync(ct);

Projection is not automatically faster; it is simply more explicit about required columns and shape. Inspect the translated SQL and measure the actual workload.

7. Hands-on lab: count relational work before object results

  1. Reset the disposable SQLite lab and seed one work order with deterministic counts: 5 tags, 4 notes, 3 attachments.
  2. Add the principal-side navigations and verify the navigation-only migration is empty.
  3. Capture ToQueryString() for reference-only, one-collection, nested-collection, and three-sibling-collection shapes.
  4. Enable Microsoft.EntityFrameworkCore.Database.Command logging and record command count.
  5. Materialize the graph and inspect ChangeTracker.DebugView.ShortView.
  6. Replace the graph with a DTO projection and compare selected columns/rows; do not invent timing numbers.

Check your understanding

  1. What does Include guarantee?
  2. Why can one tracked WorkOrder correspond to many database rows?
  3. Does ThenInclude create a cartesian product by itself?
  4. Why add Notes/Attachments navigations without schema DDL?
  5. When is projection preferable?
  6. What does ToQueryString not prove?
Review the answers

That the requested navigation is populated according to the query/loading semantics; it does not guarantee one SQL statement or cheap execution.

Collection joins duplicate the principal columns; identity resolution collapses repeated keys to one entity instance after rows are read.

Not necessarily. A nested collection is at a deeper level; cartesian explosion is especially problematic for sibling collections at the same level.

The FK relationships/tables already existed; adding the inverse navigation changes the EF object graph, not the relational relationship.

When the consumer needs a read model/DTO rather than a tracked entity graph and you want explicit columns/shape.

Runtime parameters in their final execution form, actual execution time, row counts, network bytes, or the database execution plan.

8. Production judgment and bridge

Use eager loading when the unit of work genuinely needs the related entities together and the resulting cardinality is bounded. Prefer projection for API/read models. Review generated SQL, command count, tracking mode, and serialization shape before calling a query “simple.” Lesson 2 adds filters inside Include and demonstrates why the same query can produce a surprising collection when the context already tracks related entities.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.

\n