Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20265 min read
Clean Architecture.NETEF CoreDbContextMigrationsConfiguration
Share:𝕏

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

C#
// 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

C#
// 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

C#
// 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:

C#
// 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:

Bash
# 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
C#
// 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. Use dotnet ef migrations script to generate an idempotent SQL script, review it, and apply it through your deployment pipeline before the application starts.


Seeding Reference Data

C#
// 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 by ApplyConfigurationsFromAssembly. 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.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.