Query Read Strategy
This document defines the read-side strategy for all query handlers. The IDatabaseContext pattern is the standard.
Canonical rules: this file. Rationale:
docs/decisions/idatabasecontext-over-per-aggregate-read-stores.md.
1. Why IDatabaseContext
The previous read-side convention defined a separate IXxxReadStore interface per aggregate. For each aggregate, this produced three files: the interface, the Infrastructure implementation, and the handler. None of those extra files contain business logic. They contain only EF Core projection code wrapped in an interface that exists solely to satisfy a dependency rule. The ceremony grows with every new query without adding any value.
The alternative of injecting AppDbContext directly into query handlers would violate the dependency rule: Application.Read would reference Infrastructure. That is not acceptable.
IDatabaseContext is the middle path. It is a single interface defined in Application.Read.Contracts/Shared/. It exposes one IQueryable<T> property per aggregate. AppDbContext in Infrastructure implements it. Query handlers inject IDatabaseContext and write LINQ projections directly, with no per-aggregate indirection. Application.Read still does not reference Infrastructure. The dependency boundary is preserved.
IDatabaseContext is not called IReadRepository or IReadStore. It is not a repository. It does not load aggregates. It exposes queryable collections for EF Core projections. The name reflects its role: it is the read-side view of the database context.
2. The IDatabaseContext Interface
IDatabaseContext lives in Application.Read.Contracts/Shared/IDatabaseContext.cs. Add one property when a new aggregate needs query handlers.
// GOOD: query handler injects IDatabaseContextinternal sealed class GetPostByIdQueryHandler : IQueryHandler<GetPostByIdQuery, PostResult>{ private readonly IDatabaseContext _db;
public GetPostByIdQueryHandler(IDatabaseContext db) { _db = db; }
public async Task<PostResult> HandleAsync( GetPostByIdQuery query, CancellationToken cancellationToken) { // Write projections directly var result = await _db.Posts .Where(p => p.Id == query.PostId) .Select(p => new PostResult { Id = p.Id, Title = p.Title.Value }) .FirstOrDefaultAsync(cancellationToken);
if (result is null) { throw new PostNotFoundException(query.PostId); }
return result; }}
// BAD: query handler injects IPostRepositoryinternal sealed class GetPostByIdQueryHandler : IQueryHandler<GetPostByIdQuery, PostResult>{ private readonly IPostRepository _postRepository; // BAD: repository in query handler}
// BAD: query handler injects AppDbContext directlyinternal sealed class GetPostByIdQueryHandler : IQueryHandler<GetPostByIdQuery, PostResult>{ private readonly AppDbContext _dbContext; // BAD: references Infrastructure project}3. Writing Projections
Aggregate state (TPH columns)
Domain code uses p.State is PublishedPostState. Query handlers run against IDatabaseContext, where State is not mapped to SQL directly. Filter and sort on the TPH shadow columns via shared constants (for example PostStateColumns) and EF.Property:
// GOOD: SQL filter on TPH discriminator columnvar baseQuery = _db.Posts .Where(p => EF.Property<string>(p, PostStateColumns.StateType) == PostStateColumns.Published);
var items = await baseQuery .OrderByDescending(p => EF.Property<DateTimeOffset>(p, PostStateColumns.PublishedAt)) .Select(p => new PostSummary { Id = p.Id, Title = p.Title.Value, PublishedAt = EF.Property<DateTimeOffset>(p, PostStateColumns.PublishedAt) }) .ToListAsync(cancellationToken);Projections that need a PostState instance for display logic SHOULD read the three column values in Select and reconstruct the union in memory with a shared helper (for example PostStateQuery.FromColumns).
// DON'T: filter on State in LINQ when State is ignored in EF configuration.Where(p => p.State is PublishedPostState) // may fail translation or client-evaluateDomain tests and command handlers continue to use State is PublishedPostState on loaded aggregates. Only Application.Read SQL projections use column predicates.
Simple Single-Aggregate Projection
public async Task<PostResult> HandleAsync( GetPostByIdQuery query, CancellationToken cancellationToken){ var result = await _db.Posts .Where(p => p.Id == query.PostId) .Select(p => new PostResult { Id = p.Id, Title = p.Title.Value, Content = p.Content.Value, AuthorName = p.Author.DisplayName, PublishedAt = EF.Property<DateTimeOffset?>(p, PostStateColumns.PublishedAt) }) .FirstOrDefaultAsync(cancellationToken);
if (result is null) { throw new PostNotFoundException(query.PostId); }
return result;}Paginated Projection
public async Task<PagedResult<PostSummary>> HandleAsync( GetAllPostsQuery query, CancellationToken cancellationToken){ var pageSize = Math.Min( query.Pagination.PageSize, PaginationParameters.MaxPageSize);
var baseQuery = _db.Posts .Where(p => EF.Property<string>(p, PostStateColumns.StateType) == PostStateColumns.Published);
var totalCount = query.Pagination.SkipTotalCount ? 0 : await baseQuery.CountAsync(cancellationToken);
var items = await baseQuery .OrderByDescending(p => EF.Property<DateTimeOffset>(p, PostStateColumns.PublishedAt)) .Skip((query.Pagination.PageNumber - 1) * pageSize) .Take(pageSize) .Select(p => new PostSummary { Id = p.Id, Title = p.Title.Value, PublishedAt = EF.Property<DateTimeOffset>(p, PostStateColumns.PublishedAt) }) .ToListAsync(cancellationToken);
return new PagedResult<PostSummary> { Items = items, TotalCount = totalCount, PageNumber = query.Pagination.PageNumber, PageSize = pageSize };}Multi-Aggregate Projection
IDatabaseContext exposes multiple aggregates. A single LINQ projection can join across them without multiple round trips:
public async Task<PostWithAuthorResult> HandleAsync( GetPostWithAuthorQuery query, CancellationToken cancellationToken){ // Use an explicit Join to avoid correlated subquery risk. // Correlated subqueries inside Select may be evaluated client-side in some // EF Core configurations, producing N+1 queries. return await _db.Posts .Where(p => EF.Property<string>(p, PostStateColumns.StateType) == PostStateColumns.Published) .Join( _db.Authors, p => p.AuthorId, a => a.Id, (p, a) => new PostWithAuthorResult { PostId = p.Id, PostTitle = p.Title.Value, AuthorName = a.DisplayName, PublishedAt = EF.Property<DateTimeOffset>(p, PostStateColumns.PublishedAt) }) .OrderByDescending(r => r.PublishedAt) .ToListAsync(cancellationToken);}Avoid correlated subqueries (a
Where/Selectinside aSelect) for cross-aggregate joins. EF Core may evaluate these client-side in some configurations, producing N+1 queries. UseJoinorSelectManyfor cross-aggregate projections to guarantee SQL JOIN translation.
4. Null Handling
Query handlers MUST throw AggregateNotFoundException subclasses when a resource is not found. MUST NOT return null to the caller.
// GOOD: throw when not foundvar result = await _db.Posts .Where(p => p.Id == query.PostId) .Select(p => new PostResult { ... }) .FirstOrDefaultAsync(cancellationToken);
if (result is null){ throw new PostNotFoundException(query.PostId);}
return result;
// BAD: returning nullvar result = await _db.Posts .Where(p => p.Id == query.PostId) .Select(p => new PostResult { ... }) .FirstOrDefaultAsync(cancellationToken);
return result; // BAD: caller must null-check; GlobalExceptionHandler cannot map null to 4045. AsNoTracking Note
AsNoTracking() is not needed when using Select projections. EF Core does not track projected types because the result type is not a registered entity. Adding AsNoTracking() is redundant and adds noise.
// GOOD: no AsNoTracking() neededvar result = await _db.Posts .Where(p => p.Id == query.PostId) .Select(p => new PostResult { Id = p.Id, Title = p.Title.Value }) .FirstOrDefaultAsync(cancellationToken);
// BAD: redundant AsNoTracking() on a Select projectionvar result = await _db.Posts .AsNoTracking() // BAD: redundant; EF Core does not track projected types .Where(p => p.Id == query.PostId) .Select(p => new PostResult { Id = p.Id, Title = p.Title.Value }) .FirstOrDefaultAsync(cancellationToken);6. IStreamQuery for Internal Processing
LiteBus supports IStreamQuery<T> and IStreamQueryHandler<T> for streaming scenarios. This is appropriate for background processing and data export where the consumer processes items incrementally. It is NOT appropriate for HTTP endpoints because the HTTP client buffers the entire response before processing it, eliminating the streaming benefit.
// For background processing and data export onlypublic sealed record ExportAllPostsQuery : IStreamQuery<PostExportRow>;
internal sealed class ExportAllPostsQueryHandler : IStreamQueryHandler<ExportAllPostsQuery, PostExportRow>{ private readonly IDatabaseContext _db;
public ExportAllPostsQueryHandler(IDatabaseContext db) { _db = db; }
public async IAsyncEnumerable<PostExportRow> HandleAsync( ExportAllPostsQuery query, [EnumeratorCancellation] CancellationToken cancellationToken) { await foreach (var post in _db.Posts .Select(p => new PostExportRow { Id = p.Id, Title = p.Title.Value }) .AsAsyncEnumerable() .WithCancellation(cancellationToken)) { yield return post; } }}HTTP list endpoints use PagedResult<T> with PaginationParameters. See docs/decisions/pagination-convention.md for the full pagination rationale.
Document query shapes and read models in the relevant use case doc at docs/domain/{feature}/{use-case}.md.