NP0005: An endpoint returns entities whose lazy navigations the serializer loads one by one
Default severity: Warning
What it detects
An MVC action, a minimal API handler (a lambda or a method passed by name: app.MapGet("/orders", GetOrders)) or a
SignalR hub method that returns EF entities (directly, as a query or IAsyncEnumerable<T>, in ActionResult<T>,
Ok(...), Json(...), new OkObjectResult(...), new JsonResult(...), Results.Ok(...), TypedResults.Ok(...),
Results.Json(...) or TypedResults.Json(...))
while lazy loading is on and the query did not include their navigations. The serializer reads every property of every returned entity, so each navigation that was not included
is one query per element:
[HttpGet]
public async Task<List<Order>> All() => await db.Orders.ToListAsync(); // NP0005: Customer, Lines, ... per order
A hub method or action that streams is checked item by item: yield return order in one returning
IAsyncEnumerable<Order> (also over await foreach with WithCancellation), and the writes to the channel a hub method
returns the reader of (channel.Writer.WriteAsync(order) in the method or a lambda in it). Each item is one of many,
so its own navigations count once per item.
The same check applies to calls that serialize a value: JsonSerializer.Serialize/SerializeAsync,
Newtonsoft's JsonConvert.SerializeObject, SignalR's SendAsync (Clients.All.SendAsync("orders", orders)), and the
methods of a strongly typed hub client (Clients.Caller.OrdersChanged(orders) on a Hub<IOrderClient> or an
IHubContext<OrdersHub, IOrderClient>).
Entities inside a result container built where it is returned or serialized are found too: a page or envelope
(new PaginatedItems<Order>(pageIndex, pageSize, count, orders), new PagedList<Order> { Items = orders }), an
anonymous object (Ok(new { items = orders, total })), containers nested in each other (three levels deep), and a
local holding one. The serializer writes a container's public properties, so the members followed are the
properties an object initializer sets, every member of an anonymous object, and the constructor arguments whose
parameter has the name of a public property (case aside, as records, primary constructors and System.Text.Json
itself match them). A constructor argument with no such property (Count = orders.Count), or one stored in a
non-public or ignored property, is not serialized and not reported. The message names the path through the
container (Data.Customer).
An application that switches MVC to Newtonsoft.Json (AddControllers().AddNewtonsoftJson()) has its controllers'
results checked against Newtonsoft's ignore attributes; minimal APIs keep System.Text.Json. An OData action
([EnableQuery] on it or its controller) is not reported: OData's formatter writes only the structural properties and
what $expand asks for, which goes into the SQL query.
A view (View(orders), PartialView(...), ViewComponent(...)) is rendered, not serialized: it reads only what
its markup reads, so it is not reported here.
For a collection, navigations two levels deep count (Customer, Customer.Address, Lines.Product); for a single
entity, the navigations of the elements of its collections (Lines.Product), since loading one collection is one
query but a navigation of each of its elements is N. A reference back to the parent (Order.Customer under
Customer.Orders) is fixed up by EF Core and does not count; a collection back (Customer.Orders under
Order.Customer) is not marked loaded by including Customer, so the serializer queries it once per customer.
Navigations the serializer skips without reading them are not reported: System.Text.Json's [JsonIgnore] with
no condition or Condition = JsonIgnoreCondition.Always for endpoints, SignalR and JsonSerializer calls;
Newtonsoft's [JsonIgnore] and [IgnoreDataMember] for JsonConvert calls. Neither serializer honours the other's
[JsonIgnore], and System.Text.Json ignores [IgnoreDataMember], so those are still reported. The conditions
WhenWritingNull, WhenWritingDefault and .NET 10's WhenWriting read the value before leaving it out, which
loads the navigation: those are reported too (measured on SQLite).
Auto-included navigations are not reported. Untracked results (AsNoTracking()) are reported from EF Core 8 on,
which lazy-loads no-tracking entities too (each read is a new query, with no identity map to reuse what was
loaded), except a reference whose foreign key is a shadow property: EF Core throws for it instead. Before EF
Core 8 untracked results load nothing and are not reported.
How to fix it
Return DTOs projected with Select. It removes the lazy loads, the reference cycles the serializer would trip over,
and the columns the client never needed:
public Task<List<OrderDto>> All()
=> db.Orders.Select(o => new OrderDto(o.Id, o.Customer.Name, o.Lines.Count)).ToListAsync();
Including every navigation the response contains also works, but grows with the model.
What it cannot see
Entities returned from methods whose query is unknown, custom result types, containers built in another method
(return BuildPage(orders)) or stored in a field or a list of containers, a constructor that stores an argument in a
property of a different name, and serializer settings that skip
navigations at run time: contract resolvers, ShouldSerialize* methods, [DataContract] opt-in, System.Text.Json
type-info modifiers. SignalR switched to Newtonsoft (AddNewtonsoftJsonProtocol) is still assumed to use
System.Text.Json.