Entity Configuration — Data Annotations and Fluent API

Entity configuration maps C# classes to database tables with full control over table names, column names, data types, constraints, and indexes. ASP.NET Core EF Core supports two configuration styles: Data Annotations (attributes on entity classes) for simple cases, and the Fluent API (code in OnModelCreating or IEntityTypeConfiguration) for complex mappings. The Fluent API is more powerful and keeps entity classes clean — annotations mix persistence concerns into the domain model.

Fluent API Entity Configuration

// ── Post entity ───────────────────────────────────────────────────────────
public class Post
{
    public int       Id          { get; private set; }
    public string    Title       { get; private set; } = string.Empty;
    public string    Slug        { get; private set; } = string.Empty;
    public string    Body        { get; private set; } = string.Empty;
    public string?   Excerpt     { get; private set; }
    public bool      IsPublished { get; private set; }
    public DateTime? PublishedAt { get; private set; }
    public DateTime  CreatedAt   { get; private set; }
    public string    AuthorId    { get; private set; } = string.Empty;

    // Navigation properties
    public User?              Author   { get; private set; }
    public ICollection<Tag>   Tags     { get; private set; } = [];
    public ICollection<Comment> Comments { get; private set; } = [];

    private Post() { }   // required by EF Core for materialisation
    public static Post Create(string title, string slug, string body, string authorId) => new()
    {
        Title = title, Slug = slug, Body = body, AuthorId = authorId,
        CreatedAt = DateTime.UtcNow,
    };
}

// ── IEntityTypeConfiguration — separate config class per entity ───────────
public class PostConfiguration : IEntityTypeConfiguration<Post>
{
    public void Configure(EntityTypeBuilder<Post> entity)
    {
        entity.ToTable("Posts");
        entity.HasKey(p => p.Id);

        entity.Property(p => p.Title)
            .IsRequired()
            .HasMaxLength(200);

        entity.Property(p => p.Slug)
            .IsRequired()
            .HasMaxLength(100);

        entity.Property(p => p.Body)
            .IsRequired()
            .HasColumnType("nvarchar(max)");

        entity.Property(p => p.Excerpt)
            .HasMaxLength(500);

        entity.Property(p => p.CreatedAt)
            .HasDefaultValueSql("GETUTCDATE()");

        // Unique index on Slug
        entity.HasIndex(p => p.Slug)
            .IsUnique()
            .HasDatabaseName("IX_Posts_Slug");

        // Composite index for common query pattern
        entity.HasIndex(p => new { p.IsPublished, p.PublishedAt })
            .HasDatabaseName("IX_Posts_Published_PublishedAt");

        // Enum conversion — store PostStatus as string in database
        entity.Property(p => p.Status)
            .HasConversion<string>()
            .HasMaxLength(20);
    }
}
Note: Using modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly) in OnModelCreating automatically discovers and applies all IEntityTypeConfiguration<T> classes in the assembly. This means when you add a new entity and its configuration class, the configuration is picked up without any manual registration. This pattern scales well in large applications — each entity has its own configuration file rather than a single massive OnModelCreating method.
Tip: Always add a private parameterless constructor to entities used with EF Core if you are using factory methods (like Post.Create()) instead of public constructors. EF Core needs to materialise entities from database rows and uses the parameterless constructor for this. Without it, EF Core throws a runtime exception when loading entities. Mark the constructor private (not public) to prevent accidental direct instantiation in application code.
Warning: Avoid using Data Annotations on domain entities — they couple persistence concerns to the domain model. A Post entity with [MaxLength(200)] on Title mixes EF Core semantics into a class that should be pure domain logic. Use the Fluent API in IEntityTypeConfiguration to keep entity classes clean. Domain entities should only contain domain logic; persistence mapping belongs in the infrastructure layer.

Value Conversions

// ── Enum to string conversion ─────────────────────────────────────────────
public enum PostStatus { Draft, Review, Published, Archived }

entity.Property(p => p.Status)
    .HasConversion<string>()   // stores "Draft", "Published" etc. in DB
    .HasMaxLength(20);

// ── Custom value object conversion ────────────────────────────────────────
public record Money(decimal Amount, string Currency);

entity.Property(p => p.Price)
    .HasConversion(
        v => $"{v.Amount}:{v.Currency}",          // to DB: "9.99:USD"
        v => new Money(decimal.Parse(v.Split(':')[0]), v.Split(':')[1])); // from DB

// ── Owned entity type (value object stored in same table) ─────────────────
public class Address { public string Street { get; set; } = ""; public string City { get; set; } = ""; }

entity.OwnsOne(u => u.Address, a =>
{
    a.Property(x => x.Street).HasColumnName("Address_Street").HasMaxLength(200);
    a.Property(x => x.City).HasColumnName("Address_City").HasMaxLength(100);
});

Common Mistakes

Mistake 1 — Forgetting private parameterless constructor (entity materialisation fails)

❌ Wrong — entity with only public constructor with parameters; EF Core throws at runtime.

✅ Correct — add private Post() { } to any entity with factory methods instead of public constructors.

Mistake 2 — Not adding indexes for frequently queried columns (slow queries at scale)

❌ Wrong — querying by Slug without an index; table scan on every request.

✅ Correct — add HasIndex(p => p.Slug).IsUnique() for all columns used in WHERE clauses.

🧠 Test Yourself

A PostStatus enum is configured with .HasConversion<string>(). What is stored in the database column when the status is PostStatus.Published?