Skip to content

Query System

Declarative queries, filter DTOs, and paged results — all source-generated from attributes.

Querying data in a typical .NET application involves writing repetitive IQueryable chains: filter by this, sort by that, include these navigations, project to this DTO, page the results. For 30 entities, you write 30 nearly identical query classes — each with the same pattern of Where, OrderBy, Select, Skip, Take.

// The typical approach — manual query building
public async Task<PagedResult<OrderDto>> SearchOrders(
string? customerName, OrderStatus? status, DateTimeOffset? fromDate,
SortDirection? orderDateSort, int page = 1, int pageSize = 20)
{
var query = db.Orders.AsNoTracking();
if (customerName is not null)
query = query.Where(o => o.CustomerName.Contains(customerName));
if (status is not null)
query = query.Where(o => o.Status == status);
if (fromDate is not null)
query = query.Where(o => o.OrderDate >= fromDate);
if (orderDateSort == SortDirection.Ascending)
query = query.OrderBy(o => o.OrderDate);
else if (orderDateSort == SortDirection.Descending)
query = query.OrderByDescending(o => o.OrderDate);
var total = await query.CountAsync();
var items = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(o => new OrderDto { /* map every property */ })
.ToListAsync();
return PagedResult<OrderDto>.Success(items, total, page, pageSize);
}

For every query endpoint, you repeat the same null-check-then-filter pattern. For every DTO, you hand-write the projection. For every sortable field, you write the if ascending / else descending branch. With 30 entities and multiple query endpoints each, this becomes thousands of lines of mechanical code.

A query is a class that declares what to filter, sort, and project — the source generator produces the Apply() method and the interface implementation.

// ═══ What YOU write ═══
[Query<Order, OrderDto>]
public partial class GetOrders
{
[Filter(Operator = FilterOperator.Contains)]
public string? CustomerName { get; init; }
[Filter]
public OrderStatus? Status { get; init; }
[Sort(DefaultDirection = 1)] // 1 = Descending by default
public SortDirection? OrderDateSort { get; init; }
public int Page { get; init; } = 1;
public int PageSize { get; init; } = 20;
}

The source generator reads the [Query], [Filter], and [Sort] attributes and produces the Apply() method, the Projection expression, and the correct interface implementation:

// ═══ What the SOURCE GENERATOR produces ═══
public partial class GetOrders : IPagedQuery<Order, OrderDto>
{
public Expression<Func<Order, OrderDto>>? Projection => OrderDto.FromEntity;
public IQueryable<Order> Apply(IQueryable<Order> query)
{
if (CustomerName is not null)
query = query.Where(e => e.CustomerName.Contains(CustomerName));
if (Status is not null)
query = query.Where(e => e.Status == Status);
// DefaultDirection = 1 → Descending applied even when OrderDateSort is null
var orderDateDir = OrderDateSort ?? SortDirection.Descending;
query = orderDateDir == SortDirection.Ascending
? query.OrderBy(e => e.OrderDate)
: query.OrderByDescending(e => e.OrderDate);
return query;
}
}

The SG detects Page and PageSize properties by convention and implements IPagedQuery<Order, OrderDto> (which provides Skip and Take automatically). If those properties are absent, it implements IQuery<Order, OrderDto> instead.

Properties marked required are always applied — they cannot be null:

[Query<Order, OrderDto>]
public partial class GetOrdersForCustomer
{
// Required → always included in the WHERE clause
public required Guid CustomerId { get; init; }
// Optional → applied only when not null
[Filter]
public OrderStatus? Status { get; init; }
public int Page { get; init; } = 1;
public int PageSize { get; init; } = 20;
}
// ═══ Generated Apply ═══
public IQueryable<Order> Apply(IQueryable<Order> query)
{
query = query.Where(e => e.CustomerId == CustomerId); // Always applied
if (Status is not null)
query = query.Where(e => e.Status == Status);
return query;
}

When you don’t need to project to a DTO, use the single-type-argument form. This returns the entity itself:

[Query<Order>]
public partial class FindOrders
{
[Filter]
public Guid? CustomerId { get; init; }
}

The SG implements IQuery<Order> (no projection). The executor returns entities directly.

Queries are executed through IQueryExecutor:

public class OrderService(IQueryExecutor executor, IRepository<Order, Guid> orders)
{
public async Task<PagedResult<OrderDto>> SearchOrders(GetOrders query, CancellationToken ct)
{
var source = orders.Query(); // IQueryable<Order>
return await executor.ExecuteAsync(query, source, ct);
}
}

When combined with an [Endpoint] attribute, the query is executed automatically by the endpoint pipeline — you don’t need to write the service method at all:

// This is a fully functional API endpoint. No service class needed.
[Query<Reservation, ReservationSummaryDto>]
[Endpoint(HttpVerb.Get, "api/v1/reservations/search")]
public partial class SearchReservationsQuery
{
[Filter]
public Guid? GuestId { get; init; }
[Filter]
public ReservationStatus? Status { get; init; }
[Sort(DefaultDirection = 1)]
public SortDirection? CheckInSort { get; init; }
public int Page { get; init; } = 1;
public int PageSize { get; init; } = 20;
}

The [Filter] attribute marks a property as a filter condition. When the property value is null, the filter is skipped. When it has a value, the corresponding Where clause is added.

// Basic filter — Equals operator (default for non-strings)
[Filter]
public OrderStatus? Status { get; init; }
// String filter — Contains operator (default for strings)
[Filter]
public string? Name { get; init; }
// Explicit operator
[Filter(Operator = FilterOperator.GreaterOrEqual, MapTo = "CreatedAt")]
public DateTimeOffset? FromDate { get; init; }
// Collection-based filter — IN operator
[Filter(Operator = FilterOperator.In)]
public List<OrderStatus>? Statuses { get; init; }
// Case-insensitive string comparison
[Filter(IgnoreCase = true)]
public string? Email { get; init; }
// Nested property path
[Filter(MapTo = "Customer.Name")]
public string? CustomerName { get; init; }
PropertyTypeDefaultDescription
OperatorFilterOperatorEquals (non-string), Contains (string)The comparison operator
MapTostring?Same as property nameTarget property path on the entity
IgnoreCaseboolfalseCase-insensitive string comparison
OperatorSQL EquivalentTypical Use
Equals= valueExact match (ID, status, boolean)
NotEquals!= valueExclusion filter
ContainsLIKE '%value%'Text search
StartsWithLIKE 'value%'Prefix search
EndsWithLIKE '%value'Suffix search
GreaterThan> valueRange (dates, amounts)
GreaterOrEqual>= valueRange inclusive
LessThan< valueRange (dates, amounts)
LessOrEqual<= valueRange inclusive
InIN (values)Multi-select (statuses, categories)
Between>= min AND <= maxDate ranges, price ranges

[FilterOn<TEntity>] — Filtering on Joined Entities

Section titled “[FilterOn<TEntity>] — Filtering on Joined Entities”

When you need to filter on a property from a joined entity rather than the root entity, use [FilterOn<T>] together with [Join<T>]:

[Query<Order, OrderDetailDto>]
[Join<Customer>(Via = "Customer")]
public partial class GetOrdersByCustomerName
{
// Filter on the root entity (Order)
[Filter]
public OrderStatus? Status { get; init; }
// Filter on the joined entity (Customer)
[FilterOn<Customer>(MapTo = "Name")]
public string? CustomerName { get; init; }
}

The [FilterOn<T>] attribute has the same properties as [Filter] (Operator, MapTo, IgnoreCase) but targets the specified joined entity instead of the root.

By default, all filters are combined with AND logic. When you need OR logic between a set of filters, group them using [FilterGroup]:

[Query<Product, ProductDto>]
public partial class SearchProducts
{
[Filter]
public bool? IsActive { get; init; }
// Creates: WHERE IsActive = X AND (Name LIKE '%..%' OR Description LIKE '%..%')
[FilterGroup(FilterLogic.Or)]
public ProductTextSearch? Search { get; init; }
public int Page { get; init; } = 1;
public int PageSize { get; init; } = 20;
}
[FilterDto<Product>]
public partial class ProductTextSearch
{
[Filter(Operator = FilterOperator.Contains)]
public string? Name { get; init; }
[Filter(Operator = FilterOperator.Contains)]
public string? Description { get; init; }
}

The [FilterGroup] property points to a [FilterDto<T>] class. Filters inside the group are combined with the specified logic (And or Or). The group itself is always combined with AND to the parent filters.

This generates: WHERE (IsActive = @p0) AND (Name LIKE '%@p1%' OR Description LIKE '%@p1%').

For rich client UIs that need to send structured filter objects, use [ComplexFilter]. The property value is deserialized from a JSON query parameter:

[Query<Property, PropertySummaryDto>]
[Endpoint(HttpVerb.Get, "api/properties/search")]
public partial class SearchPropertiesQuery
{
[Filter(Operator = FilterOperator.Contains)]
public string? Name { get; init; }
/// <summary>
/// Sent as JSON: ?Location={"cityGroup":{"city":"Rome"},"maxStarRating":4}
/// </summary>
[ComplexFilter]
public PropertyLocationFilter? Location { get; init; }
public int Page { get; init; } = 1;
public int PageSize { get; init; } = 20;
}
[FilterDto<Property>]
public partial class PropertyLocationFilter
{
[FilterGroup(FilterLogic.Or)]
public CityGroupFilter? CityGroup { get; init; }
[Filter(Operator = FilterOperator.LessOrEqual, MapTo = "StarRating")]
public int? MaxStarRating { get; init; }
}

The SG generates an Apply() that delegates to the filter DTO’s generated ApplyFilter() extension method:

// Inside the generated Apply()
if (Location is not null)
query = PropertyLocationFilterExtensions.ApplyFilter(query, Location);

The JSON deserialization is handled by JsonQueryConverter<T>, which is wired automatically for [ComplexFilter] properties.

The [Sort] attribute declares sortable fields. The property type is SortDirection? — null means “don’t sort by this field”:

// Dynamic sort — user chooses direction, no sorting if null
[Sort]
public SortDirection? CustomerNameSort { get; init; }
// Default sort — always applied (Descending), user can override
[Sort(DefaultDirection = 1)]
public SortDirection? CreatedAtSort { get; init; }
// Map to a different entity property
[Sort(MapTo = "CreatedAt")]
public SortDirection? DateSort { get; init; }
// Multi-sort — Priority controls the order (lower = applied first)
[Sort(Priority = 0, DefaultDirection = 0)] // Primary: Name Ascending
public SortDirection? NameSort { get; init; }
[Sort(Priority = 1, DefaultDirection = 1)] // Secondary: Date Descending
public SortDirection? DateSort { get; init; }
PropertyTypeDefaultDescription
MapTostring?Derived from property name (removes “Sort” suffix)Target entity property
DefaultDirectionint-1 (no default)0 = Ascending, 1 = Descending, -1 = none
Priorityint0Sort order when multiple sorts are active (lower = first)

The SG derives the entity property name from the query property name by removing the “Sort” suffix: CustomerNameSort maps to CustomerName, CreatedAtSort maps to CreatedAt. Use MapTo to override this convention.

Use [Join<T>] on a query class to declare entities that should be joined:

// With Domain Entity navigation (auto-detected from [Relation.*])
[Query<Order, OrderSummaryDto>]
[Join<Customer>(Via = "Customer")]
[Join<OrderLine>(Via = "Lines")]
public partial class GetOrderWithDetails
{
[FilterOn<Customer>(MapTo = "Name")]
public string? CustomerName { get; init; }
}
// With explicit keys (for POCO entities without navigation properties)
[Query<Order, OrderSummaryDto>]
[Join<Customer>(ForeignKey = "CustomerId", TargetKey = "Id")]
public partial class GetOrderWithCustomer { }
// Left join (includes orders even if no matching record on the joined side)
[Query<Order, OrderSummaryDto>]
[Join<Shipment>(Via = "Shipment", Type = JoinType.Left)]
public partial class GetOrderWithShipment { }
PropertyTypeDefaultDescription
Viastring?Navigation property name (auto-detected from [Relation.*] attributes)
ForeignKeystring?FK property name on the source entity (for POCO entities)
TargetKeystring"Id"Target property to join on
TypeJoinTypeInnerInner, Left, Right, Full, Cross
Aliasstring?Type nameAlias for disambiguation when joining the same type twice

[FilterDto<TEntity>] — Standalone Filter DTOs

Section titled “[FilterDto<TEntity>] — Standalone Filter DTOs”

When you need reusable filter logic without a full query — for example, to share the same filter across multiple queries or to use it directly in service methods — use [FilterDto<T>]:

[FilterDto<Order>]
public partial class OrderFilter
{
[Filter]
public OrderStatus? Status { get; init; }
[Filter(Operator = FilterOperator.GreaterOrEqual, MapTo = "Total")]
public decimal? MinTotal { get; init; }
}

The SG generates an extension method:

// ═══ Generated: OrderFilterExtensions.g.cs ═══
public static class OrderFilterExtensions
{
public static IQueryable<Order> ApplyFilter(
this IQueryable<Order> query, OrderFilter filter)
{
if (filter.Status is not null)
query = query.Where(e => e.Status == filter.Status);
if (filter.MinTotal is not null)
query = query.Where(e => e.Total >= filter.MinTotal);
return query;
}
}

Use it anywhere you have an IQueryable<Order>:

var filter = new OrderFilter { Status = OrderStatus.Pending };
var pending = await db.Orders.ApplyFilter(filter).ToListAsync(ct);

Filter DTOs are also the building block for [FilterGroup] and [ComplexFilter] — the nested DTO must be a [FilterDto<T>].

The query system is built on a small interface hierarchy. The SG picks the right combination based on what your query class declares:

InterfacePurpose
IQuery<TEntity>Base: has Apply(IQueryable<TEntity>) that builds the pipeline
IQuery<TEntity, TResult>Adds Projection expression for Select()
IPagedQuery<TEntity>Adds Page, PageSize, Skip, Take
IPagedQuery<TEntity, TResult>Paged query with projection
IIncludableQuery<TEntity>Declares eager-loading paths via IncludePaths
IQueryHintsExecution hints: NoTracking, SplitQuery, IgnoreGlobalFilters
Your Query Class HasGenerated Interface
[Query<Order>]IQuery<Order>
[Query<Order, OrderDto>]IQuery<Order, OrderDto>
[Query<Order>] + Page/PageSizeIPagedQuery<Order>
[Query<Order, OrderDto>] + Page/PageSizeIPagedQuery<Order, OrderDto>

IIncludableQuery<T> and IQueryHints are mixin interfaces — you implement them alongside the base query:

[Query<Order, OrderDto>]
public partial class GetOrders : IQueryHints
{
public bool NoTracking => true; // Default: true (read-only)
public bool SplitQuery => true; // Avoid cartesian explosion
public bool IgnoreGlobalFilters => false;
[Filter]
public OrderStatus? Status { get; init; }
public int Page { get; init; } = 1;
public int PageSize { get; init; } = 20;
}

IQueryHints uses default interface members — you only override what you need:

HintDefaultDescription
NoTrackingtrueEntities are not tracked (read-only queries are the common case)
SplitQueryfalseUse split queries for collection navigations to avoid cartesian explosion
IgnoreGlobalFiltersfalseBypass global query filters (soft-delete, tenant, etc.)

IIncludableQuery<TEntity> declares navigation paths for eager loading using dot notation:

[Query<Order>]
public partial class GetOrderWithDetails : IIncludableQuery<Order>
{
public IReadOnlyList<string> IncludePaths => ["Customer", "Lines.Product"];
// ↑ Translates to: .Include(o => o.Customer).Include(o => o.Lines).ThenInclude(l => l.Product)
}

The runtime component that takes a query object and a source IQueryable, then executes it:

public interface IQueryExecutor
{
Task<PagedResult<TEntity>> ExecuteAsync<TEntity>(
IPagedQuery<TEntity> query,
IQueryable<TEntity> source,
CancellationToken cancellationToken = default) where TEntity : class;
Task<PagedResult<TResult>> ExecuteAsync<TEntity, TResult>(
IPagedQuery<TEntity, TResult> query,
IQueryable<TEntity> source,
CancellationToken cancellationToken = default)
where TEntity : class where TResult : class;
Task<IReadOnlyList<TEntity>> ExecuteAllAsync<TEntity>(
IQuery<TEntity> query,
IQueryable<TEntity> source,
CancellationToken cancellationToken = default) where TEntity : class;
}

The EF Core implementation (EfCoreQueryExecutor) automatically:

  1. Applies query hintsAsNoTracking(), AsSplitQuery() based on IQueryHints
  2. Applies global filters — soft-delete, tenant, temporal via IQueryFilterProvider (see Query Filters)
  3. Applies navigation filters — via FilterMapComposer + PragmaticQueryFilterVisitor
  4. Calls Apply() — your generated filter/sort pipeline
  5. Handles projection — applies the Projection expression via Select()
  6. Counts total items — a separate CountAsync() for paging metadata
  7. Pages the resultsSkip() + Take() from IPagedQuery

The result is a PagedResult<T> that follows the Result pattern:

var result = await executor.ExecuteAsync(query, source, ct);
// Pattern matching
return result.Match(
success: (items, total) => Ok(new { items, total, result.Page, result.TotalPages }),
failure: error => BadRequest(error.Message));
// Or direct access
if (result.IsSuccess)
{
var items = result.Items; // IReadOnlyList<T>
var total = result.TotalCount; // Total across all pages
var pages = result.TotalPages; // Calculated page count
var hasNext = result.HasNextPage;
}

The paged result carries both the data and paging metadata:

PropertyTypeDescription
ItemsIReadOnlyList<T>Items for the current page
TotalCountintTotal items across all pages
PageintCurrent page number (1-based)
PageSizeintItems per page
TotalPagesintCalculated: ceil(TotalCount / PageSize)
HasPreviousPageboolPage > 1
HasNextPageboolPage < TotalPages

PagedResult<T> defaults to QueryError as its error type. For custom error types, use PagedResult<T, TError>.

Here is the full flow from HTTP request to response:

HTTP GET /api/reservations/search?Status=Confirmed&CheckInSort=Descending&Page=2
1. Model binding → SearchReservationsQuery { Status = Confirmed, CheckInSort = Descending, Page = 2 }
2. Endpoint pipeline invokes IQueryExecutor
3. Executor gets IQueryable<Reservation> from repository
4. Executor applies IQueryHints (AsNoTracking)
5. Executor applies global query filters (soft-delete, tenant)
6. Executor calls query.Apply(source) → adds WHERE + ORDER BY
7. Executor counts total (SELECT COUNT)
8. Executor pages (SKIP 20 TAKE 20)
9. Executor projects (SELECT → ReservationSummaryDto)
10. Returns PagedResult<ReservationSummaryDto>
HTTP 200 { items: [...], totalCount: 47, page: 2, pageSize: 20, totalPages: 3 }

You wrote 15 lines of C#. The source generator and runtime did the rest.