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.
The problem
Section titled “The problem”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?
What is derived
Section titled “What is derived”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 DTO | Contributes |
|---|---|
[MapProperty("Customer.Name")] — a flattened path | Customer |
| A nested DTO property | the navigation it maps from |
| A collection of element DTOs | the 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>]onWorkspacegives 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.
Where it is consumed
Section titled “Where it is consumed”- 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.
What is declared
Section titled “What is declared”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 navigationpublic partial class ApproveOrderMutation : Mutation<Order> { … }[EagerLoad] works on a query the same way, and the two sources are merged rather than replacing one
another.
When it is missing
Section titled “When it is missing”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.
What a create cannot do
Section titled “What a create cannot do”⚠️ [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 bepublic 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.
The trap worth knowing
Section titled “The trap worth knowing”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 goneThis 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.
Cross-boundary
Section titled “Cross-boundary”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.