Write Model & DbContext
The write model in PearDrop is where domain aggregates are persisted to the database. The write DbContext (PearDropDbContext<T>) orchestrates persistence, integrates with the multi-tenancy system, and coordinates change processors.
Creating a Write DbContext
A write DbContext inherits from PearDropDbContext<T> and configures domain aggregates:
using Finbuckle.MultiTenant.Abstractions;
using Microsoft.EntityFrameworkCore;
using PearDrop.Database;
using PearDrop.Database.Contracts;
using PearDrop.Domain.Contracts;
using PearDrop.Multitenancy;
namespace MyApp.Module.Data.WriteModel;
public class MyModuleWriteDbContext : PearDropDbContext<MyModuleWriteDbContext>
{
public MyModuleWriteDbContext(
DbContextOptions<MyModuleWriteDbContext> options,
IMultiTenantContextAccessor<PearDropTenantInfo> multiTenantContextAccessor,
IEnumerable<IEntityFilterProvider> filterProviders,
IChangeProcessorAccessor changeProcessorAccessor,
IEnumerable<IPersistenceModifier> persistenceModifiers,
bool isReadOnly = false)
: base(
"mymodule", // Module identifier (lowercase)
options,
multiTenantContextAccessor,
filterProviders,
persistenceModifiers,
isReadOnly)
{
// Discover change processors for this context
this.ChangeProcessors = changeProcessorAccessor.Discover<MyModuleWriteDbContext>();
}
// Change processors coordinate domain events and business logic
protected override IReadOnlyList<IChangeProcessor<MyModuleWriteDbContext>> ChangeProcessors { get; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Set default schema (optional, defaults to "dbo")
modelBuilder.HasDefaultSchema("dbo");
// Apply entity configurations for each aggregate
modelBuilder.ApplyConfiguration(new EquipmentMutableTypeConfiguration());
modelBuilder.ApplyConfiguration(new CheckoutMutableTypeConfiguration());
modelBuilder.ApplyConfiguration(new CategoryMutableTypeConfiguration());
}
}
Constructor Dependencies
The write DbContext constructor receives:
| Parameter | Purpose |
|---|---|
DbContextOptions<T> | EF Core configuration (connection string, SQL Server, etc.) |
IMultiTenantContextAccessor<PearDropTenantInfo> | Access current tenant for isolation |
IEnumerable<IEntityFilterProvider> | Query filters for tenant isolation, soft deletes |
IChangeProcessorAccessor | Discovers change processors for domain events |
IEnumerable<IPersistenceModifier> | Modifies entities before SaveChanges (audit, timestamps) |
bool isReadOnly | Prevents SaveChanges when true (defaults to false) |
base("mymodule", ...) Parameter
The first parameter to base() is the module identifier (lowercase). This:
- Identifies the module in audit logs
- Prevents naming conflicts across modules
- Appears in migration history tables
Naming convention: Use lowercase module name (e.g., "auth", "files", "mymodule")
DbContext Factory
PearDrop uses a factory pattern for DI and scoped lifecycle:
using Microsoft.EntityFrameworkCore;
using PearDrop.Database;
namespace MyApp.Module.Data.WriteModel;
public class MyModuleWriteDbContextFactory
: DbContextFactory<MyModuleWriteDbContext>
{
public MyModuleWriteDbContextFactory(IServiceProvider serviceProvider)
: base(serviceProvider)
{
}
}
Benefits:
- Scoped instances per request/service scope
- Dependency injection fully integrated
- Testable with mock dependencies
- Safe multi-tenancy isolation (each scope has its own tenant context)
Registering in Module.cs
Register the write DbContext in your module's Module.cs:
using Microsoft.EntityFrameworkCore;
using MyApp.Module.Data.WriteModel;
using MyApp.Module.Domain.Equipment.Aggregate;
public static class Module
{
public static IServiceCollection AddMyModule(
this IServiceCollection services,
IConfiguration configuration)
{
// Register write DbContext factory
services.AddPearDropSqlServerDbContextFactory<MyModuleWriteDbContext>(
"MyApp.Module", // Assembly name (for migration history)
"__EFMigrationsHistory_MyModule", // Migration table name
"PearDrop"); // Connection string name
// Register repositories for aggregates
services.AddScoped<IRepositoryFactory<Equipment>,
RepositoryFactory<Equipment, MyModuleWriteDbContext>>();
return services;
}
// Helper method to apply migrations during startup
public static async Task<IServiceProvider> ApplyMyModuleMigrationsAsync(
this IServiceProvider serviceProvider)
{
using var scope = serviceProvider.CreateScope();
// Apply write model migrations
var dbContextFactory = scope.ServiceProvider
.GetRequiredService<IDbContextFactory<MyModuleWriteDbContext>>();
var dbContext = await dbContextFactory.CreateDbContextAsync();
await dbContext.Database.MigrateAsync();
return serviceProvider;
}
}
OnModelCreating Configuration
The OnModelCreating() method applies entity configurations:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Set default schema for all entities
modelBuilder.HasDefaultSchema("dbo");
// Apply entity configurations
// Each configuration maps domain aggregates to tables
modelBuilder.ApplyConfiguration(new EquipmentMutableTypeConfiguration());
modelBuilder.ApplyConfiguration(new CheckoutMutableTypeConfiguration());
// Ignore domain events (in-memory only)
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (typeof(Entity).IsAssignableFrom(entityType.ClrType))
{
entityType.Ignore(nameof(Entity.DomainEvents));
}
}
}
Using Write DbContext in Command Handlers
Command handlers work with the write DbContext through the repository pattern:
public class CreateEquipmentCommandHandler
: AuditableCommandHandler<CreateEquipmentCommand, EquipmentCreatedResult>
{
public CreateEquipmentCommandHandler(
IEnumerable<IValidator<CreateEquipmentCommand>> validators,
ILogger<CreateEquipmentCommandHandler> logger,
ICommandStore commandStore,
IRepositoryFactory<Equipment> repositoryFactory)
: base(validators, logger, commandStore, repositoryFactory)
{
}
protected override async Task<CommandResult<EquipmentCreatedResult>>
HandleInternalWithRepository(
CreateEquipmentCommand request,
CancellationToken cancellationToken)
{
// Repository manages DbContext lifecycle
var equipment = Equipment.Create(
request.Name,
request.Category,
timeProvider.GetUtcNow());
// Add to repository (DbContext tracks changes)
this.Repository.Add(equipment);
// SaveChanges triggers change processors (domain events, etc.)
var result = await this.Repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
if (result.IsSuccess)
{
return CommandResult<EquipmentCreatedResult>.Succeeded(
new EquipmentCreatedResult(equipment.Id));
}
return CommandResult<EquipmentCreatedResult>.Failed(result.Error!);
}
}
Change Processors
Change processors execute before SaveChanges() to coordinate domain logic:
// Discovered automatically via IChangeProcessorAccessor
protected override IReadOnlyList<IChangeProcessor<MyModuleWriteDbContext>> ChangeProcessors { get; }
// Example processor: raises domain events
public class DomainEventProcessor : IChangeProcessor<MyModuleWriteDbContext>
{
public async Task ProcessAsync(MyModuleWriteDbContext context, CancellationToken ct)
{
var changes = context.ChangeTracker
.Entries<Entity>()
.Where(e => e.Entity.DomainEvents.Any());
foreach (var entry in changes)
{
// Dispatch domain events to MediatR
foreach (var evt in entry.Entity.DomainEvents)
{
await mediator.Publish(evt, ct);
}
}
}
}
Isolation & Multi-Tenancy
The write DbContext automatically handles tenant isolation:
// Entity configuration includes TenantId
builder.Property(e => e.TenantId).HasColumnName("TenantId");
// DbContext base class applies query filters automatically
// When command handler loads aggregates, only current tenant's data returned
var equipment = await repository.FindOne(
new ByIdSpecification<Equipment>(id),
cancellationToken);
// Returns only if equipment.TenantId == CurrentTenanId
Transactions & SaveChanges
Multiple aggregates can be saved in a single transaction:
// Handler works with multiple repositories in same context
var equipmentRepo = repositoryFactory.Create<Equipment>();
var categoryRepo = repositoryFactory.Create<Category>();
var equipment = Equipment.Create(...);
var category = Category.Create(...);
equipmentRepo.Add(equipment);
categoryRepo.Add(category);
// One SaveChanges = one transaction (ACID guarantees)
await equipmentRepo.UnitOfWork.SaveEntitiesAsync(cancellationToken);
Best Practices
✅ Do:
- Keep DbContext focused on one or two aggregates (or bounded context)
- Use entity configurations for all mapping logic
- Register in Module.cs with appropriate connection string name
- Mark
isReadOnly = trueif you create DbContext instances for queries only - Use
DbContextFactoryfor proper DI and lifecycle management
❌ Don't:
- Configure entities directly in
OnModelCreating()(use separate configuration classes) - Expose DbContext directly (always use repositories)
- Bypass entity configurations (always use
ApplyConfiguration()) - Create DbContext instances with
newkeyword in handlers (inject via factory)
Next Steps
- Entity Configuration - Detailed mapping patterns
- Read Models - Read-side DbContext for queries
- Creating Migrations - Schema version control