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:
- Access the read model (optimized for this specific query)
- Apply filters, sorting, pagination
- 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):
- Domain aggregates change
- Events are raised
- Changes are saved to the write database
- Projections are updated via database triggers or reconciliation jobs
- 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
- How to secure read requests? See Query Authorizers
- How to create read models? See Read Models
- How to wire write persistence? See Write Model DbContext