Accepted
The application requires a robust, type-safe Object-Relational Mapping (ORM) solution to persist domain aggregates and value objects to a relational database. The ORM must support:
- Type safety: Compile-time checked queries and mappings to prevent runtime errors
- Migration support: Evolve database schema over time without manual SQL scripting
- Modular isolation: Each module manages its own database context and migrations independently
- Performance: Efficient query translation, connection pooling, change tracking
- Value object mapping: Convert domain value objects (e.g.,
CustomerId,EmailAddress) to database primitives - Complex mappings: Support owned entities, inheritance, enumerations, sequences
- Developer productivity: Minimize boilerplate for CRUD operations and schema management
- Maintainability: Keep schema definition close to domain model for easier refactoring
- Testability: Support in-memory provider for fast integration tests
- Schema versioning: Track database changes explicitly via version-controlled migration files
- Code-first vs Database-first: Should schema be generated from code or code generated from schema?
- Migration automation: Should migrations apply automatically in development vs production?
- DbContext scope: Should there be one DbContext per module or shared across modules?
- Convention vs Configuration: Balance between magic conventions and explicit configuration
- ADR-0001: Clean/Onion Architecture dictates Infrastructure layer owns EF Core (not Domain)
- ADR-0003: Modular Monolith requires each module to have isolated DbContext
- ADR-0004: Repository pattern abstracts EF Core behind
IRepository<T>interface - ADR-0012: Domain layer defines entities; Infrastructure layer provides EF Core mappings
Use Entity Framework Core (EF Core) as the primary ORM with code-first migrations and one DbContext per module.
Each module defines its own DbContext inheriting from ModuleDbContextBase:
// src/Modules/CoreModule/CoreModule.Infrastructure/EntityFramework/CoreModuleDbContext.cs
public class CoreModuleDbContext(DbContextOptions<CoreModuleDbContext> options)
: ModuleDbContextBase(options), IOutboxDomainEventContext
{
public DbSet<Customer> Customers { get; set; }
public DbSet<OutboxDomainEvent> OutboxDomainEvents { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasSequence<int>(CodeModuleConstants.CustomerNumberSequenceName)
.StartsAt(100000);
base.OnModelCreating(modelBuilder); // applies configurations from assembly
}
}Separate configuration classes define mappings using Fluent API:
// src/Modules/CoreModule/CoreModule.Infrastructure/EntityFramework/Configurations/CustomerTypeConfiguration.cs
public class CustomerTypeConfiguration : IEntityTypeConfiguration<Customer>
{
public void Configure(EntityTypeBuilder<Customer> builder)
{
builder.ToTable("Customers")
.HasKey(x => x.Id).IsClustered(false);
// Value object conversions
builder.Property(e => e.Id)
.HasConversion(
id => id.Value, // to database
value => CustomerId.Create(value)); // from database
builder.Property(d => d.Number)
.HasConversion(
number => number.Value,
value => CustomerNumber.Create(value).Value)
.HasMaxLength(256);
// Owned entity (separate table)
builder.OwnsMany(c => c.Addresses, ab =>
{
ab.ToTable("CustomersAddresses");
ab.WithOwner().HasForeignKey("CustomerId");
ab.HasKey(a => a.Id);
// ... property configurations
});
// Audit properties (created/updated dates)
builder.OwnsOneAuditState();
}
}Factory enables dotnet ef CLI tools to create DbContext during migration generation:
// src/Modules/CoreModule/CoreModule.Infrastructure/EntityFramework/CoreModuleDbContextFactory.cs
public class CoreModuleDbContextFactory : SqlServerModuleDbContextFactory<CoreModuleDbContext>
{
public CoreModuleDbContextFactory()
: base(
options: (builder, connectionString) =>
builder.UseSqlServer(
connectionString,
sqlOptions => sqlOptions.MigrationsAssembly(
typeof(CoreModuleDbContext).Assembly.GetName().Name)))
{
}
}Register DbContext and migration services in module startup:
// src/Modules/CoreModule/CoreModule.Presentation/CoreModuleModule.cs (lines 48-61)
services.AddSqlServerDbContext<CoreModuleDbContext>(o => o
.UseConnectionString(moduleConfiguration.ConnectionStrings["Default"])
.UseLogger(true, true))
.WithSequenceNumberGenerator()
.WithDatabaseMigratorService(o => o
.Enabled(environment.IsLocalDevelopment() || environment.IsContainerized()))
.WithOutboxDomainEventService(o => o
.ProcessingInterval("00:00:30")
.ProcessingModeImmediate()
.StartupDelay("00:00:15")
.PurgeOnStartup());Development Environment (Automatic):
# Add new migration
dotnet ef migrations add AddCustomerPhone --project CoreModule.Infrastructure
# Migrations auto-apply on startup via DatabaseMigratorService
# (enabled only in local/containerized environments)Production Environment (Manual):
# Generate SQL script for review
dotnet ef migrations script --idempotent --output migration.sql
# Apply via deployment pipeline (not on app startup)EF Core creates strongly-typed migration classes:
// Migrations/20260112224142_AddCustomersAddresses.cs (excerpt)
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CustomersAddresses",
schema: "core",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Name = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
Line1 = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
// ...
CustomerId = table.Column<Guid>(type: "uniqueidentifier", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CustomersAddresses", x => x.Id);
table.ForeignKey("FK_CustomersAddresses_Customers_CustomerId",
x => x.CustomerId, principalTable: "Customers", onDelete: ReferentialAction.Cascade);
});
}- Value Object Conversions: Use
HasConversion<TValueObject>to map to primitives - Owned Entities: Use
OwnsOne/OwnsManyfor value objects that need separate tables - Enumeration Support: Use
EnumerationConverter<T>for Enumeration pattern - Audit State: Use
.OwnsOneAuditState()extension to add CreatedDate/UpdatedDate - Sequences: Configure via
modelBuilder.HasSequence<T>()for sequential IDs - Concurrency Tokens: Use
IsConcurrencyToken()for optimistic concurrency
- Mature .NET Integration: First-class support for .NET types, LINQ, async/await, dependency injection
- Strong Typing: Compile-time query validation prevents SQL injection and typos
- Change Tracking: Automatic detection of modified entities simplifies update logic
- Migration System: Version-controlled schema evolution with rollback support
- Flexible Providers: SQL Server today; swap to PostgreSQL/SQLite without application code changes
- Testability: In-memory provider enables fast integration tests without database
- Community & Tooling: Extensive documentation, Visual Studio integration, CLI tools
- Domain-Driven Design Alignment: Domain entities are source of truth; database is persistence detail
- Refactoring Safety: Rename properties in C# → migration reflects change automatically
- Version Control: Migration files track schema changes alongside code changes
- Testability: In-memory provider works seamlessly with code-first models
- Cross-Platform: No dependency on database-specific designer tools
- Bounded Context Isolation: Each module owns its schema; no cross-module table dependencies
- Independent Deployability: Modules can evolve database schemas independently
- Migration Independence: Adding module doesn't require migrating existing modules
- Schema Namespacing: Each module uses its own database schema (e.g.,
core.Customers)
- Developer Productivity: Local changes auto-apply; no manual migration steps
- Production Safety: Manual review catches breaking changes before deployment
- Rollback Control: Production migrations can be rolled back via generated SQL scripts
- Productivity: Developers write C# classes, not SQL DDL scripts
- Type Safety: Compile-time errors catch schema mismatches before runtime
- Maintainability: Schema changes tracked in source control via migration files
- Testability: In-memory provider enables fast integration tests (no SQL Server required)
- Flexibility: Switch database providers (SQL Server → PostgreSQL) without code changes
- Consistency: Fluent API ensures all modules follow same mapping conventions
- Tooling: Visual Studio and CLI tools simplify migration generation and review
- Learning Curve: Developers must learn EF Core Fluent API and migration system
- Magic Conventions: EF Core applies conventions (e.g., cascading deletes) that may surprise developers
- Performance Overhead: Change tracking and query translation add CPU/memory cost vs raw SQL
- Migration Conflicts: Multiple developers creating migrations simultaneously can cause merge conflicts
- Circular Dependencies: If not careful, modules can reference each other's DbContexts (violates isolation)
- N+1 Query Risk: Lazy loading disabled by default; developers must use
.Include()explicitly
- Vendor Lock-In (Mitigated): Tied to EF Core API, but can swap database providers
- Generated SQL Control: EF Core generates SQL; developers lose fine-grained optimization control (can use raw SQL when needed)
- Migration Size: Many migrations over time can slow down database creation (can be squashed)
Description: Lightweight ORM providing simple mapping from SQL results to C# objects.
Pros:
- Performance: Minimal overhead; very fast queries
- Control: Write raw SQL for complex queries
- Simplicity: No change tracking or magic behavior
Cons:
- No migrations: Must write SQL DDL scripts manually
- No type safety: Queries are strings; typos caught at runtime
- Boilerplate: Manual mapping code for each query
- No change tracking: Must manually detect entity modifications
Rejected Because: Requires manual SQL migrations and lacks type safety, increasing maintenance burden.
Description: Mature ORM with extensive mapping capabilities and HQL query language.
Pros:
- Feature-rich: Second-level cache, lazy loading, complex mappings
- Mature: Battle-tested in enterprise applications
Cons:
- XML configuration: Mapping via XML files (less refactor-friendly)
- Learning curve: More complex API than EF Core
- Community: Smaller .NET community compared to EF Core
- Tooling: Fewer Visual Studio integrations
Rejected Because: EF Core provides equivalent features with better .NET ecosystem integration and LINQ support.
Description: Generate C# classes from existing database schema.
Pros:
- DBA Control: Database experts design schema; developers consume it
- Existing Schema: Works well when integrating with legacy databases
Cons:
- Code regeneration: Schema changes require regenerating C# classes
- Domain alignment: Generated classes may not match domain model structure
- Value objects: Hard to map value objects (e.g.,
EmailAddress) from scalar columns - Version control: Difficult to track schema changes alongside code changes
Rejected Because: Conflicts with Domain-Driven Design principle that domain model is source of truth.
Description: Use SqlConnection, SqlCommand, and DataReader directly.
Pros:
- Full control: Write exact SQL for optimal performance
- No dependencies: No ORM framework required
Cons:
- Massive boilerplate: Manual connection management, parameter binding, result mapping
- No migrations: Must write SQL DDL scripts manually
- SQL injection risk: Prone to security vulnerabilities if not careful
- Maintenance burden: Huge amount of repetitive code
Rejected Because: Dramatically reduces developer productivity with no significant benefit for typical CRUD operations.
- ADR-0001: Clean/Onion Architecture - EF Core configurations live in Infrastructure layer
- ADR-0003: Modular Monolith Architecture - Each module has isolated DbContext
- ADR-0004: Repository Decorator Behaviors - Repository abstracts EF Core from Application layer
- ADR-0006: Outbox Pattern - Outbox events stored in same DbContext as aggregates
- ADR-0008: Typed Entity IDs - Value object conversions enable typed IDs in database
- ADR-0012: Domain Logic in Domain Layer - Domain defines entities; Infrastructure provides mappings
- Entity Framework Core Documentation
- EF Core Migrations Overview
- bITdevKit DbContext Extensions
- Project Documentation:
README.md(sections: Database Setup, EF Core Migrations) - Module Documentation:
src/Modules/CoreModule/CoreModule-README.md(Infrastructure section)
src/Modules/CoreModule/CoreModule.Infrastructure/
├── EntityFramework/
│ ├── CoreModuleDbContext.cs # DbContext definition
│ ├── CoreModuleDbContextFactory.cs # Design-time factory for migrations
│ ├── Configurations/
│ │ └── CustomerTypeConfiguration.cs # Fluent API mappings
│ └── Migrations/
│ ├── 20260109201723_Initial.cs # Initial schema creation
│ ├── 20260112224142_AddCustomersAddresses.cs# Add Addresses table
│ └── CoreModuleDbContextModelSnapshot.cs # Current schema snapshot
Add Migration:
pwsh -NoProfile -File .\bdk.ps1 -Task ef-migration-add -MigrationName AddCustomerPhoneApply Migrations:
pwsh -NoProfile -File .\bdk.ps1 -Task ef-applyUpdate Database (Manual):
dotnet ef database update --project src/Modules/CoreModule/CoreModule.InfrastructureGenerate SQL Script:
dotnet ef migrations script --idempotent --output migration.sql --project CoreModule.Infrastructure// Value object in domain
public class EmailAddress : ValueObject
{
public string Value { get; }
private EmailAddress(string value) => Value = value;
public static Result<EmailAddress> Create(string value) { /* validation */ }
}
// EF Core configuration
builder.Property(x => x.Email)
.HasConversion(
email => email.Value, // C# → Database
value => EmailAddress.Create(value).Value) // Database → C#
.HasMaxLength(256);| Environment | Auto-Apply | Approval Process | Rollback Method |
|---|---|---|---|
| Local Development | V Yes (on startup) | None | Delete database, rerun |
| Containerized/Docker | V Yes (on startup) | None | Recreate container |
| CI/CD Pipeline | X No | PR review required | Git revert migration file |
| Staging | X No | Manual review | Run Down() migration |
| Production | X No | DBA approval required | Idempotent rollback script |
- Unit Tests: Use in-memory provider (
UseInMemoryDatabase()) - Integration Tests: Use SQL Server test container or LocalDB
- Repository Tests: Mock
IRepository<T>interface (don't test EF Core itself)
- Change Tracking: Disabled for read-only queries (
.AsNoTracking()) - Projections: Use
.Select()to load only needed columns - Eager Loading: Use
.Include()to avoid N+1 queries - Compiled Queries: Cache frequently-used queries via
EF.CompileQuery() - Connection Pooling: Enabled by default; configure via connection string
- Each module schema prefixed with module name (e.g.,
core.Customers) - Shared tables (e.g.,
__EFMigrationsHistory) live in default schema - Outbox tables live in module schema alongside aggregates
- Multi-Tenancy: Add tenant ID to queries via global query filters
- Read Models: Consider separate read-optimized DbContext for queries (CQRS)
- Schema Squashing: Periodically squash old migrations to reduce migration count
- Database Sharding: If scale requires, partition DbContext by aggregate root