Skip to main content

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.