Learnixo
Back to blog
AI Systemsintermediate

EF Core Entity Configurations — Fluent API and IEntityTypeConfiguration

Configure EF Core entities using IEntityTypeConfiguration, fluent API, value converters, owned entity configuration, table naming conventions, and applying configurations automatically.

Asma Hafeez KhanMay 16, 20264 min read
EF CoreEntity FrameworkFluent APIASP.NET Core.NETDatabase
Share:𝕏

Why Fluent API Over Data Annotations

Data Annotations:               Fluent API:
  [Required]                      entity.Property(e => e.Name)
  [MaxLength(200)]                    .IsRequired()
  [Column("patient_name")]            .HasMaxLength(200)
                                      .HasColumnName("patient_name");

Data annotations mix persistence concerns into domain models.
Fluent API keeps configuration separate — domain model stays clean.
Fluent API wins: index config, value converters, owned entities,
  table splitting, complex relationships — all impossible with annotations.

IEntityTypeConfiguration Pattern

C#
// Infrastructure/Persistence/Configurations/PatientConfiguration.cs
public sealed class PatientConfiguration : IEntityTypeConfiguration<Patient>
{
    public void Configure(EntityTypeBuilder<Patient> builder)
    {
        builder.ToTable("patients");

        builder.HasKey(p => p.Id);
        builder.Property(p => p.Id)
            .HasConversion(id => id.Value, value => new PatientId(value));

        builder.Property(p => p.Mrn)
            .IsRequired()
            .HasMaxLength(20)
            .HasColumnName("mrn");

        builder.HasIndex(p => p.Mrn)
            .IsUnique()
            .HasDatabaseName("ix_patients_mrn");

        builder.Property(p => p.FirstName)
            .IsRequired()
            .HasMaxLength(100);

        builder.Property(p => p.LastName)
            .IsRequired()
            .HasMaxLength(100);

        builder.Property(p => p.DateOfBirth)
            .HasColumnType("date");

        builder.Property(p => p.IsDeleted)
            .HasDefaultValue(false);

        builder.HasQueryFilter(p => !p.IsDeleted);
    }
}

Applying Configurations Automatically

C#
// ApplicationDbContext.cs
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // Apply all IEntityTypeConfiguration<T> in the assembly automatically
    modelBuilder.ApplyConfigurationsFromAssembly(
        typeof(ApplicationDbContext).Assembly);
}

// No need to register each configuration manually.
// EF Core scans the assembly and applies every class
// that implements IEntityTypeConfiguration<T>.

Value Object Converters

C#
// Domain/ValueObjects/PatientMrn.cs
public sealed record PatientMrn(string Value)
{
    public static PatientMrn Create(string value)
    {
        if (string.IsNullOrWhiteSpace(value) || value.Length > 20)
            throw new ArgumentException("Invalid MRN");
        return new PatientMrn(value.ToUpperInvariant());
    }
}

// Configuration: convert PatientMrn <-> string
builder.Property(p => p.Mrn)
    .HasConversion(
        mrn   => mrn.Value,
        value => PatientMrn.Create(value))
    .HasMaxLength(20)
    .HasColumnName("mrn");

// For dosage value objects
public sealed record DosageValue(decimal Amount, string Unit);

builder.Property(p => p.Dose)
    .HasConversion(
        d     => $"{d.Amount}|{d.Unit}",
        value =>
        {
            var parts = value.Split('|');
            return new DosageValue(decimal.Parse(parts[0]), parts[1]);
        })
    .HasMaxLength(50);

Owned Entity Configuration

C#
// Address is a value object — owned by Patient, no separate table key
public class Address
{
    public string Street  { get; private set; } = default!;
    public string City    { get; private set; } = default!;
    public string Country { get; private set; } = default!;
    public string PostCode { get; private set; } = default!;
}

// In PatientConfiguration:
builder.OwnsOne(p => p.Address, address =>
{
    address.Property(a => a.Street)
        .HasColumnName("address_street")
        .HasMaxLength(200);

    address.Property(a => a.City)
        .HasColumnName("address_city")
        .HasMaxLength(100);

    address.Property(a => a.Country)
        .HasColumnName("address_country")
        .HasMaxLength(100);

    address.Property(a => a.PostCode)
        .HasColumnName("address_postcode")
        .HasMaxLength(10);
});

// Result: address columns are part of the patients table (no join needed)

Table Naming Conventions

C#
// Global convention: all tables use snake_case plural names
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);

    // Apply snake_case naming convention to any entity without explicit ToTable()
    foreach (var entity in modelBuilder.Model.GetEntityTypes())
    {
        var tableName = entity.GetTableName();
        if (tableName is not null)
            entity.SetTableName(ToSnakeCase(tableName));

        foreach (var property in entity.GetProperties())
        {
            var columnName = property.GetColumnName();
            property.SetColumnName(ToSnakeCase(columnName));
        }
    }
}

private static string ToSnakeCase(string name)
{
    // PascalCase → snake_case
    return Regex.Replace(name, "([a-z])([A-Z])", "$1_$2").ToLowerInvariant();
}

Enum Storage

C#
public enum PatientStatus { Active, Discharged, Deceased }

// Option A: store as string (readable, migration-safe)
builder.Property(p => p.Status)
    .HasConversion<string>()
    .HasMaxLength(20);

// Option B: store as int (compact, requires discipline)
builder.Property(p => p.Status)
    .HasConversion<int>();

// Prefer string storage — an integer enum value is meaningless in a raw SQL query.
// "Active" is self-documenting; 0 is not.

Index Configuration

C#
// Single column unique index
builder.HasIndex(p => p.Mrn)
    .IsUnique()
    .HasDatabaseName("ix_patients_mrn");

// Composite index: covering index for common query
builder.HasIndex(p => new { p.LastName, p.DateOfBirth })
    .HasDatabaseName("ix_patients_lastname_dob");

// Filtered index: only index active patients
builder.HasIndex(p => p.Mrn)
    .HasFilter("is_deleted = 0")
    .HasDatabaseName("ix_patients_mrn_active");

// Covering index (.NET 8+)
builder.HasIndex(p => p.LastName)
    .IncludeProperties(p => new { p.FirstName, p.Mrn })
    .HasDatabaseName("ix_patients_lastname_covering");

Red Flag / Green Answer

Red Flag: "I put [Required] and [MaxLength] on all my entity properties — that handles configuration."

Data annotations don't support value converters, owned entities, filtered indexes, table splitting, global query filters, or shadow properties. They also leak persistence concerns into your domain model. Any non-trivial EF Core project needs fluent API for at least some configuration.

Green Answer:

Use IEntityTypeConfiguration<T> per entity, applied via ApplyConfigurationsFromAssembly(). Keep domain models annotation-free. Use value converters for value objects and enums. Use OwnsOne() for value objects that don't need their own table.


Key Takeaway

IEntityTypeConfiguration<T> separates persistence configuration from domain models. Apply all configurations with ApplyConfigurationsFromAssembly(). Use value converters to map value objects to columns. Use OwnsOne() for owned entities — they share the parent table with no join. Store enums as strings for readability. Define indexes explicitly rather than relying on EF Core conventions.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.