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.

Intermediate → Advanced125–160 minuteslazy-loading + N+1 trace labEF Core 10.0.11 · .NET 10.0.11SQLite provider 10.0.11 baselineSDK checkpoint: 10.0.400Last reviewed: August 2026

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.

01

Configure Microsoft.EntityFrameworkCore.Proxies 10.0.11 and explain proxy/class/navigation requirements.

02

Explain ILazyLoader-based lazy loading without dynamic proxies and its entity-constructor/service coupling.

03

Observe hidden round trips with EF command logs and reproduce N+1.

04

Explain disposed-context failures and why lazy loading depends on context lifetime.

05

Explain bidirectional navigation serialization cycles and why serializer settings are not a query-performance fix.

06

Replace implicit I/O with eager/projection/explicit loading where query shape must be predictable.

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.

Optional package

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

shell · add the aligned proxies package
dotnet add src/ServiceHub.EfLab package Microsoft.EntityFrameworkCore.Proxies --version 10.0.11
csharp · enable proxy-based lazy loading
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.

csharp · proxy-compatible teaching entity shape
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.

csharp · ILazyLoader pattern
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

csharp · hidden query per navigation access
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.}
text · representative EF command trace
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.

csharp · repair: server-side summary projection
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.

csharp · wrong: entity escapes its context
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.

csharp · prefer a serializer-independent API DTO
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

  1. Create a disposable branch/project variant and add Microsoft.EntityFrameworkCore.Proxies 10.0.11.
  2. Enable UseLazyLoadingProxies() in a separate LazyLoadingLabContext and use the proxy-compatible LazyWorkOrder teaching entity; do not silently rewrite the encapsulated production model.
  3. Seed 25 work orders with notes and run the foreach N+1 example.
  4. Capture Microsoft.EntityFrameworkCore.Database.Command logs and count commands.
  5. Dispose the context before accessing an unloaded navigation and record the failure.
  6. Serialize a bidirectional graph in a controlled test and record the cycle behavior of the configured serializer.
  7. Replace the endpoint with a DTO projection and verify command count/result shape.

Check your understanding

  1. What package enables Microsoft proxy-based lazy loading?
  2. What must a proxy typically override?
  3. What does ILazyLoader change?
  4. Why does foreach + navigation access create N+1?
  5. Does IgnoreCycles solve lazy-loading performance?
  6. 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

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