Skip to main content

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

AspectWrite ModelRead Model
LocationData/WriteModel/Data/ReadModel/
Base ClassPearDropDbContext<T>PearDropReadDbContextBase<T>
PurposeDomain logic, commandsOptimized queries, reporting
StructureNormalized aggregatesDenormalized projections
ConsistencyStrong (transactions)Eventual (views/sync)
RegistrationAddPearDropSqlServerDbContextFactory<T>AddPearDropSqlServerDbContextFactory<T>
TenancyAuto-filtered by base classManual 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:

  1. Only receives DbContextOptions<T> (no multi-tenancy, domain events, persistence modifiers)
  2. Maps to views with .ToView() or denormalized tables
  3. Uses .HasNoKey() for view projections
  4. No ChangeProcessors, no Ignore(DomainEvents)
  5. 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 TenantId for 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 access
  • ReadModelMetadata specifies multi-tenancy, size limits, allowed includes
  • RequiresTenantIsolation = true automatically 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

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 TenantId in projections
  • Set RequiresTenantIsolation = true in ReadModelMetadata
  • 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 IModuleReadModels implementation
  • 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 IModuleReadModels interface
  • Expose DbContext directly (use IModuleReadModels interface)
  • Use .FirstOrDefaultAsync() without understanding MaxTakeSize limits
  • Include computed columns (calculate in C# or view definition)
  • Assume eventual consistency is instant (handle UI appropriately)

Next Steps