.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:
// 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