You’ve got your EF Core CRUD operations working—entities saved, queries run, and migrations apply without errors. But as your domain grows more complex, you start hitting walls: How do I configure a many-to-many relationship that needs extra data? Where should I put all these configuration rules? Why does my decimal column keep truncating values? And why do some developers swear by Fluent API while others stick with Data Annotations?
If you’ve ever found yourself fighting with EF Core’s conventions or staring at a 500-line OnModelCreating method wondering how it got this bad, this article is for you.
We’ll go deep into entity configuration best practices—the patterns that keep your codebase clean, your database schema correct, and your future self grateful. By the end, you’ll know exactly when to use Fluent API vs Data Annotations, how to organize configurations at scale, and how to handle the complex relationship scenarios that tutorials usually skip.
No repository for this one—just code snippets you can adapt to your own projects. Let’s get into it.
Why Entity Configuration Matters
Your C# classes represent business concepts—a Product has a name, price, and belongs to a category. The database has different concerns: storage efficiency, indexing for fast queries, constraints to maintain data integrity, and specific data types for each column.
EF Core’s conventions bridge this gap automatically. It figures out that ProductId is likely a primary key, that Category navigation property implies a foreign key, and that string properties map to nvarchar(max). These conventions handle about 80% of cases.
But that remaining 20%? That’s where configuration comes in. Maybe you need:
- Specific decimal precision for money fields (default precision might truncate
$199.999to$199.99) - Indexes on columns you frequently filter by
- Cascade delete behavior that doesn’t accidentally wipe out your entire category tree
- Composite keys for join tables with additional data
Three Ways to Configure Entities
EF Core offers three approaches, each with different trade-offs:
| Approach | Where It Lives | Best For |
|---|---|---|
| Conventions | Automatic | Default behavior, simple scenarios |
| Data Annotations | Attributes on entity properties | Quick constraints, validation integration |
| Fluent API | Code in OnModelCreating or config classes | Complex relationships, full control |
Conventions are implicit—EF Core applies them automatically. Data Annotations are attributes you place directly on your entity properties. Fluent API is code you write to explicitly configure the model.
The key insight: Fluent API has the highest precedence. If you configure the same property with both annotations and Fluent API, Fluent API wins. Always.
Data Annotations vs Fluent API — When to Use Each
Let’s be practical about this. Both approaches work, and choosing between them isn’t about which is “better”—it’s about which fits your situation.
Data Annotations — The Quick Win
Data Annotations are attributes you place directly on properties. They’re visible, self-documenting, and work with ASP.NET Core’s model validation.
public class Product{ public Guid Id { get; set; }
[Required] [MaxLength(200)] public string Name { get; set; } = string.Empty;
[Column(TypeName = "decimal(18,2)")] public decimal Price { get; set; }
[Required] public Guid CategoryId { get; set; }
public Category? Category { get; set; }}The benefit here is visibility. Anyone reading this class immediately understands: Name is required and maxes out at 200 characters, Price uses specific decimal precision, and CategoryId is required.
Data Annotations also integrate with validation. If you use ASP.NET Core’s model validation or FluentValidation, these attributes can serve double duty—configuring EF Core AND validating incoming requests.
Fluent API — The Power Tool
Fluent API moves configuration out of your entities and into dedicated code. Here’s the same configuration:
public void Configure(EntityTypeBuilder<Product> builder){ builder.Property(p => p.Name) .IsRequired() .HasMaxLength(200);
builder.Property(p => p.Price) .HasPrecision(18, 2);
builder.HasOne(p => p.Category) .WithMany(c => c.Products) .HasForeignKey(p => p.CategoryId) .OnDelete(DeleteBehavior.Restrict);}Fluent API is required for certain scenarios that annotations simply can’t express:
- Composite primary keys
- Many-to-many relationships with payload data
- Owned entities and value objects
- Shadow properties
- Global query filters
It also keeps your domain models clean of persistence concerns. If you’re following Domain-Driven Design or want entities that could theoretically work with a different ORM, Fluent API keeps EF Core specifics out of your domain layer.
The Trade-off Matrix
Here’s my recommendation for common scenarios:
| Scenario | Recommended Approach | Why |
|---|---|---|
Simple constraints (Required, MaxLength) | Either | Annotations are visible; Fluent API keeps models clean |
| Complex relationships | Fluent API | Annotations can’t express all relationship types |
| Composite keys | Fluent API | No annotation support |
| Decimal precision | Fluent API | HasPrecision(18, 2) is cleaner than [Column(TypeName = "...")] |
| DDD domain models | Fluent API | Keep persistence out of the domain |
| Quick prototyping | Data Annotations | Less boilerplate to write |
Mixing Both Approaches
Can you use both? Yes. Should you? With caution.
A common pattern is using Data Annotations for simple validation rules (that also serve API validation) and Fluent API for relationships and persistence-specific settings. This works, but remember the precedence rule: Fluent API always overrides Data Annotations.
// Entity with annotation[MaxLength(100)]public string Name { get; set; }
// Fluent API overrides itbuilder.Property(p => p.Name).HasMaxLength(200); // This wins - 200, not 100The danger is confusion. Six months from now, someone sees [MaxLength(100)] on the property and assumes that’s the limit. But it’s actually 200 because of Fluent API. Pick one approach for each concern and stick to it.
The IEntityTypeConfiguration Pattern
Here’s a pattern I’ve seen in countless real-world projects: a DbContext with an OnModelCreating method that spans 400+ lines, configuring every entity in one massive method. It starts small, but every new feature adds more configuration until it becomes unmaintainable.
// The anti-pattern - don't do thisprotected override void OnModelCreating(ModelBuilder modelBuilder){ // 50 lines for Product modelBuilder.Entity<Product>(builder => { builder.HasKey(p => p.Id); builder.Property(p => p.Name).IsRequired().HasMaxLength(200); // ... 45 more lines });
// 50 lines for Category modelBuilder.Entity<Category>(builder => { // ... another 50 lines });
// 50 lines for Order // 50 lines for OrderItem // 50 lines for Customer // ... imagine 15 more entities}This violates the Single Responsibility Principle. It’s hard to find configuration for a specific entity, merge conflicts are constant, and testing is a nightmare.
The Solution: IEntityTypeConfiguration<T>
EF Core provides IEntityTypeConfiguration<T>, an interface for separating configuration into dedicated classes:
public class ProductConfiguration : IEntityTypeConfiguration<Product>{ public void Configure(EntityTypeBuilder<Product> builder) { builder.ToTable("Products", "catalog");
builder.HasKey(p => p.Id);
builder.Property(p => p.Name) .IsRequired() .HasMaxLength(200);
builder.Property(p => p.Price) .HasPrecision(18, 2);
builder.Property(p => p.Sku) .IsRequired() .HasMaxLength(50);
builder.HasIndex(p => p.Sku) .IsUnique();
builder.HasIndex(p => p.Name);
builder.HasOne(p => p.Category) .WithMany(c => c.Products) .HasForeignKey(p => p.CategoryId) .OnDelete(DeleteBehavior.Restrict); }}Each entity gets its own configuration file. The configuration is focused, testable, and easy to find.
Automatic Registration with ApplyConfigurationsFromAssembly
You don’t need to manually register each configuration class. EF Core 2.2+ provides a one-liner that scans an assembly for all IEntityTypeConfiguration<T> implementations:
public class AppDbContext : DbContext{ public DbSet<Product> Products => Set<Product>(); public DbSet<Category> Categories => Set<Category>(); public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); base.OnModelCreating(modelBuilder); }}That’s it. Add a new entity and its configuration class—no DbContext changes needed. The assembly scan finds it automatically.
Recommended Folder Structure
For projects with more than a handful of entities, organize configurations in a dedicated folder:
📁 Persistence/ 📁 Configurations/ 📄 ProductConfiguration.cs 📄 CategoryConfiguration.cs 📄 OrderConfiguration.cs 📄 OrderItemConfiguration.cs 📄 CustomerConfiguration.cs 📄 AppDbContext.csWhy this structure?
- Discoverability: Need to check Product’s configuration? It’s in
ProductConfiguration.cs - Merge conflicts: Each entity has its own file—parallel development rarely conflicts
- IDE navigation: “Go to definition” and search work naturally
For larger projects with multiple bounded contexts, you might add another level:
📁 Persistence/ 📁 Configurations/ 📁 Catalog/ 📄 ProductConfiguration.cs 📄 CategoryConfiguration.cs 📁 Sales/ 📄 OrderConfiguration.cs 📄 OrderItemConfiguration.csConfiguring Complex Relationships
Basic one-to-many relationships work automatically with EF Core conventions. But real applications have messier requirements. Let’s tackle the scenarios that trip people up.
Many-to-Many with Payload
The classic many-to-many example is Student ↔ Course. EF Core 5+ handles simple many-to-many automatically. But what if you need additional data on the relationship itself?
Consider orders and products. An order contains products, but you also need:
- Quantity — How many of this product?
- UnitPrice — The price at the time of order (not the current product price)
- Discount — Any discount applied to this line item
This requires a join entity—a class that represents the relationship and holds the payload data:
public class OrderItem{ public Guid OrderId { get; private set; } public Order Order { get; private set; } = null!;
public Guid ProductId { get; private set; } public Product Product { get; private set; } = null!;
public int Quantity { get; private set; } public decimal UnitPrice { get; private set; } public decimal Discount { get; private set; }
public decimal LineTotal => (UnitPrice * Quantity) - Discount;}Now configure it:
public class OrderItemConfiguration : IEntityTypeConfiguration<OrderItem>{ public void Configure(EntityTypeBuilder<OrderItem> builder) { builder.ToTable("OrderItems");
// Composite primary key builder.HasKey(oi => new { oi.OrderId, oi.ProductId });
builder.Property(oi => oi.Quantity) .IsRequired();
builder.Property(oi => oi.UnitPrice) .HasPrecision(18, 2);
builder.Property(oi => oi.Discount) .HasPrecision(18, 2);
// Relationship to Order - cascade delete (delete order = delete items) builder.HasOne(oi => oi.Order) .WithMany(o => o.Items) .HasForeignKey(oi => oi.OrderId) .OnDelete(DeleteBehavior.Cascade);
// Relationship to Product - restrict (can't delete product with orders) builder.HasOne(oi => oi.Product) .WithMany() .HasForeignKey(oi => oi.ProductId) .OnDelete(DeleteBehavior.Restrict); }}The composite key new { oi.OrderId, oi.ProductId } ensures each product appears only once per order. The delete behaviors are intentionally different: deleting an order removes its items, but deleting a product that’s been ordered should fail.
Self-Referencing Relationships
Categories often have hierarchies—Electronics contains Computers, which contains Laptops. This is a self-referencing relationship:
public class Category{ public Guid Id { get; private set; } public string Name { get; private set; } = string.Empty;
// Parent reference (null for root categories) public Guid? ParentCategoryId { get; private set; } public Category? ParentCategory { get; private set; }
// Children collection public ICollection<Category> SubCategories { get; private set; } = new List<Category>();
// Products in this category public ICollection<Product> Products { get; private set; } = new List<Product>();}The ParentCategoryId is nullable—root categories like “Electronics” have no parent. Here’s the configuration:
public class CategoryConfiguration : IEntityTypeConfiguration<Category>{ public void Configure(EntityTypeBuilder<Category> builder) { builder.ToTable("Categories", "catalog");
builder.HasKey(c => c.Id);
builder.Property(c => c.Name) .IsRequired() .HasMaxLength(100);
// Self-referencing relationship builder.HasOne(c => c.ParentCategory) .WithMany(c => c.SubCategories) .HasForeignKey(c => c.ParentCategoryId) .OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(c => c.ParentCategoryId); builder.HasIndex(c => c.Name); }}Warning: Never use DeleteBehavior.Cascade on self-referencing relationships. Deleting “Electronics” would cascade to “Computers,” which cascades to “Laptops,” potentially wiping out your entire category tree. Use Restrict and handle hierarchy deletion explicitly in your business logic.
Composite Keys
We saw composite keys in the OrderItem example. Here’s the key insight: composite keys don’t work with Find() the way single keys do.
// Single key - works greatvar product = await dbContext.Products.FindAsync(productId);
// Composite key - must provide all key parts in ordervar orderItem = await dbContext.OrderItems.FindAsync(orderId, productId);The FindAsync parameter order must match the order you defined the key. Alternatively, use a query:
var orderItem = await dbContext.OrderItems .FirstOrDefaultAsync(oi => oi.OrderId == orderId && oi.ProductId == productId);Advanced Configuration Techniques
Let’s cover a few techniques that aren’t strictly about relationships but come up frequently.
Value Conversions
Got an enum you want stored as a string instead of an integer? Value conversions handle this:
public enum OrderStatus{ Pending, Processing, Shipped, Delivered, Cancelled}
// In configurationbuilder.Property(o => o.Status) .HasConversion<string>() .HasMaxLength(50);Now the database column contains "Pending", "Shipped", etc., instead of 0, 2. This makes the database more readable and survives enum reordering.
Owned Types for Value Objects
If you’re following DDD, you might have value objects like Address or Money. Owned types let you map these without creating separate tables:
public class Address{ public string Street { get; private set; } = string.Empty; public string City { get; private set; } = string.Empty; public string PostalCode { get; private set; } = string.Empty; public string Country { get; private set; } = string.Empty;}
public class Customer{ public Guid Id { get; private set; } public string Name { get; private set; } = string.Empty; public Address ShippingAddress { get; private set; } = null!; public Address BillingAddress { get; private set; } = null!;}Configure the owned type:
builder.OwnsOne(c => c.ShippingAddress, address =>{ address.Property(a => a.Street).HasMaxLength(200).HasColumnName("ShippingStreet"); address.Property(a => a.City).HasMaxLength(100).HasColumnName("ShippingCity"); address.Property(a => a.PostalCode).HasMaxLength(20).HasColumnName("ShippingPostalCode"); address.Property(a => a.Country).HasMaxLength(100).HasColumnName("ShippingCountry");});
builder.OwnsOne(c => c.BillingAddress, address =>{ address.Property(a => a.Street).HasMaxLength(200).HasColumnName("BillingStreet"); address.Property(a => a.City).HasMaxLength(100).HasColumnName("BillingCity"); address.Property(a => a.PostalCode).HasMaxLength(20).HasColumnName("BillingPostalCode"); address.Property(a => a.Country).HasMaxLength(100).HasColumnName("BillingCountry");});The Customer table gets columns like ShippingStreet, ShippingCity, BillingStreet, etc. No separate Address table.
Global Query Filters
Implementing soft delete? Global query filters automatically exclude deleted records from all queries:
public interface ISoftDelete{ bool IsDeleted { get; } DateTime? DeletedAt { get; }}
// In configurationbuilder.HasQueryFilter(p => !p.IsDeleted);Now every query against Products automatically adds WHERE IsDeleted = 0. To include deleted records, use IgnoreQueryFilters():
var allProducts = await dbContext.Products .IgnoreQueryFilters() .ToListAsync();Common Mistakes and How to Avoid Them
I’ve reviewed a lot of EF Core code over the years. Here are the mistakes I see most often.
Mistake 1: Configuring Both Ends of a Relationship
This causes confusion and sometimes conflicts:
// In ProductConfigurationbuilder.HasOne(p => p.Category) .WithMany(c => c.Products);
// In CategoryConfiguration - DON'T DO THISbuilder.HasMany(c => c.Products) .WithOne(p => p.Category);Both configurations describe the same relationship. Configure it once, from one side. EF Core figures out the other side automatically.
Mistake 2: Forgetting OnDelete Behavior
EF Core defaults to Cascade for required relationships. This might not be what you want:
// Default cascade - deleting category deletes all its products!builder.HasOne(p => p.Category) .WithMany(c => c.Products) .HasForeignKey(p => p.CategoryId);
// Explicit restrict - can't delete category with productsbuilder.HasOne(p => p.Category) .WithMany(c => c.Products) .HasForeignKey(p => p.CategoryId) .OnDelete(DeleteBehavior.Restrict);Always be explicit about delete behavior. Restrict is usually safer for business entities.
Mistake 3: Not Using HasPrecision for Decimals
Default decimal precision varies by database. SQL Server uses decimal(18,2) by default, but relying on this is risky:
// Always specify precision for money/decimal fieldsbuilder.Property(p => p.Price) .HasPrecision(18, 2);
builder.Property(p => p.TaxRate) .HasPrecision(5, 4); // e.g., 0.0825 for 8.25%Mistake 4: Inconsistent Configuration Approaches
Some entities use annotations, others use Fluent API, and nobody knows which is authoritative:
// Product uses annotations[MaxLength(200)]public string Name { get; set; }
// Category uses Fluent APIbuilder.Property(c => c.Name).HasMaxLength(100);
// OrderItem uses... both? Maybe?Pick a strategy. My recommendation: IEntityTypeConfiguration for everything. It’s consistent, keeps entities clean, and all configuration lives in predictable locations.
Mistake 5: Missing Indexes on Foreign Keys
Foreign keys are frequently used in queries. Always index them:
builder.HasIndex(p => p.CategoryId);builder.HasIndex(o => o.CustomerId);builder.HasIndex(oi => oi.OrderId);Also add indexes for columns you frequently filter or sort by:
builder.HasIndex(p => p.Name);builder.HasIndex(o => o.OrderDate);builder.HasIndex(p => new { p.CategoryId, p.IsActive }); // Composite indexMistake 6: Not Reviewing Generated Migrations
Always review what EF Core generates before applying migrations:
dotnet ef migrations add AddProductIndexdotnet ef migrations script # See the actual SQLI’ve seen migrations that accidentally drop columns, change data types in breaking ways, or create indexes that tank performance. Trust, but verify.
Configuration Precedence Rules
Quick reference for when configurations conflict:
- Fluent API — Highest priority, always wins
- Data Annotations — Applied if Fluent API doesn’t configure that aspect
- Conventions — Default behavior, lowest priority
Example:
// Convention: string = nvarchar(max)// Data Annotation: MaxLength(100)[MaxLength(100)]public string Name { get; set; }
// Fluent API: MaxLength(200) - THIS WINSbuilder.Property(p => p.Name).HasMaxLength(200);The final column is nvarchar(200), not 100, not max.
Best Practices Checklist
Before we wrap up, here’s a checklist you can reference for your projects:
- Use
IEntityTypeConfiguration<T>for all entity configurations - Register with
ApplyConfigurationsFromAssembly— no manual registration needed - Keep configurations in a dedicated folder —
Persistence/Configurations/ - Use Fluent API for relationships — annotations can’t express everything
- Always specify decimal precision —
HasPrecision(18, 2)for money fields - Add indexes for foreign keys — and frequently filtered columns
- Be explicit about
OnDeletebehavior — don’t rely on cascade defaults - Configure relationships from one side only — avoid duplicate configuration
- Review generated migrations — always check the SQL before applying
Summary
Entity configuration is the bridge between your domain models and the database. Get it right, and your schema evolves cleanly as your application grows. Get it wrong, and you’ll spend hours debugging relationship issues, cascade delete disasters, or truncated decimal values.
The key takeaways:
- Fluent API offers full control and keeps persistence concerns out of your domain
IEntityTypeConfiguration<T>preventsOnModelCreatingfrom becoming a maintenance nightmare- Complex relationships like many-to-many with payload require explicit join entities
- Common mistakes like missing indexes or cascade delete defaults are easy to avoid once you know about them
Take the time to set up proper configuration classes early—your future self will thank you.
What patterns do you use for entity configuration in your projects? Let me know in the comments below.


