The Fluent API is EF Core's most complete way to configure how your classes map to the database — table names, column types, keys, indexes, relationships, defaults — all expressed in code inside OnModelCreating, leaving your entity classes clean. This tutorial covers the configurations you'll actually use, verified against the schemas they produce in SQL Server, and when to choose Fluent API over DataAnnotations.

Where Fluent configuration lives
public class StoreContext : DbContext
{
public DbSet<Category> Categories { get; set; }
public DbSet<Product> Products { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// all configuration goes here
}
}
Configuration chains off modelBuilder.Entity<T>(). As it grows, split it per entity with IEntityTypeConfiguration<T>:
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.ToTable("Product", "Catalog");
// …everything for Product
}
}
// in OnModelCreating:
modelBuilder.ApplyConfigurationsFromAssembly(typeof(StoreContext).Assembly);
One class per entity, discovered automatically — the pattern every sizable codebase lands on.
Table and column mapping
modelBuilder.Entity<Category>(entity =>
{
entity.ToTable("ProductCategory", "Admin"); // name + schema
entity.Property(e => e.CategoryName)
.HasColumnName("Name")
.HasMaxLength(50)
.IsRequired();
entity.Property(e => e.Price)
.HasPrecision(18, 2); // decimal(18,2)
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("getdate()");
});
Verified in a generated migration: the table lands as Admin.ProductCategory, the column as Name nvarchar(50) NOT NULL, the decimal as decimal(18,2) — exactly what the chain declares.
Keys and indexes
entity.HasKey(e => e.CategoryId);
// composite key — Fluent API only; DataAnnotations cannot express this in EF Core
modelBuilder.Entity<OrderLine>()
.HasKey(ol => new { ol.OrderId, ol.ProductId });
entity.HasIndex(e => e.CategoryName)
.IsUnique()
.HasDatabaseName("UX_Category_Name");
Filtered and composite indexes
HasIndex goes beyond single columns:
entity.HasIndex(e => new { e.CategoryId, e.Name }); // composite
entity.HasIndex(e => e.Sku)
.IsUnique()
.HasFilter("[Sku] IS NOT NULL"); // unique among non-nulls only
The filtered unique index solves the classic "unique when present" requirement — multiple NULL SKUs allowed, duplicates rejected — something a plain unique index refuses on SQL Server.
Relationships
The relationship chains read as sentences — HasOne(...).WithMany(...):
// one-to-many with delete behavior
modelBuilder.Entity<Product>()
.HasOne(p => p.Category)
.WithMany(c => c.Products)
.HasForeignKey(p => p.CategoryId)
.OnDelete(DeleteBehavior.Restrict);
// one-to-one — the FK side must be named
modelBuilder.Entity<Employee>()
.HasOne(e => e.Address)
.WithOne(a => a.Employee)
.HasForeignKey<EmployeeAddress>(a => a.EmpId);
// many-to-many with a named join table (EF Core 5+)
modelBuilder.Entity<Employee>()
.HasMany(e => e.Departments)
.WithMany(d => d.Employees)
.UsingEntity("DeptEmployees");
All three verified — the one-to-one produced a unique index on EmpId, the many-to-many auto-created the DeptEmployees join table. Full walkthrough with schema output in
configuring entity relationships with the Fluent API.
Beyond mapping: behavior configuration
// global query filter — every query gets this WHERE automatically
modelBuilder.Entity<Post>().HasQueryFilter(p => p.DeletedAt == null);
// value conversion — store an enum as a string
entity.Property(e => e.Status).HasConversion<string>();
// concurrency token
entity.Property(e => e.RowVersion).IsRowVersion();
// seed data written by migrations
modelBuilder.Entity<Category>().HasData(
new Category { CategoryId = 1, CategoryName = "Mobiles" });
Query filters are how this platform implements soft delete — deleted posts vanish from every query without a single Where in application code.
Owned types — value objects without a table of their own
An address on a supplier isn't an entity with identity; it's part of the supplier. OwnsOne maps it into the same table as prefixed columns:
public class Supplier
{
public int SupplierId { get; set; }
public string Name { get; set; } = string.Empty;
public Address Address { get; set; } = new();
}
public class Address // no key — it's a value object
{
public string Street { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
public string Pincode { get; set; } = string.Empty;
}
modelBuilder.Entity<Supplier>().OwnsOne(s => s.Address, a =>
{
a.Property(x => x.City).HasMaxLength(50).HasColumnName("City");
});
Result: Suppliers gains Address_Street, City, Address_Pincode columns — the C# model keeps its clean value object, the database keeps one table. OwnsMany does the same for collections (into a separate table keyed by the owner).
Shadow properties — columns without C# properties
Audit columns that no business code should touch can exist only in the model:
modelBuilder.Entity<Product>().Property<DateTime>("LastModified");
Set them centrally by overriding SaveChangesAsync:
public override Task<int> SaveChangesAsync(CancellationToken ct = default)
{
foreach (var entry in ChangeTracker.Entries<Product>()
.Where(e => e.State is EntityState.Added or EntityState.Modified))
entry.Property("LastModified").CurrentValue = DateTime.UtcNow;
return base.SaveChangesAsync(ct);
}
Every insert and update stamps the column; no entity property to misuse, no forgotten assignments scattered through services.
Fluent API or DataAnnotations?
DataAnnotations cover the common cases with attributes on the entity; Fluent API covers everything, including what annotations can't express in EF Core — composite keys, delete behavior, query filters, value conversions, join-table naming. They compose: annotations for the simple 80%, Fluent for the rest — and where both configure the same thing, Fluent API wins.
Practical split most teams settle on:
- Validation-flavored facts on the entity (
[Required],[MaxLength]) — they double as request validation. - Everything structural — relationships, keys, indexes, conversions, filters — in
IEntityTypeConfiguration<T>classes, where the database schema reads like documentation.
After configuring, generate and apply the schema with migrations: dotnet ef migrations add shows you exactly what your Fluent chains became.
Comments (0)
No comments yet — be the first to share your thoughts.