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. An Address or a Money value doesn’t need its own Id. 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 or IsActive). If you start without a join entity, adding one later is a breaking schema change. Just create the OrderProduct 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 the nvarchar(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 and LastUpdatedAt. 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:

  1. 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.
  2. 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%.
  3. Clustered Indexes: By default, your primary key is the clustered index. This is usually what you want for int or long 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

FAQ

Why is explicit key configuration important in EF Core?

Explicit keys prevent EF Core from inferring poor defaults and make your SQL Server schema predictable. Always define primary keys explicitly.

Should I use nvarchar(max) for strings in EF Core?

No. Always set HasMaxLength to avoid SQL Server creating nvarchar(max), which can severely impact indexing and performance.

How do I handle concurrency in EF Core?

Use a RowVersion column configured as IsRowVersion() in EF Core. This maps to SQL Server’s rowversion type and ensures safe optimistic concurrency.

When should I use owned types in EF Core?

Use owned types for value objects that don’t need their own identity or lifecycle, like Money or Address. Avoid them for entities that will evolve independently.

How do I manage cascade deletes in SQL Server with EF Core?

Restrict cascade deletes in most cases to avoid unintended data loss. Use DeleteBehavior.Restrict and rely on soft deletes or query filters instead.

About the Author

@CodeCrafter is a software engineer who builds real-world systems , from resilient ASP.NET Core backends to clean, maintainable Angular frontend. With 11+ years in production development, he shares what actually works when shipping software that has to last.