EF Core Setup — DbContext, Configurations, and Migrations in Clean Architecture
How to set up EF Core correctly in Clean Architecture: DbContext with IUnitOfWork, IEntityTypeConfiguration per entity, strongly-typed ID converters, owned entities for value objects, and migrations in the right project.
The Setup Philosophy
In Clean Architecture, EF Core lives entirely in Infrastructure. The Domain entities have no EF Core attributes. All mapping happens in IEntityTypeConfiguration<T> classes, which are discovered automatically by ApplyConfigurationsFromAssembly.
Production issue I've seen: A team put
[Required],[MaxLength], and[ForeignKey]attributes directly on their domain entities "to save time." Six months later, they needed to add a second persistence layer (a read store backed by a NoSQL DB). Every entity was coupled to EF Core metadata. The migration to a clean separation took two weeks.
DbContext as IUnitOfWork
// Application/Abstractions/IUnitOfWork.cs
namespace SystemForge.Application.Abstractions;
public interface IUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken ct);
}
// Infrastructure/Persistence/AppDbContext.cs
public sealed class AppDbContext : DbContext, IUnitOfWork
{
private readonly IDomainEventPublisher _publisher;
public AppDbContext(
DbContextOptions<AppDbContext> options,
IDomainEventPublisher publisher)
: base(options)
{
_publisher = publisher;
}
public DbSet<Patient> Patients => Set<Patient>();
public DbSet<Prescription> Prescriptions => Set<Prescription>();
public DbSet<DrugOrder> DrugOrders => Set<DrugOrder>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AssemblyReference).Assembly);
base.OnModelCreating(modelBuilder);
}
public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
{
var events = ChangeTracker
.Entries<Entity>()
.Select(e => e.Entity)
.SelectMany(e => e.PopDomainEvents())
.ToList();
var rows = await base.SaveChangesAsync(ct);
if (events.Count > 0)
await _publisher.PublishAsync(events, ct);
return rows;
}
}Entity Configuration — Patient
// Infrastructure/Persistence/Configurations/PatientConfiguration.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public sealed class PatientConfiguration : IEntityTypeConfiguration<Patient>
{
public void Configure(EntityTypeBuilder<Patient> builder)
{
builder.ToTable("Patients");
builder.HasKey(p => p.Id);
// Strongly-typed ID → stored as Guid
builder.Property(p => p.Id)
.HasConversion(id => id.Value, value => new PatientId(value));
builder.Property(p => p.Name)
.HasMaxLength(200)
.IsRequired();
builder.Property(p => p.MRN)
.HasMaxLength(50)
.IsRequired();
builder.HasIndex(p => p.MRN)
.IsUnique(); // enforced at DB level too, not just application level
builder.Property(p => p.DateOfBirth)
.IsRequired();
// Enum stored as string — readable in the database, survives enum renumbering
builder.Property(p => p.BloodType)
.HasConversion<string>()
.HasMaxLength(15);
// One-to-many: prescriptions owned by this patient
builder.HasMany(p => p.Prescriptions)
.WithOne()
.HasForeignKey("PatientId") // shadow property
.OnDelete(DeleteBehavior.Cascade);
builder.Property(p => p.IsActive)
.HasDefaultValue(true);
}
}Value Object as Owned Entity
// Infrastructure/Persistence/Configurations/PrescriptionConfiguration.cs
public sealed class PrescriptionConfiguration : IEntityTypeConfiguration<Prescription>
{
public void Configure(EntityTypeBuilder<Prescription> builder)
{
builder.ToTable("Prescriptions");
builder.HasKey(p => p.Id);
builder.Property(p => p.Id)
.HasConversion(id => id.Value, value => new PrescriptionId(value));
// Dosage value object → flattened into same row (OwnsOne)
builder.OwnsOne(p => p.Dosage, dosage =>
{
dosage.Property(d => d.Amount)
.HasColumnName("DosageAmount")
.HasPrecision(10, 3)
.IsRequired();
dosage.Property(d => d.Unit)
.HasColumnName("DosageUnit")
.HasMaxLength(10)
.IsRequired();
});
// MedicationCode value object → stored as string
builder.Property(p => p.MedicationCode)
.HasConversion(
code => code.Value,
value => MedicationCode.Create(value).Value)
.HasMaxLength(20)
.IsRequired();
builder.Property(p => p.Frequency)
.HasMaxLength(100)
.IsRequired();
builder.Property(p => p.IsActive)
.HasDefaultValue(true);
builder.Property(p => p.PrescribedAt)
.IsRequired();
}
}Strongly-Typed ID Convention (Global)
Instead of configuring each ID individually, register a convention:
// Infrastructure/Persistence/Conventions/StronglyTypedIdConvention.cs
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
public sealed class StronglyTypedIdConvention : IModelFinalizingConvention
{
public void ProcessModelFinalizing(
IConventionModelBuilder builder,
IConventionContext<IConventionModelBuilder> context)
{
foreach (var entityType in builder.Metadata.GetEntityTypes())
{
var idProperty = entityType.FindPrimaryKey()?.Properties
.FirstOrDefault(p => p.ClrType.IsGenericType == false
&& p.ClrType.Name.EndsWith("Id"));
if (idProperty is null) continue;
var idType = idProperty.ClrType;
if (!idType.IsValueType) continue;
// Create a ValueConverter for the strongly-typed ID to Guid
var converterType = typeof(StronglyTypedIdConverter<>).MakeGenericType(idType);
idProperty.Builder.HasConversion(
(ValueConverter)Activator.CreateInstance(converterType)!);
}
}
}
// Infrastructure/Persistence/AppDbContext.cs — register the convention
protected override void ConfigureConventions(ModelConfigurationBuilder builder)
{
builder.Conventions.Add(_ => new StronglyTypedIdConvention());
}Migrations
Migrations live in Infrastructure and are run against the real database:
# Add a migration (run from the solution root)
dotnet ef migrations add AddPatientBloodType \
--project src/SystemForge.Infrastructure \
--startup-project src/SystemForge.Api
# Apply all pending migrations
dotnet ef database update \
--project src/SystemForge.Infrastructure \
--startup-project src/SystemForge.Api
# Generate SQL script for production (no auto-apply in prod)
dotnet ef migrations script \
--project src/SystemForge.Infrastructure \
--startup-project src/SystemForge.Api \
--output migrations.sql// Program.cs — apply migrations on startup (development only)
if (app.Environment.IsDevelopment())
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
}PRO TIP: Never call
Database.MigrateAsync()in production startup. It holds a database lock during migration and blocks all connections. Usedotnet ef migrations scriptto generate an idempotent SQL script, review it, and apply it through your deployment pipeline before the application starts.
Seeding Reference Data
// Infrastructure/Persistence/Seeders/BloodTypeSeeder.cs
public static class DatabaseSeeder
{
public static async Task SeedAsync(AppDbContext context)
{
if (await context.Patients.AnyAsync())
return; // already seeded
// Seed lookup tables, initial admin user, reference data
var adminUser = AppUser.Create("admin@hospital.org", "System Administrator");
await context.Users.AddAsync(adminUser);
await context.SaveChangesAsync();
}
}Red Flag Answers
Red flag: "My Patient entity has
[Key],[MaxLength(200)], and[Column("patient_id")]attributes."
The domain layer now depends on EF Core. You cannot use the entity without EF Core. It cannot be instantiated in a pure unit test without the EF Core annotation system being loaded.
Green answer: "Domain entities have no attributes. All mapping is in
IEntityTypeConfiguration<T>classes in Infrastructure, discovered automatically byApplyConfigurationsFromAssembly. The entity is a plain C# class."
Key Takeaway
EF Core is an infrastructure detail. Domain entities model the business.
IEntityTypeConfiguration<T>classes bridge the gap — they tell EF Core how to persist entities without the entities knowing they are being persisted. When EF Core releases a breaking change, you update the configuration classes. The entities do not change.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.