Skip to content

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.


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 }

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()
);
});
  • DbContext is keyed by boundary — if the entity has [BelongsTo<BookingBoundary>], the handler resolves the DbContext keyed by typeof(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/PageSize are always query parameters.
  • Claim binding — properties marked [FromClaim("sub")] are populated from HttpContext.User.

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;
}

The EF Core implementation. It adds:

FeatureHow
Root filtersIQueryFilterProvider.GetCombinedFilter<T>(context)
Navigation filtersFilterMapComposer + PragmaticQueryFilterVisitor
Query hintsAsNoTracking(), AsSplitQuery(), IgnoreQueryFilters()
CachingICacheStack for queries implementing ICacheable
Error mappingEF 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
);

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;
}
HintDefaultEffect
NoTrackingtrueEntities are not tracked — best for read-only queries
SplitQueryfalseSingle SQL query — set to true with multiple collection navigations
IgnoreGlobalFiltersfalseEF 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.


This is the most complex step. It has two phases: root filtering and navigation filtering.

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.

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:

ModeSoft-DeleteTenantVisibilityPermission
NormalYesYesYesYes
AdminYesYesNoNo
BackgroundYesNoNoNo
RawNoNoNoNo

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 chainsInclude(o => o.Items).ThenInclude(i => i.Tags) — filters both Items and Tags if both have registered filters.
  • Respects IQueryFilterToggle — if you disabled SoftDeleteFilter_LineItem, the visitor skips it.

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.

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 DESC

All three filter sources are composed into a single expression tree before EF Core translates it to SQL — there is no in-memory filtering.


The executor runs two SQL queries for paged results:

// Query 1: Count
var totalCount = await source.CountAsync(ct);
// Query 2: Data
var items = await source
.Skip(query.Skip) // (Page - 1) * PageSize
.Take(query.Take) // PageSize
.Select(query.Projection) // DTO mapping (if IQuery<T, R>)
.ToListAsync(ct);

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.

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 + tags

The cache stores the PagedResult<T> — both the items and the total count. On cache hit, no SQL is executed.


The result carries data and paging metadata:

var result = await executor.ExecuteAsync(query, source, ct);
// Success path
if (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 path
if (result.IsFailure)
{
result.Error // QueryError with Type (Timeout, Connection, Database)
}

EfCoreQueryExecutor catches EF Core exceptions and maps them to typed errors:

EF Core ExceptionQueryError TypeMeaning
OperationCanceledExceptionTimeoutQuery exceeded timeout or was cancelled
DbUpdateException (connection)ConnectionDatabase connectivity issue
Other DbUpdateExceptionDatabaseQuery translation or execution error

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.


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 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 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;
}

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 (AsNoTracking is 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.

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;
}

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);

Replace the default filter provider to add application-specific filter logic:

services.AddScoped<IQueryFilterProvider, MyCustomFilterProvider>();

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;
}
}

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 { /* ... */ }