Skip to content

Eager loading

Two mechanisms, and this page is one of them. What follows is derived from the DTO’s own shape: the generator works out which navigations it reads. The other is [LoadWith<T>], which is declared rather than derived and means “every navigation of the entity down to depth N”. Prefer this one when you project — a projection resolves the navigation in SQL and there is nothing left to include.

A DTO reads order.Customer.Name. A mutation merges order.OrderLines. Both need the navigation to be there — and EF gives you nothing when it is not: the property is null, the collection is empty, and the code carries on. A merge against a collection nobody loaded removes nothing and adds everything a second time; a projection through an unloaded reference throws, or silently reads a default, depending on where it runs.

So the question is not “should I call Include”. It is: who knows what has to be loaded, and does that knowledge reach the query?

Most of it. A DTO’s shape already says which navigations it reads, and the generator works them out once, at [MapFrom] time, into RequiredNavigations:

[MapFrom<Order>]
public partial class OrderDto
{
public string Reference { get; init; } = "";
[MapProperty("Customer.Name")] // a path across a navigation
public string CustomerName { get; init; } = "";
public List<OrderLineDto> OrderLines { get; init; } = []; // a collection of DTOs
}
// ═══ generated on the DTO ═══
public static IReadOnlyList<string> RequiredNavigations { get; } = ["Customer", "OrderLines"];

Three shapes feed it, and all three are covered:

In the DTOContributes
[MapProperty("Customer.Name")] — a flattened pathCustomer
A nested DTO propertythe navigation it maps from
A collection of element DTOsthe collection navigation

It is emitted on every [MapFrom] DTO, empty list included — that is what makes it nameable from other generated code without anyone checking first.

⚠️ A relation is declared once and produces members on both sides. [Relation.OneToMany<Member>] on Workspace gives Workspace the collection and Member the way back. Both are navigations, and both are resolved — an application that declares every relation on the parent has every child navigation in the second shape.

  • A mutation’s load, from [ReturnsDto<T>] and from the children it will write. Loading the children is not an optimisation here: it is what makes the merge correct.
  • A generated query, through IIncludableQuery<TEntity>.IncludePaths, applied before the projection.
  • WithIncludesFor{Dto}(), for a query you assemble yourself.

RequiredNavigations covers what the DTO reads. It cannot cover what your code reads — a validator that walks order.Customer.Country, a lifecycle hook, an ApplyAsync of your own. Say so:

[Mutation(Mode = MutationMode.Update)]
[EagerLoad("Customer")]
[EagerLoad("OrderLines.Product")] // dot-separated: a path, not just a navigation
public partial class ApproveOrderMutation : Mutation<Order> { … }

[EagerLoad] works on a query the same way, and the two sources are merged rather than replacing one another.

The generated code does not read null and carry on. Where a mapping crosses a navigation that was not loaded, it throws naming the navigation, the type, and both ways out — [EagerLoad("…")] on the mutation, or project with {Dto}.Projection, which resolves it in the database instead.

That message exists because the alternative was a NullReferenceException inside a generated file its author cannot open.

⚠️ [EagerLoad] is inert on a MutationMode.Create, and nothing says so. A create’s LoadEntityAsync returns null — there is no query to add an Include to — and the entity it builds carries the foreign key it was given, never the navigation behind it. The attribute parses, generates nothing, and the build stays green.

The consequence lands one step later. A create that declares [ReturnsDto<T>] where T flattens a navigation throws on the way out: the write has already succeeded, and the caller gets a 500.

[MapFrom<Member>]
public partial class MemberDto
{
[MapProperty("Workspace.Name")] public string WorkspaceName { get; init; } = "";
}
[Mutation(Mode = MutationMode.Create)]
[ReturnsDto<MemberDto>] // throws: Workspace is not loaded, and on a create it cannot be
public partial class InviteMemberMutation : Mutation<Member> { … }

Two answers, and [EagerLoad] is neither:

  • give the create a response shape that reads only the entity’s own columns;
  • or return the id and let the caller read the resource, which is what a 201 with a location is for.

An update has none of this problem: it loads, so [EagerLoad("Workspace")] becomes a real Include and the same DTO works.

EF drops an Include before a Select that changes the type, and says nothing. Measured, not inferred:

query.Include(o => o.Customer)
.Select(o => new OrderDto { … }) // the Include is gone

This is why a generated query applies its include paths where they still count, and why {Dto}.Projection is the better answer when you are projecting anyway: a projection resolves the navigation in SQL, so there is nothing to include.

A [Relation.*] that crosses a boundary generates no navigation — the two entities live in different DbContexts, and there is no property for EF to include. A DTO that tries to flatten across one gets PRAG0334, which says the path crosses a boundary rather than merely that the property is absent.

The way across a boundary is the other boundary’s operations, not a navigation.