The Query Pipeline
From HTTP request to SQL and back — every step, every decision point, every extension hook.
This document explains how a query flows through the Pragmatic stack. Understanding the pipeline helps you make informed decisions about performance, caching, filtering, and projection.
Pipeline Overview
Section titled “Pipeline Overview”HTTP GET /api/reservations/search?Status=Confirmed&Page=2 │ ▼┌─────────────────────────────────────────────────┐│ 1. Endpoint Handler (source-generated) ││ • Model binding → Query object ││ • Claim binding from HttpContext.User ││ • Pre-processors (authorization, etc.) │└───────────────────┬─────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────┐│ 2. IQueryExecutor.ExecuteAsync() ││ • Resolve from DI ││ • Receives: query + IQueryable<TEntity> │└───────────────────┬─────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────┐│ 3. Prepare Source ││ • AsNoTracking() (if IQueryHints.NoTracking)││ • AsSplitQuery() (if IQueryHints.SplitQuery)││ • IgnoreQueryFilters() (if requested) ││ • Include() paths (from IIncludableQuery) │└───────────────────┬─────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────┐│ 4. Apply Global Filters ││ a. Build FilterContext (mode, tenant, user) ││ b. IQueryFilterProvider.GetCombinedFilter() ││ → Soft-delete, tenant, permission filters ││ c. FilterMapComposer.ApplyNavigationFilters()││ → PragmaticQueryFilterVisitor walks ││ Include expressions, injects .Where() │└───────────────────┬─────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────┐│ 5. query.Apply(source) ││ • YOUR generated filters (WHERE clauses) ││ • YOUR generated sorts (ORDER BY) ││ • This is the SG-generated Apply() method │└───────────────────┬─────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────┐│ 6. Count + Page + Project ││ a. SELECT COUNT(*) (total items) ││ b. Skip() + Take() (pagination) ││ c. Select(Projection) (DTO mapping) ││ d. ToListAsync() (materialize) │└───────────────────┬─────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────┐│ 7. Return PagedResult<TResult> ││ • Items, TotalCount, Page, TotalPages ││ • Or QueryError on failure │└───────────────────┬─────────────────────────────┘ │ ▼HTTP 200 { items: [...], totalCount: 47, page: 2, totalPages: 3 }Step 1: Endpoint Handler
Section titled “Step 1: Endpoint Handler”When a query class has both [Query<T, R>] and [Endpoint], the source generator produces a Minimal API handler.
// ═══ What YOU write ═══[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;}// ═══ What the SG generates (simplified) ═══app.MapGet("api/v1/reservations/search", async ( [FromQuery] Guid? guestId, [FromQuery] string? status, [FromQuery] string? checkInSort, [FromQuery] int page, [FromQuery] int pageSize, [FromKeyedServices(typeof(BookingBoundary))] DbContext dbContext, [FromServices] IQueryExecutor executor, HttpContext httpContext, CancellationToken ct) =>{ // 1. Create query instance with bound parameters var query = new SearchReservationsQuery { GuestId = guestId, Status = status is not null ? Enum.Parse<ReservationStatus>(status) : null, CheckInSort = checkInSort is not null ? Enum.Parse<SortDirection>(checkInSort) : null, Page = page, PageSize = pageSize };
// 2. Bind claim parameters (if any [FromClaim] properties) // query.UserId = httpContext.User.FindFirst("sub")?.Value;
// 3. Execute pre-processors (if registered) // var preResult = await preprocessor.ProcessAsync(context, ct);
// 4. Execute the query var source = dbContext.Set<Reservation>().AsQueryable(); var result = await executor.ExecuteAsync<Reservation, ReservationSummaryDto>( query, source, ct);
// 5. Map result to HTTP response return result.Match( success: _ => Results.Ok(result), failure: error => error.ToResult() );});Key Points
Section titled “Key Points”- DbContext is keyed by boundary — if the entity has
[BelongsTo<BookingBoundary>], the handler resolves the DbContext keyed bytypeof(BookingBoundary). - IQueryExecutor is resolved from DI — it carries the filter pipeline, caching, and error mapping.
- Query parameters are bound by convention —
[Filter]properties become[FromQuery]parameters.Page/PageSizeare always query parameters. - Claim binding — properties marked
[FromClaim("sub")]are populated fromHttpContext.User.
Step 2: IQueryExecutor
Section titled “Step 2: IQueryExecutor”The runtime component that orchestrates execution. It is not query-specific — one instance handles all queries.
public interface IQueryExecutor{ // Paged query with projection Task<PagedResult<TResult>> ExecuteAsync<TEntity, TResult>( IPagedQuery<TEntity, TResult> query, IQueryable<TEntity> source, CancellationToken ct = default) where TEntity : class where TResult : class;
// Paged query without projection (returns entities) Task<PagedResult<TEntity>> ExecuteAsync<TEntity>( IPagedQuery<TEntity> query, IQueryable<TEntity> source, CancellationToken ct = default) where TEntity : class;
// Non-paged query (returns all matching items) Task<IReadOnlyList<TEntity>> ExecuteAllAsync<TEntity>( IQuery<TEntity> query, IQueryable<TEntity> source, CancellationToken ct = default) where TEntity : class;}EfCoreQueryExecutor
Section titled “EfCoreQueryExecutor”The EF Core implementation. It adds:
| Feature | How |
|---|---|
| Root filters | IQueryFilterProvider.GetCombinedFilter<T>(context) |
| Navigation filters | FilterMapComposer + PragmaticQueryFilterVisitor |
| Query hints | AsNoTracking(), AsSplitQuery(), IgnoreQueryFilters() |
| Caching | ICacheStack for queries implementing ICacheable |
| Error mapping | EF Core exceptions → typed QueryError |
Constructor — you rarely instantiate this yourself; it is registered by the generated DI code:
var executor = new EfCoreQueryExecutor( filterProvider, // IQueryFilterProvider — root filters filterMapComposer, // FilterMapComposer? — navigation filters filterToggle, // IQueryFilterToggle? — disabled state cacheStack, // ICacheStack? — optional caching logger // ILogger? — optional diagnostics);Step 3: Prepare Source
Section titled “Step 3: Prepare Source”Before applying your query’s filters, the executor prepares the IQueryable:
// Internal: PrepareSource()private IQueryable<TEntity> PrepareSource<TEntity>( IQueryable<TEntity> source, IQuery<TEntity> query){ // 1. Query hints if (query is IQueryHints hints) { if (hints.NoTracking) source = source.AsNoTracking(); if (hints.SplitQuery) source = source.AsSplitQuery(); if (hints.IgnoreGlobalFilters) source = source.IgnoreQueryFilters(); }
// 2. Include paths if (query is IIncludableQuery<TEntity> includable) { foreach (var path in includable.IncludePaths) source = source.Include(path); }
return source;}IQueryHints Defaults
Section titled “IQueryHints Defaults”| Hint | Default | Effect |
|---|---|---|
NoTracking | true | Entities are not tracked — best for read-only queries |
SplitQuery | false | Single SQL query — set to true with multiple collection navigations |
IgnoreGlobalFilters | false | EF Core global query filters apply — set to true for admin views |
Most queries don’t implement IQueryHints at all — the defaults are correct for 90% of cases. Override only when you need tracked entities (mutations done through queries) or need to bypass global filters.
Step 4: Apply Global Filters
Section titled “Step 4: Apply Global Filters”This is the most complex step. It has two phases: root filtering and navigation filtering.
Phase 4a: Build FilterContext
Section titled “Phase 4a: Build FilterContext”var context = new FilterContext{ TenantId = tenantContext?.TenantId, UserId = currentUser?.Id, Now = timeProvider.GetUtcNow(), DisabledFilters = filterToggle?.GetDisabledFilters() ?? new HashSet<Type>(), Mode = filterToggle?.CurrentMode ?? FilterMode.Normal};The FilterContext carries all the runtime information filters need to decide what to show.
Phase 4b: Root Filters
Section titled “Phase 4b: Root Filters”IQueryFilterProvider.GetCombinedFilter<TEntity>(context) combines all registered filters into a single Expression<Func<TEntity, bool>>:
// Conceptually (simplified):// 1. Soft-delete filter: e => !e.IsDeleted (if [SoftDelete])// 2. Tenant filter: e => e.TenantId == context.TenantId (if tenant-aware)// 3. Permission filter: e => e.TeamId == context.TeamId (if IPermissionBasedFilter)// Combined: e => !e.IsDeleted && e.TenantId == "T1" && e.TeamId == "team-42"Which filters actually apply depends on FilterMode:
| Mode | Soft-Delete | Tenant | Visibility | Permission |
|---|---|---|---|---|
| Normal | Yes | Yes | Yes | Yes |
| Admin | Yes | Yes | No | No |
| Background | Yes | No | No | No |
| Raw | No | No | No | No |
Phase 4c: Navigation Filters
Section titled “Phase 4c: Navigation Filters”After root filters, FilterMapComposer builds a FilterMap — a dictionary of Type → Expression. Then PragmaticQueryFilterVisitor walks the expression tree and injects .Where() into collection navigations:
// Before visitor:query.Include(o => o.Items)
// After visitor (if LineItem has [SoftDelete]):query.Include(o => o.Items.Where(i => !i.IsDeleted))The visitor is smart:
- Detects already-filtered navigations — if you wrote
Include(o => o.Items.Where(...)), the visitor won’t double-filter. - Handles ThenInclude chains —
Include(o => o.Items).ThenInclude(i => i.Tags)— filters bothItemsandTagsif both have registered filters. - Respects IQueryFilterToggle — if you disabled
SoftDeleteFilter_LineItem, the visitor skips it.
Step 5: query.Apply()
Section titled “Step 5: query.Apply()”This is YOUR code — the source-generated Apply() method. It adds WHERE and ORDER BY clauses based on the query’s [Filter] and [Sort] properties.
// ═══ Generated for SearchReservationsQuery ═══public IQueryable<Reservation> Apply(IQueryable<Reservation> query){ // Required filters — always applied // (none in this example)
// Optional filters — applied when non-null if (GuestId is not null) query = query.Where(e => e.GuestId == GuestId); if (Status is not null) query = query.Where(e => e.Status == Status);
// Sort — default descending var checkInDir = CheckInSort ?? SortDirection.Descending; query = checkInDir == SortDirection.Ascending ? query.OrderBy(e => e.CheckIn) : query.OrderByDescending(e => e.CheckIn);
return query;}The Apply() method is a pure IQueryable transform — it adds LINQ operators but does not execute anything. The executor calls it after global filters, so your WHERE conditions compose with soft-delete/tenant filters via AND.
Order of Operations
Section titled “Order of Operations”The final SQL WHERE clause combines three sources:
WHERE /* 4b: Root filters */ NOT IsDeleted AND TenantId = 'T1'
/* 5: Your filters (from Apply) */ AND GuestId = @p0 AND Status = 'Confirmed'
ORDER BY CheckIn DESCAll three filter sources are composed into a single expression tree before EF Core translates it to SQL — there is no in-memory filtering.
Step 6: Count + Page + Project
Section titled “Step 6: Count + Page + Project”The executor runs two SQL queries for paged results:
// Query 1: Countvar totalCount = await source.CountAsync(ct);
// Query 2: Datavar items = await source .Skip(query.Skip) // (Page - 1) * PageSize .Take(query.Take) // PageSize .Select(query.Projection) // DTO mapping (if IQuery<T, R>) .ToListAsync(ct);Projection
Section titled “Projection”If the query implements IQuery<TEntity, TResult>, the Projection expression is applied via Select(). This is typically the FromEntity expression generated by [MapFrom]:
// On the DTO:[MapFrom<Reservation>]public partial class ReservationSummaryDto{ public Guid Id { get; init; } public string GuestName { get; init; } public ReservationStatus Status { get; init; }}
// Generated:public static readonly Expression<Func<Reservation, ReservationSummaryDto>> FromEntity = e => new ReservationSummaryDto { Id = e.PersistenceId, GuestName = e.Guest.FirstName + " " + e.Guest.LastName, Status = e.Status };Projections are translated to SQL SELECT — only the columns needed by the DTO are fetched from the database. This is significantly more efficient than loading full entities and mapping in memory.
Caching
Section titled “Caching”If the query is marked with [Cacheable], the SG generates ICacheable implementation and the executor checks ICacheStack before hitting the database:
[Cacheable(Duration = "5m", Tags = ["properties"])]public partial class GetPopularProperties{ public int Page { get; init; } = 1; public int PageSize { get; init; } = 20;}
// SG generates: GetCacheKey() → "GetPopularProperties:Page=1:PageSize=20"// SG generates: GetCacheOptions() → CacheEntryOptions with 5 min duration + tagsThe cache stores the PagedResult<T> — both the items and the total count. On cache hit, no SQL is executed.
Step 7: PagedResult
Section titled “Step 7: PagedResult”The result carries data and paging metadata:
var result = await executor.ExecuteAsync(query, source, ct);
// Success pathif (result.IsSuccess){ result.Items // IReadOnlyList<ReservationSummaryDto> result.TotalCount // 47 (total across all pages) result.Page // 2 result.PageSize // 20 result.TotalPages // 3 result.HasNextPage // true}
// Error pathif (result.IsFailure){ result.Error // QueryError with Type (Timeout, Connection, Database)}Error Mapping
Section titled “Error Mapping”EfCoreQueryExecutor catches EF Core exceptions and maps them to typed errors:
| EF Core Exception | QueryError Type | Meaning |
|---|---|---|
OperationCanceledException | Timeout | Query exceeded timeout or was cancelled |
DbUpdateException (connection) | Connection | Database connectivity issue |
Other DbUpdateException | Database | Query translation or execution error |
Without an Endpoint
Section titled “Without an Endpoint”You can use the query pipeline without endpoints — for service methods, background jobs, or tests:
public class ReservationService( IQueryExecutor executor, IReadRepository<Reservation, Guid> reservations){ public async Task<PagedResult<ReservationSummaryDto>> Search( Guid? guestId, ReservationStatus? status, CancellationToken ct) { var query = new SearchReservationsQuery { GuestId = guestId, Status = status, Page = 1, PageSize = 50 };
return await executor.ExecuteAsync(query, reservations.Query(), ct); }}The pipeline (filters, projection, paging) works the same way regardless of whether the query comes from an HTTP endpoint or a service method.
Query Interface Hierarchy
Section titled “Query Interface Hierarchy”The source generator chooses the interface based on your query class:
IQuery<TEntity> ← Base: has Apply()├── IQuery<TEntity, TResult> ← + Projection expression├── IPagedQuery<TEntity> ← + Page, PageSize, Skip, Take│ └── IPagedQuery<TEntity, TResult> ← Paged + Projection├── IIncludableQuery<TEntity> ← Mixin: IncludePaths└── IQueryHints ← Mixin: NoTracking, SplitQuery, IgnoreGlobalFilters| Your Query Has | Generated Interface |
|---|---|
[Query<Order>] | IQuery<Order> |
[Query<Order, OrderDto>] | IQuery<Order, OrderDto> |
[Query<Order>] + Page/PageSize | IPagedQuery<Order> |
[Query<Order, OrderDto>] + Page/PageSize | IPagedQuery<Order, OrderDto> |
IIncludableQuery and IQueryHints are mixed in alongside the base — they don’t change the base interface choice:
// SG generates: IPagedQuery<Order, OrderDto>, IQueryHints, IIncludableQuery<Order>[Query<Order, OrderDto>]public partial class GetOrders : IQueryHints, IIncludableQuery<Order>{ public bool NoTracking => true; public bool SplitQuery => true; public IReadOnlyList<string> IncludePaths => ["Customer", "Lines.Product"];
public int Page { get; init; } = 1; public int PageSize { get; init; } = 20;}Performance Considerations
Section titled “Performance Considerations”When to Use Projections
Section titled “When to Use Projections”Always prefer [Query<T, R>] over [Query<T>] for read endpoints. Projections:
- Fetch only the columns the DTO needs (less I/O, less memory)
- Skip change tracking entirely (
AsNoTrackingis the default) - Avoid materializing navigation properties you don’t need
Use [Query<T>] (without projection) only when you need full entities — typically for mutations or when the caller needs to modify and save the entity.
When to Use SplitQuery
Section titled “When to Use SplitQuery”Activate SplitQuery = true when your query includes multiple collection navigations at the same depth:
// Order has Items (collection) and Tags (collection)// Without split: cartesian explosion → 3 items × 2 tags = 6 rows per order// With split: 3 separate SQL queries, no duplication[Query<Order, OrderDetailDto>]public partial class GetOrderDetail : IQueryHints{ public bool SplitQuery => true;}Filter Performance
Section titled “Filter Performance”All filters — root, navigation, and query — are composed into the expression tree before EF Core translates to SQL. There is no in-memory filtering. The database does all the work.
For complex filter combinations, check the generated SQL with EF Core logging:
optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information);Extending the Pipeline
Section titled “Extending the Pipeline”Custom IQueryFilterProvider
Section titled “Custom IQueryFilterProvider”Replace the default filter provider to add application-specific filter logic:
services.AddScoped<IQueryFilterProvider, MyCustomFilterProvider>();Custom IQueryExecutor
Section titled “Custom IQueryExecutor”Wrap or replace EfCoreQueryExecutor for cross-cutting concerns (telemetry, throttling):
public class InstrumentedQueryExecutor( EfCoreQueryExecutor inner, ILogger<InstrumentedQueryExecutor> logger) : IQueryExecutor{ public async Task<PagedResult<TResult>> ExecuteAsync<TEntity, TResult>( IPagedQuery<TEntity, TResult> query, IQueryable<TEntity> source, CancellationToken ct) { using var activity = ActivitySource.StartActivity("Query"); activity?.SetTag("query.type", query.GetType().Name);
var result = await inner.ExecuteAsync(query, source, ct);
activity?.SetTag("query.total_count", result.TotalCount); return result; }}Pre/Post Processors on Endpoints
Section titled “Pre/Post Processors on Endpoints”Query endpoints support pre-processors (for authorization, rate-limiting) and post-processors (for logging, metrics):
[Query<Reservation, ReservationSummaryDto>][Endpoint(HttpVerb.Get, "api/v1/reservations/search")][PreProcess<AuthorizationProcessor>][PostProcess<AuditLogProcessor>]public partial class SearchReservationsQuery { /* ... */ }Related Guides
Section titled “Related Guides”- Query System — Declaring queries, filters, sorts, joins
- Grid Filtering — Dynamic grid framework integration
- Query Filters — Soft-delete, tenant, permission filters
- Projections and Views — Computed expressions and aggregations
- Data Sources — Loading strategies and profiles
- Filter Pipeline — Navigation filter internals