A few years back, I spent a solid week chasing a weird performance bug. Queries that should have taken milliseconds were sometimes taking seconds to run. The culprit? A simple string property on a core entity. Nobody had set a max length, so EF Core defaulted it to nvarchar(max)
. SQL Server handles those columns differently, and it was wrecking our query plans.
This one burned me in production, and it taught me a hard lesson: your entity model is the foundation of your application’s performance. Get it wrong, and you’ll pay for it with slow queries and painful migrations.
This checklist is the result of modeling dozens of schemas for EF Core on SQL Server. I’m focusing on SQL Server here because its defaults and indexing behaviors are unique.
Getting the Basics Right: The Entity Itself
Let’s start with the entity class. If you mess this up, everything else gets more complicated.
Primary Keys: Just use
Id
or[EntityName]Id
. Don’t get clever. I avoid composite keys unless the domain model is impossible without them. They make joins and repository logic a pain to write and maintain.// Good: Simple, clean, effective. public int Id { get; private set; } // Avoid: This complicates everything. public class OrderItemKey { public int OrderId { get; set; } public int ProductId { get; set; } }
Value Objects: For data that has no identity on its own, use
Owned Types
. AnAddress
or aMoney
value doesn’t need its ownId
. It belongs to its parent. This keeps your domain model clean and translates well to the database.Enums: Store them as an
int
. Always. It’s faster and more compact. The only time I’d ever consider mapping to a string (HasConversion<string>
) is if a non-developer needs to read the raw database table for reporting. For 99% of applications,int
is the right call.Backing Fields and Private Setters: Your entity should protect its own rules. Public setters are an invitation for bugs. By using private setters and exposing methods, you control how the entity’s state can change. I’ve seen too many production bugs caused by another service setting a property to an invalid value because it was
public set
.
Stop Letting EF Core Guess Your Relationships
EF Core’s conventions are pretty good, but I don’t rely on them for production code. Being explicit prevents nasty surprises during migrations.
One-to-Many: Always configure the foreign key with
HasForeignKey
. It makes the relationship obvious in your configuration and removes any ambiguity.Many-to-Many: In modern EF Core, you can get away with not having a join entity. I think that’s a mistake. Sooner or later, you’ll need to add a property to that relationship (like
DateAdded
orIsActive
). If you start without a join entity, adding one later is a breaking schema change. Just create theOrderProduct
join entity from day one.Cascade Deletes: I turn this off for most relationships in SQL Server.
OnDelete(DeleteBehavior.Restrict)
is much safer. Accidental deletion of a parent record shouldn’t wipe out historical child records. For “deleting” data, I almost always prefer a soft-delete pattern using a global query filter.// This configuration has saved me from data loss more than once. builder.HasOne(o => o.Customer) .WithMany(c => c.Orders) .HasForeignKey(o => o.CustomerId) .OnDelete(DeleteBehavior.Restrict); // Don't delete customer orders!
How to Configure Columns for SQL Server
These are the small details that make a huge difference in performance and data integrity.
String Lengths: Always, always set
HasMaxLength
. This avoids thenvarchar(max)
problem I mentioned earlier and helps the database optimize storage and query plans.Decimal Precision: If you’re storing money, don’t accept the default decimal precision. You’ll get rounding errors. Be explicit with
HasColumnType("decimal(18,2)")
.Required vs. Optional: Use
IsRequired()
to make nullability clear. This ensures the database constraint matches your C# code and avoids confusion.Indexes: EF Core does not automatically create indexes on foreign keys in SQL Server. This is a huge “gotcha.” You have to add them yourself. I always add an index on my foreign key columns and any other column that’s frequently used in
WHERE
clauses.
Thinking Ahead: Performance and Sanity
A few final configurations that will save you headaches down the road.
Concurrency Tokens: For any entity that multiple users might edit at the same time, you need a concurrency token. In SQL Server,
RowVersion
is the way to go. It’s handled automatically by the database and is more reliable than trying to manage it yourself.// In your entity public byte[] RowVersion { get; set; } // In your configuration builder.Property(p => p.RowVersion).IsRowVersion();
Shadow Properties: These are great for system-level data that doesn’t belong in your domain model. I use them for
CreatedAt
andLastUpdatedAt
. Your domain logic doesn’t care about them, but your application framework does.Global Query Filters: These are perfect for soft deletes and multi-tenancy. But be careful. A poorly written filter can prevent SQL Server from using an index correctly. Always check the query plan (
EXPLAIN
) on any complex query against a table that has a global filter.
SQL Server Gotchas You Can’t Ignore
I’ve learned these things the hard way:
- Index Your Foreign Keys: I’m saying it again because it’s that important. SQL Server doesn’t do it for you. Your reads will be slow until you do.
- GUID Primary Keys: If you must use GUIDs as primary keys, make sure they are sequential. Random GUIDs cause massive index fragmentation and kill your insert performance. On one high-volume system, switching from
Guid.NewGuid()
to a sequential GUID generator cut our insert times by over 70%. - Clustered Indexes: By default, your primary key is the clustered index. This is usually what you want for
int
orlong
keys. For sequential GUIDs, it’s also fine. For random GUIDs, it’s terrible.
Putting It All Together: A Code Example
Here’s an Order
entity configuration that applies these rules.
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
// Primary key
builder.HasKey(o => o.Id);
// Properties with explicit constraints
builder.Property(o => o.OrderNumber)
.HasMaxLength(50)
.IsRequired();
builder.Property(o => o.TotalAmount)
.HasColumnType("decimal(18,2)")
.IsRequired();
builder.Property(o => o.Status)
.HasConversion<int>() // Store enum as int
.IsRequired();
// Concurrency token for SQL Server
builder.Property(o => o.RowVersion)
.IsRowVersion();
// Indexes for performance
builder.HasIndex(o => o.OrderNumber).IsUnique();
builder.HasIndex(o => o.CustomerId); // Index the FK!
// Relationships
builder.HasOne(o => o.Customer)
.WithMany(c => c.Orders)
.HasForeignKey(o => o.CustomerId)
.OnDelete(DeleteBehavior.Restrict); // Safer default
// Owned Type for a Value Object
builder.OwnsOne(o => o.ShippingAddress, addr =>
{
addr.Property(a => a.Street).HasMaxLength(200);
addr.Property(a => a.City).HasMaxLength(100);
addr.Property(a => a.PostalCode).HasMaxLength(20);
});
}
}
// Supporting record for the Value Object
public record Address(string Street, string City, string PostalCode);
Here’s My Take
When should you use this checklist? I treat this as the default for any new EF Core project on SQL Server. It establishes a safe, performant baseline and prevents the most common problems I’ve seen in production systems.
When should you break these rules?
When you have a very specific, well-understood reason. For example, you might use a composite key if you’re mapping to a legacy database that requires it. You might use nvarchar(max)
if you’re storing user-generated documents. If you break a rule, document why you’re doing it. Otherwise, stick to the checklist. It’ll save you a lot of trouble.
References
- Microsoft Docs: Creating and Configuring a Model
- Microsoft Docs: Owned Entity Types
- Microsoft Docs: Concurrency Tokens