Chapter 10 · Loading Related Data: Eager, Explicit, Lazy, Filtered Include, and Split Queries
Lazy Loading Proxies and ILazyLoader: N+1 Risk, Serialization Problems, and When to Avoid Them
Expose the hidden I/O of lazy-loading proxies and ILazyLoader, including N+1 traces, context-lifetime coupling, serialization cycles, test unpredictability, and safer read-model alternatives.
Learning outcomes
Lazy loading can make domain code feel elegant: access
workOrder.Notes and the data appears. That
convenience moves database I/O into property access, couples
entities to a live context or loader service, and can turn a
simple loop or JSON serializer into dozens of queries. This
lesson enables lazy loading in a disposable branch of the
ServiceHub lab, captures the hidden commands, and then removes
it from a read endpoint where its costs are unacceptable.
Configure Microsoft.EntityFrameworkCore.Proxies 10.0.11 and explain proxy/class/navigation requirements.
Explain ILazyLoader-based lazy loading without dynamic proxies and its entity-constructor/service coupling.
Observe hidden round trips with EF command logs and reproduce N+1.
Explain disposed-context failures and why lazy loading depends on context lifetime.
Explain bidirectional navigation serialization cycles and why serializer settings are not a query-performance fix.
Replace implicit I/O with eager/projection/explicit loading where query shape must be predictable.
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.
Lazy-loading proxies are an optional teaching dependency, not part of the mandatory core project baseline. Add Microsoft.EntityFrameworkCore.Proxies 10.0.11 only for this lab branch and keep Microsoft EF package versions aligned.
1. Proxy-based lazy loading
dotnet add src/ServiceHub.EfLab package Microsoft.EntityFrameworkCore.Proxies --version 10.0.11
builder.Services.AddDbContext<ServiceHubContext>(options => options .UseLazyLoadingProxies() .UseSqlite(connectionString));
Proxy lazy loading requires EF to create a derived proxy
instance. In the common pattern, the entity class must be
inheritable and the navigation must be overridable
(virtual). Private/final/sealed members prevent the
proxy from intercepting navigation access. That constraint can
conflict with the encapsulation choices from Chapter 06—another
reason not to adopt proxies by default.
public class LazyWorkOrder{ public int Id { get; protected set; } public string Number { get; protected set; } = string.Empty; public virtual ICollection<LazyWorkOrderNote> Notes { get; protected set; } = new List<LazyWorkOrderNote>();}
2. ILazyLoader: lazy loading without proxy inheritance
EF can inject ILazyLoader into an entity and the
entity can call it from a navigation getter. This avoids dynamic
proxy inheritance requirements, but it explicitly couples the
entity to an EF service abstraction and still performs implicit
database I/O.
public class WorkOrderWithLoader{ private ICollection<WorkOrderNote>? _notes; private readonly ILazyLoader? _lazyLoader; private WorkOrderWithLoader(ILazyLoader lazyLoader) => _lazyLoader = lazyLoader; public WorkOrderWithLoader() { } public int Id { get; private set; } public ICollection<WorkOrderNote> Notes { get => _lazyLoader?.Load(this, ref _notes) ?? (_notes ??= new List<WorkOrderNote>()); }}
The exact constructor-binding rules matter: EF can inject recognized services, but application code should not turn entities into service locators. If persistence-free domain objects are a goal, explicit/eager loading at the application boundary is usually cleaner.
3. Deliberately wrong: a harmless-looking foreach becomes N+1
var workOrders = await lazyDb.Set<LazyWorkOrder>() .OrderBy(w => w.Id) .Take(50) .ToListAsync(ct); // root commandforeach (var workOrder in workOrders){ Console.WriteLine($"{workOrder.Number}: {workOrder.Notes.Count}"); // First virtual Notes access can execute another command through the proxy.}
Command 1: SELECT ... FROM work_orders ORDER BY work_order_id LIMIT 50Command 2: SELECT ... FROM work_order_notes WHERE work_order_id = @p0Command 3: SELECT ... FROM work_order_notes WHERE work_order_id = @p0...Command 51: SELECT ... FROM work_order_notes WHERE work_order_id = @p0
The code contains no explicit query inside the loop, which is precisely why lazy-loading N+1 is easy to miss. The safe diagnosis is command logs/metrics, not source-code aesthetics.
var rows = await db.WorkOrders .OrderBy(w => w.Id) .Take(50) .Select(w => new { w.WorkOrderNumber, NoteCount = w.Notes.Count }) .AsNoTracking() .ToListAsync(ct);
4. Context lifetime is now part of property behavior
Lazy loading requires a context/loader that can execute the database query. If an entity escapes its unit of work and code later touches an unloaded navigation after the context is disposed, loading cannot succeed. Exact exception text can vary, but the architectural bug is stable: an ordinary-looking property getter depends on a dead data-access scope.
LazyWorkOrder detached;await using (var lazyDb = await lazyFactory.CreateDbContextAsync(ct)){ detached = await lazyDb.Set<LazyWorkOrder>().SingleAsync(w => w.Id == id, ct);}// Later, outside the context lifetime:var count = detached.Notes.Count; // proxy tries to lazy-load through a disposed context
Repair by loading/projecting what must cross the boundary before disposal, or return a DTO. Extending the DbContext lifetime merely to keep lazy loading alive reintroduces Chapter 02's tracking, concurrency, memory, and stale-state hazards.
5. Serialization can traverse relationships and trigger more I/O
Relationship fixup creates bidirectional cycles such as
WorkOrder → Notes → WorkOrder. Serializers may reject cycles,
ignore them, or preserve references depending on configuration.
With lazy loading, serialization can also touch unloaded virtual
navigations and trigger database commands. Configuring
ReferenceHandler.IgnoreCycles can avoid a JSON
exception, but it does not solve hidden I/O, over-fetching,
authorization, or response-contract ambiguity.
var response = await db.WorkOrders .Where(w => w.Id == id) .Select(w => new WorkOrderResponse( w.Id, w.WorkOrderNumber, w.CustomerName, w.Notes .OrderByDescending(n => n.Id) .Take(10) .Select(n => new NoteResponse(n.Id, n.Text)) .ToArray())) .AsNoTracking() .SingleAsync(ct);
6. Lazy loading also makes tests less deterministic
A unit test that reads a navigation may suddenly require a configured provider, open database, active context, and seeded relationship rows. Query-count tests can also change when a new property is rendered in a view. If lazy loading is retained, treat command-count regressions as part of test coverage and keep it out of contexts where implicit I/O is unacceptable.
| Context | Lazy loading judgment |
|---|---|
| Small desktop/admin tool with bounded graph | May be acceptable if command logging makes I/O visible. |
| High-throughput API list endpoint | Usually avoid; projection/eager shape is more predictable. |
| Background batch loop | Usually avoid hidden per-item I/O; use set-based queries. |
| Serialization boundary | Prefer DTO; entity graph can cycle and trigger loads. |
| Domain model with strict encapsulation | ILazyLoader/proxy requirements may conflict with domain design. |
7. Hands-on lab: expose and remove hidden I/O
-
Create a disposable branch/project variant and add
Microsoft.EntityFrameworkCore.Proxies 10.0.11. -
Enable
UseLazyLoadingProxies()in a separateLazyLoadingLabContextand use the proxy-compatibleLazyWorkOrderteaching entity; do not silently rewrite the encapsulated production model. - Seed 25 work orders with notes and run the foreach N+1 example.
-
Capture
Microsoft.EntityFrameworkCore.Database.Commandlogs and count commands. - Dispose the context before accessing an unloaded navigation and record the failure.
- Serialize a bidirectional graph in a controlled test and record the cycle behavior of the configured serializer.
- Replace the endpoint with a DTO projection and verify command count/result shape.
Check your understanding
- What package enables Microsoft proxy-based lazy loading?
- What must a proxy typically override?
- What does ILazyLoader change?
- Why does foreach + navigation access create N+1?
- Does IgnoreCycles solve lazy-loading performance?
- What is the safest API-boundary pattern?
Review the answers
Microsoft.EntityFrameworkCore.Proxies; this chapter pins 10.0.11 to match the EF Core baseline.
The navigation property; therefore the class/navigation must be proxy-compatible, commonly inheritable and virtual.
It avoids dynamic proxy inheritance but injects an EF loader service into the entity; I/O remains implicit.
The root query is followed by a separate relationship query for each principal whose navigation is first accessed.
No. It only changes serialization cycle handling; hidden database commands and over-fetching remain.
Project an explicit DTO/read model with the exact related data needed before the context is disposed.
8. Production judgment and bridge to Chapter 11
Lazy loading is a capability, not a default architecture. Keep it only when hidden relationship I/O is acceptable, context lifetime is controlled, serialization is bounded, and query-count observability/tests exist. For most APIs and batches, explicit projections or deliberate eager/explicit loading make performance and security easier to reason about. Chapter 11 now turns from loading to change tracking: entity states, identity resolution, snapshots, and disconnected graphs—the client-side state machine that all these tracking queries have been feeding.
Authoritative references
- Lazy Loading of Related Data - EF Core — proxies and ILazyLoader patterns plus N+1 warning.
- Microsoft.EntityFrameworkCore.Proxies 10.0.11 — aligned optional package/version.
- Related Data and Serialization - EF Core — navigation cycles and serializer behavior.
- Efficient Querying - EF Core — N+1 and round-trip awareness.
- DbContext Lifetime, Configuration, and Initialization — context lifetime and async safety.