Skip to main content

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:

ParameterPurpose
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
IChangeProcessorAccessorDiscovers change processors for domain events
IEnumerable<IPersistenceModifier>Modifies entities before SaveChanges (audit, timestamps)
bool isReadOnlyPrevents 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 = true if you create DbContext instances for queries only
  • Use DbContextFactory for 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 new keyword in handlers (inject via factory)

Next Steps