Free .NET Web API Course

Configuring Entities with Fluent API in EF Core - Entity Configuration Best Practices

Entity configuration is where your domain meets the database. Learn Fluent API in EF Core—why it beats Data Annotations for complex scenarios, how to organize configurations with IEntityTypeConfiguration, configure complex relationships, and avoid common mistakes.

dotnet webapi-course

efcore fluent-api entity-configuration webapi dotnet-webapi-zero-to-hero-course

14 min read
3.8K views

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.999 to $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:

ApproachWhere It LivesBest For
ConventionsAutomaticDefault behavior, simple scenarios
Data AnnotationsAttributes on entity propertiesQuick constraints, validation integration
Fluent APICode in OnModelCreating or config classesComplex 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:

ScenarioRecommended ApproachWhy
Simple constraints (Required, MaxLength)EitherAnnotations are visible; Fluent API keeps models clean
Complex relationshipsFluent APIAnnotations can’t express all relationship types
Composite keysFluent APINo annotation support
Decimal precisionFluent APIHasPrecision(18, 2) is cleaner than [Column(TypeName = "...")]
DDD domain modelsFluent APIKeep persistence out of the domain
Quick prototypingData AnnotationsLess 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 it
builder.Property(p => p.Name).HasMaxLength(200); // This wins - 200, not 100

The 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 this
protected 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.

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.cs

Why 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.cs

Configuring 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 StudentCourse. 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 great
var product = await dbContext.Products.FindAsync(productId);
// Composite key - must provide all key parts in order
var 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 configuration
builder.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 configuration
builder.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 ProductConfiguration
builder.HasOne(p => p.Category)
.WithMany(c => c.Products);
// In CategoryConfiguration - DON'T DO THIS
builder.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 products
builder.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 fields
builder.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 API
builder.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 index

Mistake 6: Not Reviewing Generated Migrations

Always review what EF Core generates before applying migrations:

Terminal window
dotnet ef migrations add AddProductIndex
dotnet ef migrations script # See the actual SQL

I’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:

  1. Fluent API — Highest priority, always wins
  2. Data Annotations — Applied if Fluent API doesn’t configure that aspect
  3. 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 WINS
builder.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:

  1. Use IEntityTypeConfiguration<T> for all entity configurations
  2. Register with ApplyConfigurationsFromAssembly — no manual registration needed
  3. Keep configurations in a dedicated folderPersistence/Configurations/
  4. Use Fluent API for relationships — annotations can’t express everything
  5. Always specify decimal precisionHasPrecision(18, 2) for money fields
  6. Add indexes for foreign keys — and frequently filtered columns
  7. Be explicit about OnDelete behavior — don’t rely on cascade defaults
  8. Configure relationships from one side only — avoid duplicate configuration
  9. 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> prevents OnModelCreating from 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.

What's your Feedback?
Do let me know your thoughts around this article.

.NET + AI: Build Smarter, Ship Faster

Join 8,000+ developers learning to leverage AI for faster .NET development, smarter architectures, and real-world productivity gains.

AI + .NET tips
Productivity hacks
100% free
No spam, unsubscribe anytime