Skip to main content

Read Side: Queries & Projections

The Read Side of CQRS is optimized for fast queries by using projections—denormalized copies of your domain data shaped for specific query needs.

Queries: Asking for Data

In CQRS, you retrieve data through queries:

public sealed record GetUserProfileQuery(Guid UserId) : IQuery;

public sealed record GetUserProfileQueryResult(
string Name,
string Email,
DateTime CreatedAt);

Queries ask questions without modifying state. They're processed by Query Handlers that:

  1. Access the read model (optimized for this specific query)
  2. Apply filters, sorting, pagination
  3. Return exactly what the client needs

Before the query handler runs, PearDrop can execute a Query Authorizer to enforce read permissions. See Query Authorizers.

internal sealed class GetUserProfileQueryHandler : 
IQueryProcessor<GetUserProfileQuery, GetUserProfileQueryResult>
{
private readonly IMyReadModels readModels; // Injected read model service

public async Task<QueryResult<GetUserProfileQueryResult>> Handle(
GetUserProfileQuery request,
CancellationToken cancellationToken)
{
var user = await readModels.Users
.Where(u => u.Id == request.UserId)
.Select(u => new GetUserProfileQueryResult(
u.Name,
u.Email,
u.CreatedAt))
.FirstOrDefaultAsync(cancellationToken);

return user != null
? QueryResult<GetUserProfileQueryResult>.Succeeded(user)
: QueryResult<GetUserProfileQueryResult>.Failed();
}
}

Projections: Denormalized Data

A projection is a read-only copy of your domain data, optimized for queries. Instead of navigating complex domain aggregates, projections flatten the data structure:

Domain Aggregate (Write Side):

// Complex business logic, invariants, private state
public class User : AggregateRoot
{
private List<Role> _roles; // Hidden implementation
private DateTime _whenDisabled;

public void AssignRole(Role role) { ... } // Business method
}

Projection (Read Side):

public sealed class UserProjection : IProjection
{
public required Guid Id { get; init; }
public required string Name { get; init; }
public required string Email { get; init; }
public required ICollection<RoleProjection> Roles { get; init; } // Pre-loaded, flat
public bool IsDisabled { get; init; } // Pre-computed values
}

Key differences:

  • No business logic or invariants
  • All data public and queryable
  • Flat structure (no hidden state)
  • Pre-computed values for fast filtering
  • Maps to database views for efficient querying

Read Models: The Service Layer

A Read Model service provides type-safe access to projections without exposing the underlying database:

public interface IMyReadModels : IModuleReadModels
{
IReadModelQueryable<UserProjection> Users { get; }
IReadModelQueryable<RoleProjection> Roles { get; }
}

// In your query handler - inject the service, not the DbContext
private readonly IMyReadModels readModels; // ✅ Correct: Use service interface

This pattern:

  • Encapsulates the read DbContext (never leaked to consumers)
  • Enforces query limits (MaxTakeSize) for performance
  • Applies tenant isolation automatically
  • Provides a consistent query interface across your module

How Projections Stay in Sync

When you execute a command (write side):

  1. Domain aggregates change
  2. Events are raised
  3. Changes are saved to the write database
  4. Projections are updated via database triggers or reconciliation jobs
  5. Next query reads the fresh projection

This separation means:

  • Commands enforce business rules
  • Queries get fresh, pre-shaped data
  • Performance is optimized for each operation

Next Steps