Fluent Configuration
Map entities with IEntityTypeConfiguration classes: indexes, lengths, precision, column types and value conversions, plus model-wide conventions.
public sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> b) =>
b.HasIndex(o => new { o.CustomerId, o.PlacedAt });
}The fluent API is the full mapping language. Putting all of it in OnModelCreating works for five entities and becomes a merge-conflict magnet at fifty, so teams write one IEntityTypeConfiguration<T> class per entity, next to the entity or in a Persistence folder, and load them with modelBuilder.ApplyConfigurationsFromAssembly(typeof(ShopDb).Assembly). The scan picks up every non-abstract configuration class with a parameterless constructor in that assembly, in no guaranteed order, so two classes must not configure the same property differently.
Model-wide rules first
ConfigureConventions runs before any entity is configured and sets defaults by CLR type: every decimal gets HavePrecision(18, 4) (enough for unit prices and tax rates), every string a max length, every enum a string conversion. Per-property configuration still overrides it. This is where you stop forgetting precision on the next money column.
Indexes
EF indexes every foreign key by convention; everything else is yours. HasIndex(o => new { o.CustomerId, o.PlacedAt }) creates a composite index whose column order matters: it serves filters on CustomerId alone, not on PlacedAt alone. IsUnique() turns a business rule into a constraint, and HasFilter makes a partial index, written in the provider's SQL: "\"DeletedAt\" IS NULL" on PostgreSQL, "[DeletedAt] IS NULL" on SQL Server. Name indexes you will refer to in operations runbooks with HasDatabaseName.
Lengths, precision and column types
Prefer the portable calls: HasMaxLength(64), HasPrecision(12, 2), IsUnicode(false) for ASCII codes such as SKUs. Use HasColumnType("jsonb") or HasColumnType("money") only for a type the portable calls cannot express, because the string is passed to the database verbatim and ties the model to one provider. HasDefaultValueSql("now()") lets the database fill a column, and EF reads the value back after insert.
Value conversions
HasConversion maps a CLR type to a column type: an enum to text, a strongly typed id such as OrderId to its Guid, a Money struct to a decimal. Conversions run in EF, not in SQL, which has two consequences. Equality filters work (o.Id == orderId becomes a parameter), but the database only ever sees the stored form, so a LIKE on a converted value or a sort on an enum stored as text sorts alphabetically, not by declaration order.
| Enum storage | Pro | Production risk |
|---|---|---|
| int (default) | Small, fast | Reordering or inserting a member silently changes the meaning of every stored row |
string via HasConversion<string>() | Readable, safe to reorder | Renaming a member breaks reading old rows; needs a data migration |
Choose strings for enums that outlive a release, with a max length so the column is not unbounded. The failure mode of ints is the worst kind: no error, just wrong statuses on old orders after someone adds PartiallyShipped in the middle of the list.
Example
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public sealed class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<Customer> Customers => Set<Customer>();
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
// Defaults for every property of these types; per-property calls still override.
configurationBuilder.Properties<decimal>().HavePrecision(18, 4);
configurationBuilder.Properties<string>().HaveMaxLength(256);
configurationBuilder.Properties<OrderStatus>().HaveConversion<string>().HaveMaxLength(32);
}
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ShopDb).Assembly);
}
public sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> order)
{
order.ToTable("orders");
order.HasKey(o => o.Id);
// Strongly typed id stored as its Guid.
order.Property(o => o.Id).HasConversion(id => id.Value, value => new OrderId(value));
order.Property(o => o.Number).HasMaxLength(20).IsUnicode(false);
order.HasIndex(o => o.Number).IsUnique().HasDatabaseName("ux_orders_number");
// Serves "orders for customer X, newest first"; column order matters.
order.HasIndex(o => new { o.CustomerId, o.PlacedAt }).HasDatabaseName("ix_orders_customer_placed");
// Partial index: only open orders are polled by the fulfilment worker.
order.HasIndex(o => o.Status)
.HasFilter("\"Status\" IN ('Pending', 'Paid')")
.HasDatabaseName("ix_orders_open_status");
order.Property(o => o.Total).HasPrecision(12, 2);
order.Property(o => o.Currency).HasMaxLength(3).IsFixedLength();
order.Property(o => o.PlacedAt).HasDefaultValueSql("now()");
order.Property(o => o.Notes).HasMaxLength(2000);
}
}
public sealed class CustomerConfiguration : IEntityTypeConfiguration<Customer>
{
public void Configure(EntityTypeBuilder<Customer> customer)
{
customer.ToTable("customers");
customer.Property(c => c.Email).HasMaxLength(320);
customer.HasIndex(c => c.Email).IsUnique();
// Restrict: deleting a customer with orders must fail, not erase their order history.
customer.HasMany(c => c.Orders).WithOne().HasForeignKey(o => o.CustomerId).OnDelete(DeleteBehavior.Restrict);
}
}
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.CreateVersion7());
}
public enum OrderStatus { Pending, Paid, Shipped, Cancelled }
public sealed class Order
{
public OrderId Id { get; set; } = OrderId.New();
public required string Number { get; set; }
public int CustomerId { get; set; }
public OrderStatus Status { get; set; }
public decimal Total { get; set; }
public required string Currency { get; set; }
public DateTime PlacedAt { get; set; }
public string? Notes { get; set; }
}
public sealed class Customer
{
public int Id { get; set; }
public required string Email { get; set; }
public List<Order> Orders { get; } = [];
}When to use it
- Split a 600-line OnModelCreating into one IEntityTypeConfiguration class per entity loaded with ApplyConfigurationsFromAssembly
- Add a partial unique index on customers.email WHERE deleted_at IS NULL so a deleted account frees its address
- Store OrderStatus as a bounded string so adding PartiallyShipped in the middle of the enum cannot corrupt old orders
More examples
A reusable converter for every strongly typed id
Strongly typed ids make <code>GetInvoice(paymentId)</code> a compile error instead of a wrong-row bug. Registering the converter in <code>ConfigureConventions</code> covers the key and every foreign key of that type, such as <code>Payment.InvoiceId</code>, which <code>HasForeignKey</code> makes a real foreign key to <code>Invoices</code>. <code>Guid.CreateVersion7()</code> produces time-ordered values, which keeps B-tree inserts at the end of the index instead of scattering them like random v4 GUIDs.
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
public sealed class BillingDb(DbContextOptions<BillingDb> options) : DbContext(options)
{
public DbSet<Invoice> Invoices => Set<Invoice>();
public DbSet<Payment> Payments => Set<Payment>();
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
// One line per id type instead of a HasConversion call on every key and foreign key.
configurationBuilder.Properties<InvoiceId>().HaveConversion<InvoiceIdConverter>();
configurationBuilder.Properties<PaymentId>().HaveConversion<PaymentIdConverter>();
}
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
modelBuilder.Entity<Payment>()
.HasOne<Invoice>()
.WithMany()
.HasForeignKey(p => p.InvoiceId)
.OnDelete(DeleteBehavior.Restrict);
}
public readonly record struct InvoiceId(Guid Value);
public readonly record struct PaymentId(Guid Value);
public sealed class InvoiceIdConverter() : ValueConverter<InvoiceId, Guid>(id => id.Value, value => new InvoiceId(value));
public sealed class PaymentIdConverter() : ValueConverter<PaymentId, Guid>(id => id.Value, value => new PaymentId(value));
public sealed class Invoice
{
public InvoiceId Id { get; set; } = new(Guid.CreateVersion7());
public required string Number { get; set; }
public decimal Amount { get; set; }
}
public sealed class Payment
{
public PaymentId Id { get; set; } = new(Guid.CreateVersion7());
public InvoiceId InvoiceId { get; set; } // the converter applies to the foreign key too
public decimal Amount { get; set; }
}Enum stored as int, then reordered
The default enum mapping stores the numeric value, so the meaning of stored data depends on member order in source code. The string conversion removes that coupling at the cost of a few bytes per row. Switching an existing int column to string needs a migration that converts the data with a <code>CASE</code> expression; EF only scaffolds the type change.
using Microsoft.EntityFrameworkCore;
public sealed class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
public DbSet<Shipment> Shipments => Set<Shipment>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Fix: store the name, bounded. Reordering members no longer changes stored data.
modelBuilder.Entity<Shipment>()
.Property(s => s.Status)
.HasConversion<string>()
.HasMaxLength(32);
}
}
// Release 1 had: Created = 0, Dispatched = 1, Delivered = 2.
// Release 2 inserted PartiallyDispatched in the middle. With int storage every
// row saved as 1 (Dispatched) now reads as PartiallyDispatched, and every 2 reads
// as Dispatched: no exception, just wrong data.
public enum ShipmentStatus { Created, PartiallyDispatched, Dispatched, Delivered }
public sealed class Shipment
{
public int Id { get; set; }
public int OrderId { get; set; }
public ShipmentStatus Status { get; set; }
}The DDL from the configuration class
<code>IsFixedLength</code> produced <code>character(3)</code>, the enum became <code>character varying(32)</code> from the convention, and the partial index carries the filter text exactly as written in <code>HasFilter</code>. Because the filter is raw SQL, quoting rules are the provider's: the same model would need <code>[Status]</code> on SQL Server.
CREATE TABLE orders (
"Id" uuid NOT NULL,
"Number" character varying(20) NOT NULL,
"CustomerId" integer NOT NULL,
"Status" character varying(32) NOT NULL,
"Total" numeric(12,2) NOT NULL,
"Currency" character(3) NOT NULL,
"PlacedAt" timestamp with time zone NOT NULL DEFAULT (now()),
"Notes" character varying(2000),
CONSTRAINT "PK_orders" PRIMARY KEY ("Id"),
CONSTRAINT "FK_orders_customers_CustomerId" FOREIGN KEY ("CustomerId")
REFERENCES customers ("Id") ON DELETE RESTRICT
);
CREATE UNIQUE INDEX ux_orders_number ON orders ("Number");
CREATE INDEX ix_orders_customer_placed ON orders ("CustomerId", "PlacedAt");
CREATE INDEX ix_orders_open_status ON orders ("Status") WHERE "Status" IN ('Pending', 'Paid');
Discussion