Learnixo

.NET & C# Development · Lesson 72 of 229

Fluent API — Configure Entities Without Polluting Your Domain

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:

C#
// 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:

C#
// 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; }
}
C#
// 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:

C#
// 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:

C#
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

C#
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

C#
// Exclude from the schema entirely
builder.Ignore(p => p.CachedDisplayName);

Concurrency Token (Optimistic Locking)

C#
// 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

C#
// 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

C#
// 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

C#
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 deleted
  • Restrict — 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

C#
builder.HasOne(u => u.Profile)
       .WithOne(p => p.User)
       .HasForeignKey<UserProfile>(p => p.UserId)
       .IsRequired();

Many-to-Many (EF 5+)

C#
// 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)

C#
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.

C#
// 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; }
}
C#
// 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:

SQL
customers
├── Id
├── Name
├── shipping_street
├── shipping_city
├── shipping_postal
├── shipping_country
├── billing_street
├── billing_city
├── billing_postal
└── billing_country

Value Converters

Value converters let you store a type in the DB differently from how it looks in C#.

Enum → String

C#
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

C#
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

C#
// 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.

C#
public class ProductSummary { public int Id; public string Name; public decimal Price; }
public class ProductDetail  { public int Id; public string FullDescription; public byte[] Image; }
C#
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:

C#
public abstract class Payment { public int Id; public decimal Amount; }
public class CreditCardPayment : Payment { public string CardLast4; }
public class BankTransferPayment : Payment { public string IBAN; }
C#
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:

C#
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.

C#
// 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:

C#
await _db.Orders.IgnoreQueryFilters().ToListAsync();

Shadow Properties

Properties that exist in the DB schema but not in the entity class — useful for audit columns.

C#
// 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:

C#
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

C#
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:

C#
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:

C#
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 in OnModelCreating directly for anything beyond trivial models
  • ApplyConfigurationsFromAssembly is 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
Lesson Checkpoint
Quick CheckQuestion 1 of 2

Which Fluent API chain correctly configures a one-to-many relationship?