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.
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.
Explain eager loading, reference navigation, collection navigation, Include, ThenInclude, and multiple include paths before using them.
Extend existing Chapter 05 note/attachment relationships with principal-side navigations without changing their foreign-key schema.
Inspect representative SQL and predict duplicated principal rows before materialization.
Distinguish database row multiplication from EF identity resolution in tracking queries.
Load nested and derived navigations without assuming object syntax removes relational joins.
Repair an over-eager graph by projecting only the shape an endpoint actually needs.
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.
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();
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
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.
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.
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.
// 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);
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
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.
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
- Reset the disposable SQLite lab and seed one work order with deterministic counts: 5 tags, 4 notes, 3 attachments.
- Add the principal-side navigations and verify the navigation-only migration is empty.
-
Capture
ToQueryString()for reference-only, one-collection, nested-collection, and three-sibling-collection shapes. -
Enable
Microsoft.EntityFrameworkCore.Database.Commandlogging and record command count. -
Materialize the graph and inspect
ChangeTracker.DebugView.ShortView. - Replace the graph with a DTO projection and compare selected columns/rows; do not invent timing numbers.
Check your understanding
- What does Include guarantee?
- Why can one tracked WorkOrder correspond to many database rows?
- Does ThenInclude create a cartesian product by itself?
- Why add Notes/Attachments navigations without schema DDL?
- When is projection preferable?
- 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
- Eager Loading of Related Data - EF Core — Include/ThenInclude, multiple paths, derived types, filtered Include.
- Single vs. Split Queries - EF Core — JOIN duplication and cartesian explosion.
- Tracking vs. No-Tracking Queries - EF Core — identity resolution and tracking behavior.
- Relationships - EF Core — navigation and foreign-key semantics.
- EF Core logging and diagnostics — command/log evidence.