Skip to main content

How to Configure EF Core Entities in Separate Classes with IEntityTypeConfiguration

· 7 min read
Jagdish Kumawat
Founder @ Dewiride

Every EF Core rule you add, a table name, a maximum length, an index, a constraint, goes into OnModelCreating by default, and that method grows until nobody wants to touch it. IEntityTypeConfiguration<T> gives each entity its own small class instead. This guide moves a real configuration into such classes, registers them all with one line, and proves the move changed nothing in the database model.

Prerequisites​

The Starting Point​

Two entities, Category and Product, with all their rules in the DbContext:

src/Dewiride.Inventory.Data/InventoryDbContext.cs (before, OnModelCreating)
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
ArgumentNullException.ThrowIfNull(modelBuilder);

modelBuilder.Entity<Category>().ToTable("categories");
modelBuilder.Entity<Category>().Property(c => c.Name).HasMaxLength(100);
modelBuilder.Entity<Category>().HasIndex(c => c.Name).IsUnique();

modelBuilder.Entity<Product>().ToTable("products", table =>
{
table.HasCheckConstraint("CK_products_UnitPrice", "`UnitPrice` >= 0");
table.HasCheckConstraint("CK_products_QuantityOnHand", "`QuantityOnHand` >= 0");
table.HasCheckConstraint("CK_products_ReorderLevel", "`ReorderLevel` >= 0");
});
modelBuilder.Entity<Product>().Property(p => p.Sku).HasMaxLength(32);
modelBuilder.Entity<Product>().HasIndex(p => p.Sku).IsUnique();
modelBuilder.Entity<Product>().Property(p => p.Name).HasMaxLength(200);
modelBuilder.Entity<Product>().Property(p => p.UnitPrice).HasPrecision(12, 2);
modelBuilder.Entity<Product>()
.HasOne(p => p.Category)
.WithMany(c => c.Products)
.HasForeignKey(p => p.CategoryId)
.OnDelete(DeleteBehavior.Restrict);
}

Two entities, and it's already hard to read. A real app has ten or twenty.

Step 1: One Class per Entity​

EF Core's modeling docs describe the way out: "To reduce the size of the OnModelCreating method all configuration for an entity type can be extracted to a separate class implementing IEntityTypeConfiguration<TEntity>."

Create a Configurations folder in the data project, and a class for Category:

src/Dewiride.Inventory.Data/Configurations/CategoryConfiguration.cs
using Dewiride.Inventory.Data.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace Dewiride.Inventory.Data.Configurations;

/// <summary>
/// How the Category class maps to the categories table in MySQL.
/// </summary>
public sealed class CategoryConfiguration : IEntityTypeConfiguration<Category>
{
public void Configure(EntityTypeBuilder<Category> builder)
{
ArgumentNullException.ThrowIfNull(builder);

builder.ToTable("categories");

builder.HasKey(c => c.Id);

builder.Property(c => c.Name)
.HasMaxLength(100);

builder.HasIndex(c => c.Name)
.IsUnique();
}
}
  • IEntityTypeConfiguration<Category> marks the class as the configuration for Category.
  • Configure(EntityTypeBuilder<Category> builder) receives a builder for that one entity, so every line drops the modelBuilder.Entity<Category>() prefix.
  • builder.HasKey(c => c.Id) states the key that EF Core finds by convention anyway, so the class describes the whole table.

The same for Product:

src/Dewiride.Inventory.Data/Configurations/ProductConfiguration.cs
using Dewiride.Inventory.Data.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace Dewiride.Inventory.Data.Configurations;

/// <summary>
/// How the Product class maps to the products table in MySQL.
/// </summary>
public sealed class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
ArgumentNullException.ThrowIfNull(builder);

builder.ToTable("products", table =>
{
// MySQL checks these on every INSERT and UPDATE, whatever program sends them.
table.HasCheckConstraint("CK_products_UnitPrice", "`UnitPrice` >= 0");
table.HasCheckConstraint("CK_products_QuantityOnHand", "`QuantityOnHand` >= 0");
table.HasCheckConstraint("CK_products_ReorderLevel", "`ReorderLevel` >= 0");
});

builder.HasKey(p => p.Id);

builder.Property(p => p.Sku)
.HasMaxLength(32);

builder.HasIndex(p => p.Sku)
.IsUnique();

builder.Property(p => p.Name)
.HasMaxLength(200);

builder.Property(p => p.UnitPrice)
.HasPrecision(12, 2);

builder.HasOne(p => p.Category)
.WithMany(c => c.Products)
.HasForeignKey(p => p.CategoryId)
.OnDelete(DeleteBehavior.Restrict);
}
}

The ProductConfiguration class in a macOS-style code window, implementing IEntityTypeConfiguration of Product with the products table, three check constraints, maximum lengths, a unique SKU index, the price precision and a restricted foreign key to its category.

Step 2: Register Them All with One Line​

Replace the whole body of OnModelCreating:

src/Dewiride.Inventory.Data/InventoryDbContext.cs (after, OnModelCreating)
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
ArgumentNullException.ThrowIfNull(modelBuilder);

// Picks up every IEntityTypeConfiguration class in this project.
modelBuilder.ApplyConfigurationsFromAssembly(typeof(InventoryDbContext).Assembly);
}

The docs describe it: "It is possible to apply all configuration specified in types implementing IEntityTypeConfiguration in a given assembly." typeof(InventoryDbContext).Assembly is the data project itself, so every configuration class you add there later is picked up without touching the DbContext again.

Step 3: Prove Nothing Changed​

A refactor must not change the model. EF Core can check that, by comparing the model with the last migration's snapshot:

Terminal
dotnet ef migrations has-pending-model-changes --project src/Dewiride.Inventory.Data --startup-project src/Dewiride.Inventory.Api
Output
Build started...
Build succeeded.
No changes have been made to the model since the last migration.

Same model, so no migration is needed. The classes say exactly what OnModelCreating said.

The Mistake That Drops Every Rule​

Without the registration line, the same check gives a different answer:

Output (with ApplyConfigurationsFromAssembly commented out)
Build started...
Build succeeded.
Changes have been made to the model since the last migration. Add a new migration.

A terminal where has-pending-model-changes reports no changes after the refactor, then reports Changes have been made to the model when ApplyConfigurationsFromAssembly is commented out.

Without the line, the classes are never used, and EF Core falls back to its conventions: table names from the DbSets, longtext strings, no indexes, no constraints. The project still builds without any error, which is why the check matters. A migration created at that moment would undo all of the configuration. Run has-pending-model-changes after any refactor of the model.

Things to Know​

  • Order isn't guaranteed. The docs warn: "The order in which the configurations will be applied is undefined, therefore this method should only be used when the order doesn't matter." Keep each class about its own entity, and the order never matters.
  • Constructors. "ApplyConfigurationsFromAssembly only instantiates configuration types that have a parameterless constructor". A configuration class that needs parameters is skipped, with a warning in the log.
  • Conventions stay in the DbContext. Rules for every property of a type, such as all decimals, belong in ConfigureConventions, not in an entity's class.

Common Mistakes​

MistakeWhat happensDo this instead
Forgetting ApplyConfigurationsFromAssemblyEvery rule silently disappearsKeep the line, and check with has-pending-model-changes
Passing another project's assemblyThe configurations in the data project aren't foundUse a type from the project that holds them
Configuring the same entity in two classesThe order is undefined, so either may winOne class per entity
Leaving old rules in OnModelCreating tooTwo places to change, and they driftMove everything, then delete the old lines

Troubleshooting​

SymptomLikely causeFix
has-pending-model-changes reports changes after the refactorA rule wasn't moved, was changed while moving, or the registration line is missingCompare the classes with the old method
A configuration class is ignoredIt has a constructor with parametersGive it a parameterless constructor

FAQ​

Can I call each configuration explicitly instead?​

Yes: modelBuilder.ApplyConfiguration(new CategoryConfiguration()); for each class. The docs show it as "just invoke the Configure method from OnModelCreating". It's explicit, but every new class needs a new line.

Does this change any SQL?​

No. It's the same model, built from different classes, which has-pending-model-changes confirms.

Conclusion​

Move each entity's rules into a class that implements IEntityTypeConfiguration<T>, put the classes in a Configurations folder, and replace OnModelCreating's body with modelBuilder.ApplyConfigurationsFromAssembly(typeof(InventoryDbContext).Assembly). Then run dotnet ef migrations has-pending-model-changes: "No changes" proves the refactor kept the model, and its absence catches the one missing line that would silently drop every rule.

Additional Resources​

Stay Updated

Subscribe to our newsletter for the latest tutorials, tech insights, and developer news.

By subscribing, you agree to our privacy policy. Unsubscribe at any time.