EF Core Fluent API: Complete Entity Configuration Guide
Master EF Core Fluent API to configure entities, relationships, indexes, value converters, table splitting, and owned entities ā the production way, without data annotation clutter.
Why Fluent API Over Data Annotations?
Data annotations are fine for simple models. But as your domain grows, they leak infrastructure concerns into your domain entities:
// Data annotations ā EF config pollutes your domain entity
public class Order
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
[Required]
[MaxLength(500)]
[Column("order_note")]
public string Note { get; set; }
[ForeignKey("Customer")]
public int CustomerId { get; set; }
}With Fluent API in dedicated IEntityTypeConfiguration<T> classes, your entity stays clean:
// Clean domain entity ā no EF references
public class Order
{
public int Id { get; set; }
public string Note { get; set; }
public int CustomerId { get; set; }
public Customer Customer { get; set; }
}// All EF config in its own class
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.ToTable("orders");
builder.HasKey(o => o.Id);
builder.Property(o => o.Note)
.IsRequired()
.HasMaxLength(500)
.HasColumnName("order_note");
}
}Setting Up IEntityTypeConfiguration
Create a configuration class per entity and register them automatically:
// Infrastructure/Persistence/Configurations/OrderConfiguration.cs
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
// Table and schema
builder.ToTable("orders", schema: "sales");
// Primary key
builder.HasKey(o => o.Id);
builder.Property(o => o.Id)
.UseIdentityColumn(); // SQL Server IDENTITY(1,1)
}
}Register all configurations from the assembly automatically ā no need to call each one manually:
public class AppDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Automatically registers all IEntityTypeConfiguration<T> in this assembly
modelBuilder.ApplyConfigurationsFromAssembly(
typeof(AppDbContext).Assembly
);
}
}Property Configuration
Basic Column Mapping
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.Property(p => p.Name)
.IsRequired()
.HasMaxLength(200)
.HasColumnName("product_name")
.HasDefaultValue("Unnamed");
builder.Property(p => p.Price)
.HasColumnType("decimal(18,2)")
.HasPrecision(18, 2); // EF 6+ shorthand
builder.Property(p => p.Description)
.IsRequired(false) // nullable
.HasMaxLength(2000);
// Computed column (SQL expression, stored)
builder.Property(p => p.FullName)
.HasComputedColumnSql("[FirstName] + ' ' + [LastName]", stored: true);
}Ignoring Properties
// Exclude from the schema entirely
builder.Ignore(p => p.CachedDisplayName);Concurrency Token (Optimistic Locking)
// SQL Server: rowversion maps to byte[]
builder.Property(p => p.RowVersion)
.IsRowVersion(); // auto-generated, checked on UPDATE/DELETE
// Any column as concurrency token:
builder.Property(p => p.UpdatedAt)
.IsConcurrencyToken();Primary Keys
// Single column PK
builder.HasKey(e => e.Id);
// Composite PK
builder.HasKey(e => new { e.OrderId, e.ProductId });
// Alternate key (unique constraint + FK target)
builder.HasAlternateKey(e => e.Email);
// Shadow property as PK (not in the class)
builder.Property<int>("InternalId");
builder.HasKey("InternalId");Indexes
// Single column index
builder.HasIndex(e => e.Email)
.IsUnique()
.HasDatabaseName("IX_users_email");
// Composite index
builder.HasIndex(e => new { e.LastName, e.FirstName })
.HasDatabaseName("IX_customers_name");
// Filtered index (SQL Server)
builder.HasIndex(e => e.DeletedAt)
.HasFilter("[DeletedAt] IS NOT NULL")
.HasDatabaseName("IX_orders_deleted");
// Include columns (covering index)
builder.HasIndex(e => e.CustomerId)
.IncludeProperties(e => new { e.Status, e.TotalAmount });Relationships
One-to-Many
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
// Order has many OrderItems; OrderItem belongs to one Order
builder.HasMany(o => o.Items)
.WithOne(i => i.Order)
.HasForeignKey(i => i.OrderId)
.OnDelete(DeleteBehavior.Cascade);
}
}Common DeleteBehavior options:
Cascadeā delete children when parent is deletedRestrictā prevent deletion if children exist (throws)SetNullā set FK to NULL (FK must be nullable)NoActionā leave it to the DB (default in many cases)
One-to-One
builder.HasOne(u => u.Profile)
.WithOne(p => p.User)
.HasForeignKey<UserProfile>(p => p.UserId)
.IsRequired();Many-to-Many (EF 5+)
// EF 5+ can skip the join entity entirely:
builder.HasMany(p => p.Tags)
.WithMany(t => t.Products)
.UsingEntity(j => j.ToTable("product_tags"));
// Or explicit join entity for payload:
builder.HasMany(p => p.Tags)
.WithMany(t => t.Products)
.UsingEntity<ProductTag>(
j => j.HasOne(pt => pt.Tag).WithMany().HasForeignKey(pt => pt.TagId),
j => j.HasOne(pt => pt.Product).WithMany().HasForeignKey(pt => pt.ProductId),
j =>
{
j.ToTable("product_tags");
j.Property(pt => pt.AssignedAt).HasDefaultValueSql("GETUTCDATE()");
}
);Self-Referencing (Tree/Hierarchy)
builder.HasMany(c => c.Children)
.WithOne(c => c.Parent)
.HasForeignKey(c => c.ParentId)
.IsRequired(false)
.OnDelete(DeleteBehavior.Restrict);Owned Entities
Owned entities are value objects mapped to the same table as the owner. They have no identity of their own.
// Domain
public class Address
{
public string Street { get; set; }
public string City { get; set; }
public string PostalCode { get; set; }
public string Country { get; set; }
}
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
public Address ShippingAddress { get; set; }
public Address BillingAddress { get; set; }
}// Configuration
public class CustomerConfiguration : IEntityTypeConfiguration<Customer>
{
public void Configure(EntityTypeBuilder<Customer> builder)
{
builder.OwnsOne(c => c.ShippingAddress, a =>
{
a.Property(x => x.Street).HasColumnName("shipping_street").HasMaxLength(200);
a.Property(x => x.City).HasColumnName("shipping_city").HasMaxLength(100);
a.Property(x => x.PostalCode).HasColumnName("shipping_postal").HasMaxLength(20);
a.Property(x => x.Country).HasColumnName("shipping_country").HasMaxLength(100);
});
builder.OwnsOne(c => c.BillingAddress, a =>
{
a.Property(x => x.Street).HasColumnName("billing_street").HasMaxLength(200);
a.Property(x => x.City).HasColumnName("billing_city").HasMaxLength(100);
a.Property(x => x.PostalCode).HasColumnName("billing_postal").HasMaxLength(20);
a.Property(x => x.Country).HasColumnName("billing_country").HasMaxLength(100);
});
}
}Generated table ā all in customers:
customers
āāā Id
āāā Name
āāā shipping_street
āāā shipping_city
āāā shipping_postal
āāā shipping_country
āāā billing_street
āāā billing_city
āāā billing_postal
āāā billing_countryValue Converters
Value converters let you store a type in the DB differently from how it looks in C#.
Enum ā String
builder.Property(o => o.Status)
.HasConversion(
v => v.ToString(), // C# ā DB: store as string
v => Enum.Parse<OrderStatus>(v) // DB ā C#: parse back
)
.HasMaxLength(50);
// Or use the built-in:
builder.Property(o => o.Status)
.HasConversion<string>();List<string> ā JSON Column
builder.Property(p => p.Tags)
.HasConversion(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions)null),
v => JsonSerializer.Deserialize<List<string>>(v, (JsonSerializerOptions)null)
)
.HasColumnType("nvarchar(max)");Custom Value Object Converter
// Value object
public record Money(decimal Amount, string Currency);
// Converter
public class MoneyConverter : ValueConverter<Money, string>
{
public MoneyConverter() : base(
v => $"{v.Amount}|{v.Currency}",
v => new Money(
decimal.Parse(v.Split('|')[0]),
v.Split('|')[1]
)
) { }
}
// Registration
builder.Property(p => p.Price)
.HasConversion(new MoneyConverter())
.HasMaxLength(30);Table Splitting
Map multiple entity types to the same table. Useful to lazy-load heavy columns.
public class ProductSummary { public int Id; public string Name; public decimal Price; }
public class ProductDetail { public int Id; public string FullDescription; public byte[] Image; }modelBuilder.Entity<ProductSummary>().ToTable("products");
modelBuilder.Entity<ProductDetail>().ToTable("products");
modelBuilder.Entity<ProductSummary>()
.HasOne<ProductDetail>()
.WithOne()
.HasForeignKey<ProductDetail>(d => d.Id);Table-Per-Hierarchy (TPH) ā Inheritance
All subclasses share one table with a discriminator column:
public abstract class Payment { public int Id; public decimal Amount; }
public class CreditCardPayment : Payment { public string CardLast4; }
public class BankTransferPayment : Payment { public string IBAN; }builder.HasDiscriminator<string>("payment_type")
.HasValue<CreditCardPayment>("credit_card")
.HasValue<BankTransferPayment>("bank_transfer");
// Null columns for unused subtype properties ā add sparse column hint:
builder.Property<CreditCardPayment>(p => p.CardLast4)
.HasColumnName("card_last4")
.IsRequired(false);Table-Per-Type (TPT) ā Inheritance
Each subclass gets its own table joined by PK:
modelBuilder.Entity<CreditCardPayment>().ToTable("credit_card_payments");
modelBuilder.Entity<BankTransferPayment>().ToTable("bank_transfer_payments");TPH is faster (no JOIN). TPT is cleaner for very different subtypes.
Global Query Filters
Apply a filter to all queries for an entity ā perfect for soft deletes and multi-tenancy.
// Soft delete filter
builder.HasQueryFilter(e => !e.IsDeleted);
// Tenant filter (inject tenant ID via constructor or field)
builder.HasQueryFilter(e => e.TenantId == _tenantId);To bypass the filter for admin queries:
await _db.Orders.IgnoreQueryFilters().ToListAsync();Shadow Properties
Properties that exist in the DB schema but not in the entity class ā useful for audit columns.
// Define
builder.Property<DateTime>("CreatedAt")
.HasDefaultValueSql("GETUTCDATE()");
builder.Property<DateTime>("UpdatedAt");
// Set in SaveChanges override
public override int SaveChanges()
{
foreach (var entry in ChangeTracker.Entries())
{
if (entry.State == EntityState.Added)
entry.Property("CreatedAt").CurrentValue = DateTime.UtcNow;
if (entry.State is EntityState.Added or EntityState.Modified)
entry.Property("UpdatedAt").CurrentValue = DateTime.UtcNow;
}
return base.SaveChanges();
}Sequences
Database sequences give you a source of unique numbers independent of a table insert:
modelBuilder.HasSequence<int>("order_numbers", schema: "sales")
.StartsAt(10000)
.IncrementsBy(1);
builder.Property(o => o.OrderNumber)
.HasDefaultValueSql("NEXT VALUE FOR sales.order_numbers");Seeding Data
builder.HasData(
new Product { Id = 1, Name = "Widget", Price = 9.99m },
new Product { Id = 2, Name = "Gadget", Price = 24.99m }
);For owned entities, seed with anonymous types:
builder.OwnsOne(c => c.Address).HasData(new
{
CustomerId = 1,
Street = "123 Main St",
City = "London",
PostalCode = "EC1A 1BB",
Country = "UK"
});Complete Real-World Example
Here's a complete configuration for an Order aggregate that uses most of the above:
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.ToTable("orders", schema: "sales");
builder.HasKey(o => o.Id);
builder.Property(o => o.Id).UseIdentityColumn();
builder.Property(o => o.Reference)
.IsRequired()
.HasMaxLength(20)
.HasColumnName("order_ref");
builder.HasIndex(o => o.Reference).IsUnique();
builder.Property(o => o.Status)
.HasConversion<string>()
.HasMaxLength(30)
.IsRequired();
builder.Property(o => o.TotalAmount)
.HasPrecision(18, 2)
.IsRequired();
builder.Property<DateTime>("CreatedAt")
.HasDefaultValueSql("GETUTCDATE()");
builder.Property<string>("CreatedBy")
.HasMaxLength(100);
// Owned address
builder.OwnsOne(o => o.DeliveryAddress, a =>
{
a.Property(x => x.Street).HasColumnName("delivery_street").HasMaxLength(200);
a.Property(x => x.City).HasColumnName("delivery_city").HasMaxLength(100);
a.Property(x => x.PostalCode).HasColumnName("delivery_postal").HasMaxLength(20);
a.Property(x => x.Country).HasColumnName("delivery_country").HasMaxLength(60);
});
// Soft delete filter
builder.Property<bool>("IsDeleted").HasDefaultValue(false);
builder.HasQueryFilter(o => !EF.Property<bool>(o, "IsDeleted"));
// Relationship
builder.HasMany(o => o.Items)
.WithOne(i => i.Order)
.HasForeignKey(i => i.OrderId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(o => o.Customer)
.WithMany(c => c.Orders)
.HasForeignKey(o => o.CustomerId)
.OnDelete(DeleteBehavior.Restrict);
}
}Key Takeaways
- Use
IEntityTypeConfiguration<T>in separate files ā one per entity ā never configure inOnModelCreatingdirectly for anything beyond trivial models ApplyConfigurationsFromAssemblyis the cleanest registration approach- Use owned entities for value objects ā keeps your aggregate root boundaries honest
- Global query filters for soft deletes and multi-tenancy are invisible to callers and composable with LINQ
- Shadow properties keep audit columns out of your domain model
- Fluent API gives you everything data annotations cannot: composite keys, alternate keys, filtered indexes, table splitting, sequences, and full inheritance mapping control
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.