Skip to content

Query Strategy and Loading Profiles

Control how the repository executes a query — from full entity tracking to raw projections.

Different operations need different loading strategies. A mutation needs a tracked entity with all navigations loaded. A list query needs a lightweight projection with no tracking. An admin dashboard needs raw data bypassing all filters. A grid endpoint needs filtered data with specific includes.

Without explicit control, every query loads entities the same way — usually tracked with all filters applied. This wastes resources for read-only scenarios and prevents admin access to filtered-out data.

[QueryStrategy(Strategy = QueryStrategy.Projection)]
[Query<Order, OrderListDto>]
public partial class GetOrderList
{
// Loaded as projection — no entity tracking, best performance
}
[QueryStrategy(Strategy = QueryStrategy.Entity)]
[Mutation(Mode = MutationMode.Update)]
public partial class UpdateOrder
{
// Loaded as tracked entity — full change tracking
}
StrategyTrackingFiltersUse Case
ProjectionNoAppliedRead-only lists, DTOs, API responses
EntityYesAppliedMutations, updates, business logic
FilteredYesApplied + custom FilterMapQueries needing navigation-level filtering
RawNoNoneAdmin dashboards, data exports, migrations

Default behavior: If you don’t specify [QueryStrategy], the source generator infers the strategy from the operation type:

  • Mutations → Entity (needs tracking for SaveChanges)
  • Queries with a projection type → Projection (best performance)
  • Queries without projection → Entity
// Admin endpoint that needs to see soft-deleted records
[QueryStrategy(Strategy = QueryStrategy.Raw)]
[Query<Order, OrderAdminDto>]
public partial class GetAllOrdersAdmin { }
// Read query that needs custom navigation filtering
[QueryStrategy(Strategy = QueryStrategy.Filtered)]
[Query<Order, OrderDetailDto>]
public partial class GetOrderWithFilteredLines { }

Different views of the same entity need different navigation loading. An order list needs just the order fields. An order detail needs Customer and LineItems. An order invoice needs LineItems.Product. Writing Include() chains everywhere is repetitive, easy to get wrong, and invisible to the source generator.

Declare what you need on the query class. The source generator reads the entity’s [Relation.*] attributes and generates the correct Include() / ThenInclude() calls.

[LoadWith<Order>(MaxDepth = 2, SplitQuery = true)]
[Query<Order, OrderDetailDto>]
public partial class GetOrderDetail
{
[Filter]
public Guid? OrderId { get; set; }
}
PropertyTypeDefaultDescription
MaxDepthint1How deep to include navigations (0 = none)
SplitQueryboolfalseUse split queries for collections (avoids cartesian explosion)

Given an Order entity with navigations to Customer, LineItems, and LineItems.Product:

Depth 0: Order only (no navigations)
Depth 1: Order -> Customer, Order -> LineItems
Depth 2: Order -> Customer -> Address, Order -> LineItems -> Product
Depth 3: Order -> LineItems -> Product -> Category

The source generator reads all [Relation.*] attributes on the entity and its related types, then generates Include() / ThenInclude() calls up to MaxDepth.

Performance warning: Depth > 3 emits diagnostic PRAG0711. Deep includes cause large SQL joins and cartesian explosion with collection navigations. If you need data from deeply nested types, consider using QueryStrategy.Projection to select only the fields you need.

EF Core joins all navigations into a single SQL query by default. With collection navigations, this causes cartesian explosion — every row in the parent is repeated for every row in each child collection.

// Without SplitQuery: one big JOIN
// If Order has 3 LineItems and 2 Tags: 3 x 2 = 6 rows returned for one Order
[LoadWith<Order>(MaxDepth = 2)]
// With SplitQuery: separate SQL queries per collection
// Query 1: Orders + Customer (1:1 join, no duplication)
// Query 2: LineItems for matched Orders
// Query 3: Tags for matched Orders
[LoadWith<Order>(MaxDepth = 2, SplitQuery = true)]

Rule of thumb: Use SplitQuery = true when including more than one collection navigation at the same depth level.


How QueryStrategy, LoadWith, and Queries Compose

Section titled “How QueryStrategy, LoadWith, and Queries Compose”

The attributes control orthogonal concerns:

[QueryStrategy] -> Controls tracking + filter behavior
[LoadWith] -> Controls eager loading (Include depth + split)
[Query] -> Controls filtering + sorting + projection

They compose in a fixed order during query execution:

  1. StrategyAsNoTracking() applied (if Projection or Raw)
  2. Filters — Query filters applied or skipped (based on strategy)
  3. IncludesInclude() / ThenInclude() calls generated from [LoadWith]
  4. Where — Filter properties from the query class
  5. OrderBy — Sort properties from the query class
  6. ProjectionSelect() to the result DTO type
  7. PagingSkip() / Take() if pagination properties exist
[QueryStrategy(Strategy = QueryStrategy.Projection)]
[LoadWith<Order>(MaxDepth = 1)]
[Query<Order, OrderSummaryDto>]
public partial class GetOrderSummaries
{
[Filter]
public OrderStatus? Status { get; set; }
[Sort(DefaultDirection = SortDirection.Descending)]
public SortDirection? CreatedAt { get; set; }
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 50;
}

The generated code:

  1. AsNoTracking() — Projection strategy, no tracking overhead
  2. Include(o => o.Customer), Include(o => o.LineItems) — MaxDepth 1
  3. .Where(o => o.Status == status) — only when Status is not null
  4. .OrderByDescending(o => o.CreatedAt) — default descending
  5. .Select(o => new OrderSummaryDto { ... }) — projection
  6. .Skip((page - 1) * pageSize).Take(pageSize) — pagination

If you don’t use [QueryStrategy] or [LoadWith], the defaults apply:

// Minimal query — defaults to Projection strategy, depth 1, no split
[Query<Order, OrderDto>]
public partial class GetOrders { }
// Minimal mutation — defaults to Entity strategy
[Mutation(Mode = MutationMode.Update)]
public partial class UpdateOrder { }

The source generator picks sensible defaults so you only need attributes when the defaults don’t fit.