Read Models
Read models provide a separate, optimized view of data for queries and reporting. Unlike write models (domain aggregates), read models are denormalized, flat, and view-based. In PearDrop, read models live in a completely separate DbContext inheriting from PearDropReadDbContextBase<T>.
Architecture: Write vs. Read
| Aspect | Write Model | Read Model |
|---|---|---|
| Location | Data/WriteModel/ | Data/ReadModel/ |
| Base Class | PearDropDbContext<T> | PearDropReadDbContextBase<T> |
| Purpose | Domain logic, commands | Optimized queries, reporting |
| Structure | Normalized aggregates | Denormalized projections |
| Consistency | Strong (transactions) | Eventual (views/sync) |
| Registration | AddPearDropSqlServerDbContextFactory<T> | AddPearDropSqlServerDbContextFactory<T> |
| Tenancy | Auto-filtered by base class | Manual filtering needed |
Key Principle: Write model owns the truth; read model optimizes for queries.
Read DbContext Implementation
using Microsoft.EntityFrameworkCore;
using PearDrop.Database;
namespace MyApp.Module.Data.ReadModel;
/// <summary>
/// Read-optimized DbContext for MyApp module queries.
/// Maps only to database views and denormalized tables, no domain logic.
/// </summary>
public class MyAppReadDbContext : PearDropReadDbContextBase<MyAppReadDbContext>
{
/// <summary>
/// Equipment projection for listing, searching, filtering.
/// </summary>
public DbSet<EquipmentProjection> Equipment { get; set; }
/// <summary>
/// Checkouts projection for reporting and user visibility.
/// </summary>
public DbSet<CheckoutProjection> Checkouts { get; set; }
/// <summary>
/// Constructor receives only DbContextOptions.
/// No multi-tenancy, domain event, or persistence modifier complexity.
/// </summary>
public MyAppReadDbContext(DbContextOptions<MyAppReadDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Configure projections
this.ConfigureEquipmentProjection(modelBuilder);
this.ConfigureCheckoutProjection(modelBuilder);
}
private void ConfigureEquipmentProjection(ModelBuilder modelBuilder)
{
modelBuilder.Entity<EquipmentProjection>()
.ToView("vw_Equipment", "ReadModel") // Maps to database view
.HasNoKey(); // Views typically have no key in EF
}
private void ConfigureCheckoutProjection(ModelBuilder modelBuilder)
{
modelBuilder.Entity<CheckoutProjection>()
.ToView("vw_Checkout", "ReadModel")
.HasNoKey();
}
}
Key Differences from Write DbContext:
- Only receives
DbContextOptions<T>(no multi-tenancy, domain events, persistence modifiers) - Maps to views with
.ToView()or denormalized tables - Uses
.HasNoKey()for view projections - No
ChangeProcessors, noIgnore(DomainEvents) - Simpler
OnModelCreating()with no complex configuration
Projection Entity Classes
Projections are NOT domain entities — they're flat, read-only data containers:
using PearDrop.Database;
namespace MyApp.Module.Data.ReadModel.Projections;
/// <summary>
/// Read-model projection of Equipment for queries.
/// Implements IProjection marker interface.
/// No domain logic, methods, or events.
/// </summary>
public sealed class EquipmentProjection : IProjection
{
/// <summary>
/// Primary key from Equipment aggregate.
/// </summary>
public required Guid Id { get; init; }
/// <summary>
/// Equipment name (from aggregate).
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Category for filtering.
/// </summary>
public required string Category { get; init; }
/// <summary>
/// Current availability status.
/// </summary>
public required bool IsAvailable { get; init; }
/// <summary>
/// When last checked out (denormalized for reporting).
/// </summary>
public DateTime? LastCheckedOutAt { get; init; }
/// <summary>
/// Count of times checked out (denormalized aggregate).
/// </summary>
public int CheckoutCount { get; init; }
/// <summary>
/// Tenant ID for multi-tenancy filtering.
/// </summary>
public required Guid TenantId { get; init; }
}
/// <summary>
/// Denormalized checkout information for user-facing queries.
/// </summary>
public sealed class CheckoutProjection : IProjection
{
public required Guid Id { get; init; }
public required Guid EquipmentId { get; init; }
public required string EquipmentName { get; init; } // Denormalized
public required Guid UserId { get; init; }
public required string UserName { get; init; } // Denormalized
public required DateOnly CheckoutDate { get; init; }
public DateOnly? ReturnDate { get; init; }
public required Guid TenantId { get; init; }
}
Principles:
- Only properties needed for queries (no domain methods)
- Denormalized data for performance (e.g., EquipmentName, CheckoutCount)
- Immutable (
init-only properties) - Marker interface
IProjection - Always include
TenantIdfor multi-tenancy filtering
IModuleReadModels Interface
The module exposes a single interface for all read model access:
namespace MyApp.Module.Data.ReadModel;
/// <summary>
/// Query interface for MyApp module.
/// Provides safe, queryable access to read models without exposing DbContext.
/// Consumers inject this interface, never the DbContext directly.
/// </summary>
public interface IMyAppReadModels : IModuleReadModels
{
/// <summary>
/// Equipment projections for searching, filtering, reporting.
/// </summary>
IReadModelQueryable<EquipmentProjection> Equipment { get; }
/// <summary>
/// Checkout history and tracking.
/// </summary>
IReadModelQueryable<CheckoutProjection> Checkouts { get; }
}
Read Models Implementation
using PearDrop.Database;
using PearDrop.Multitenancy;
using Microsoft.EntityFrameworkCore;
namespace MyApp.Module.Data.ReadModel;
/// <summary>
/// Provides queryable access to module read models.
/// Handles lazy initialization and multi-tenancy filtering.
/// </summary>
internal sealed class MyAppReadModels : ModuleReadModelsBase<MyAppReadDbContext>, IMyAppReadModels
{
/// <summary>
/// Lazy-loaded Equipment projection queryable.
/// Only creates DbContext scope if accessor is used.
/// </summary>
private readonly Lazy<IReadModelQueryable<EquipmentProjection>> equipment;
/// <summary>
/// Lazy-loaded Checkout projection queryable.
/// </summary>
private readonly Lazy<IReadModelQueryable<CheckoutProjection>> checkouts;
public MyAppReadModels(
IDbContextFactory<MyAppReadDbContext> dbContextFactory,
IMultiTenantContextAccessor tenantContextAccessor,
IReadModelExpressionValidator expressionValidator)
: base(dbContextFactory, tenantContextAccessor, expressionValidator)
{
// Lazy initialization - DbContext not created until accessor is used
this.equipment = new Lazy<IReadModelQueryable<EquipmentProjection>>(() =>
this.CreateQueryable<EquipmentProjection>(new ReadModelMetadata
{
EntityName = nameof(EquipmentProjection),
RequiresTenantIsolation = true, // Auto-filter to current tenant
MaxTakeSize = 1000, // Prevent excessive data retrieval
AllowedIncludes = new HashSet<string>
{
// No includes for view projections (all data already there)
},
}));
this.checkouts = new Lazy<IReadModelQueryable<CheckoutProjection>>(() =>
this.CreateQueryable<CheckoutProjection>(new ReadModelMetadata
{
EntityName = nameof(CheckoutProjection),
RequiresTenantIsolation = true, // Auto-filter to current tenant
MaxTakeSize = 500, // Checkouts typically smaller result sets
}));
}
/// <summary>
/// Get module name for diagnostics/logging.
/// </summary>
public override string ModuleName => "MyApp";
/// <summary>
/// Public property returning IReadModelQueryable interface (not DbSet).
/// Never expose DbContext directly.
/// </summary>
public IReadModelQueryable<EquipmentProjection> Equipment => this.equipment.Value;
/// <summary>
/// Public property returning IReadModelQueryable interface.
/// </summary>
public IReadModelQueryable<CheckoutProjection> Checkouts => this.checkouts.Value;
}
Key Patterns:
Lazy<T>initialization defers DbContext creation until first accessReadModelMetadataspecifies multi-tenancy, size limits, allowed includesRequiresTenantIsolation = trueautomatically filters to current tenant- Properties return
IReadModelQueryable<T>interface, never expose DbSet CreateQueryable<T>()wraps DbSet with authorization checks
Using Read Models in Queries
using MyApp.Module.Data.ReadModel;
internal sealed class GetEquipmentAvailabilityQueryHandler
: IQueryProcessor<GetEquipmentAvailabilityQuery, GetEquipmentAvailabilityResult>
{
private readonly IMyAppReadModels readModels;
public GetEquipmentAvailabilityQueryHandler(IMyAppReadModels readModels)
{
this.readModels = readModels;
}
public async Task<QueryResult<GetEquipmentAvailabilityResult>> Handle(
GetEquipmentAvailabilityQuery request,
CancellationToken cancellationToken)
{
// Simple query leveraging multi-tenancy filtering and denormalization
var availableEquipment = await this.readModels.Equipment
.Where(e => e.IsAvailable)
.OrderBy(e => e.Name)
.Select(e => new EquipmentDto
{
Id = e.Id,
Name = e.Name,
Category = e.Category,
LastCheckedOutAt = e.LastCheckedOutAt, // Denormalized
CheckoutCount = e.CheckoutCount, // Aggregate
})
.ToListAsync(cancellationToken);
return QueryResult<GetEquipmentAvailabilityResult>.Succeeded(
new GetEquipmentAvailabilityResult(availableEquipment));
}
}
Populating Read Models
Option 1: Database Views (Recommended)
Create a SQL view that denormalizes the write model:
-- Create schema for read models
CREATE SCHEMA ReadModel;
GO
-- Create Equipment view from write model
CREATE VIEW ReadModel.vw_Equipment AS
SELECT
e.Id,
e.Name,
e.Category,
CASE
WHEN COUNT(c.Id) FILTER (WHERE c.ReturnDate IS NULL) > 0 THEN 0
ELSE 1
END AS IsAvailable,
MAX(c.CheckoutDate) AS LastCheckedOutAt,
COUNT(c.Id) AS CheckoutCount,
e.TenantId
FROM peardrop_myapp_equipment e
LEFT JOIN peardrop_myapp_checkout c ON e.Id = c.EquipmentId
GROUP BY e.Id, e.Name, e.Category, e.TenantId;
GO
Advantages:
- Automatic synchronization (always current)
- Database-level performance optimization
- Supported by
ToView()mapping
Disadvantages:
- Requires SQL knowledge
- Changes to views require migration files
Option 2: Denormalized Tables with Synchronization
Maintain separate denormalized table updated by domain events:
// When Equipment is created/updated, publish event
public class EquipmentCreatedDomainEvent : IDomainEvent { ... }
// In integration event handler, update read model
public class EquipmentProjectionUpdater : ICapSubscribe
{
private readonly IDbContextFactory<MyAppReadDbContext> readDbContextFactory;
[CapSubscribe("myapp.equipment-created")]
public async Task Handle(
EquipmentCreatedIntegrationEvent @event,
CancellationToken cancellationToken)
{
var readDb = await readDbContextFactory.CreateDbContextAsync(cancellationToken);
try
{
var projection = new EquipmentProjection
{
Id = @event.EquipmentId,
Name = @event.Name,
Category = @event.Category,
IsAvailable = true,
CheckoutCount = 0,
TenantId = @event.TenantId,
};
readDb.Equipment.Add(projection);
await readDb.SaveChangesAsync(cancellationToken);
}
finally
{
readDb.Dispose();
}
}
}
Advantages:
- Explicit control over what's denormalized
- Can add computed columns (e.g., CheckoutCount)
- Easier to test
Disadvantages:
- Must keep in sync via events
- Eventual consistency (temporary staleness)
Registering Read Models in DI
In Module.cs:
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddMyAppModule(this IServiceCollection services)
{
// Register read DbContext factory
services.AddPearDropSqlServerDbContextFactory<MyAppReadDbContext>(
"MyApp.Module",
"__EFMigrationsHistory_MyAppRead",
"MyApp-Read"); // Optional separate read connection string
// Register read models interface
services.AddScoped<IMyAppReadModels, MyAppReadModels>();
// ... other registrations
return services;
}
}
Multi-Tenancy in Read Models
Read models automatically filter to current tenant:
// In IReadModelQueryable creation (framework handles this)
var equipmentFor CurrentTenant = await readModels.Equipment
// Automatically filters: WHERE TenantId = [current tenant]
.Where(e => e.Category == "Electronics")
.ToListAsync();
Important:
- Always include
TenantIdin projections - Set
RequiresTenantIsolation = trueinReadModelMetadata - The framework automatically applies tenant filtering based on
IMultiTenantContextAccessor
Best Practices
✅ Do:
- Create separate DbContext for read models (
PearDropReadDbContextBase<T>) - Make projections immutable (
init-only properties) - Include all data needed for queries (no N+1 problems)
- Add indexes to projections for frequently queried fields
- Use lazy initialization in
IModuleReadModelsimplementation - Denormalize for performance (CheckoutCount, LastCheckedOutAt, etc.)
- Include TenantId explicitly for multi-tenancy
❌ Don't:
- Share DbContext between read and write models
- Add domain logic to projections (no methods beyond properties)
- Forget to implement
IModuleReadModelsinterface - Expose DbContext directly (use IModuleReadModels interface)
- Use
.FirstOrDefaultAsync()without understandingMaxTakeSizelimits - Include computed columns (calculate in C# or view definition)
- Assume eventual consistency is instant (handle UI appropriately)
Next Steps
- Entity Configuration - Configure write-model properties
- Creating Migrations - Generate read DbContext migrations
- CQRS Pattern - Understand query/command separation