.NET & C# Development · Lesson 4 of 11

EF Core Fluent API

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