All articles

Using Fluent API in EF Core Code First

EF Core Fluent API configuration in OnModelCreating — tables, columns, keys, indexes, relationships, query filters, value conversions, and when to prefer it over DataAnnotations.

0 · log in to like, save & follow Share on LinkedIn Share on X

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.

cover

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.

Enjoyed this article? Get the best GeeksArray articles in your inbox — once a week, no spam, unsubscribe anytime.

Comments (0)

Log in to join the conversation.

No comments yet — be the first to share your thoughts.