Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Database Mapping and EF Core

"Every team that adopts EF Core eventually writes a wiki page explaining their naming conventions. Then they write tests to enforce them. Then they discover the wiki is wrong. Then they fix the wiki. Then they discover the tests are wrong too."


The Domain That Reveals Everything

Database mapping is where Convention's double cost is most painful. Every ORM has conventions — table naming, column naming, relationship discovery, cascade behavior, value object storage. These conventions work beautifully for simple cases and become a documentation nightmare for real domain models.

The EF Core conventions page in Microsoft's documentation is 47 sections long. Your team's internal wiki explaining which conventions you follow, which you override, and why — that's another 20+ pages. The architecture tests enforcing that your entities follow your conventions — that's another 200+ lines of test code.

All of this exists because the ORM doesn't know your domain. It guesses. Convention is guessing with rules. Contention is telling the compiler exactly what you mean.


Era 1: Code — Raw ADO.NET

In the beginning, there was SQL. Written by hand. Mapped by hand. Maintained by hand.

public class OrderRepository
{
    private readonly string _connectionString;

    public OrderRepository(string connectionString)
    {
        _connectionString = connectionString;
    }

    public async Task<Order?> GetByIdAsync(Guid id)
    {
        using var connection = new SqlConnection(_connectionString);
        await connection.OpenAsync();

        // Query 1: Get the order
        using var orderCmd = new SqlCommand(
            "SELECT Id, CustomerId, TotalAmount, CreatedDate, Status " +
            "FROM Orders WHERE Id = @Id", connection);
        orderCmd.Parameters.AddWithValue("@Id", id);

        Order? order = null;
        using (var reader = await orderCmd.ExecuteReaderAsync())
        {
            if (await reader.ReadAsync())
            {
                order = new Order
                {
                    Id = reader.GetGuid(0),
                    CustomerId = reader.GetGuid(1),
                    TotalAmount = reader.GetDecimal(2),
                    CreatedDate = reader.GetDateTime(3),
                    Status = (OrderStatus)reader.GetInt32(4)
                };
            }
        }

        if (order == null) return null;

        // Query 2: Get the line items
        using var itemsCmd = new SqlCommand(
            "SELECT Id, OrderId, ProductId, Quantity, UnitPrice " +
            "FROM LineItems WHERE OrderId = @OrderId", connection);
        itemsCmd.Parameters.AddWithValue("@OrderId", id);

        var items = new List<LineItem>();
        using (var reader = await itemsCmd.ExecuteReaderAsync())
        {
            while (await reader.ReadAsync())
            {
                items.Add(new LineItem
                {
                    Id = reader.GetGuid(0),
                    OrderId = reader.GetGuid(1),
                    ProductId = reader.GetGuid(2),
                    Quantity = reader.GetInt32(3),
                    UnitPrice = reader.GetDecimal(4)
                });
            }
        }

        order.LineItems = items;
        return order;
    }

    public async Task AddAsync(Order order)
    {
        using var connection = new SqlConnection(_connectionString);
        await connection.OpenAsync();
        using var transaction = connection.BeginTransaction();

        try
        {
            // Insert order
            using var orderCmd = new SqlCommand(
                "INSERT INTO Orders (Id, CustomerId, TotalAmount, CreatedDate, Status) " +
                "VALUES (@Id, @CustomerId, @TotalAmount, @CreatedDate, @Status)",
                connection, transaction);
            orderCmd.Parameters.AddWithValue("@Id", order.Id);
            orderCmd.Parameters.AddWithValue("@CustomerId", order.CustomerId);
            orderCmd.Parameters.AddWithValue("@TotalAmount", order.TotalAmount);
            orderCmd.Parameters.AddWithValue("@CreatedDate", order.CreatedDate);
            orderCmd.Parameters.AddWithValue("@Status", (int)order.Status);
            await orderCmd.ExecuteNonQueryAsync();

            // Insert each line item
            foreach (var item in order.LineItems)
            {
                using var itemCmd = new SqlCommand(
                    "INSERT INTO LineItems (Id, OrderId, ProductId, Quantity, UnitPrice) " +
                    "VALUES (@Id, @OrderId, @ProductId, @Quantity, @UnitPrice)",
                    connection, transaction);
                itemCmd.Parameters.AddWithValue("@Id", item.Id);
                itemCmd.Parameters.AddWithValue("@OrderId", order.Id);
                itemCmd.Parameters.AddWithValue("@ProductId", item.ProductId);
                itemCmd.Parameters.AddWithValue("@Quantity", item.Quantity);
                itemCmd.Parameters.AddWithValue("@UnitPrice", item.UnitPrice);
                await itemCmd.ExecuteNonQueryAsync();
            }

            await transaction.CommitAsync();
        }
        catch
        {
            await transaction.RollbackAsync();
            throw;
        }
    }

    // UpdateAsync: another 50 lines
    // DeleteAsync: another 30 lines
    // SearchAsync: another 40 lines
    // Per aggregate root: ~250 lines of ADO.NET boilerplate
}

What goes wrong:

  • Rename a column? Find and replace across every SQL string. Miss one? Runtime SqlException.
  • Add a property? Update every SELECT, every INSERT, every mapping line. Miss one? Silent data loss.
  • Change a relationship? Rewrite the join queries. Rewrite the transaction logic.
  • 50 aggregate roots × 250 lines = 12,500 lines of handwritten data access code.

Every line is a potential bug. Every SQL string is invisible to the compiler. Every mapping is manual and drift-prone.


Era 2: Configuration — NHibernate XML

NHibernate separated the mapping from the code — into XML files. One XML file per entity. Every property, every relationship, every cascade rule spelled out in angle brackets.

<?xml version="1.0" encoding="utf-8" ?>
<hibernate-mapping xmlns="urn:nhibernate-mapping-2.2"
                   namespace="MyApp.Domain" assembly="MyApp.Domain">

  <class name="Order" table="Orders" lazy="true">
    <id name="Id" column="Id" type="Guid">
      <generator class="guid.comb" />
    </id>

    <property name="CustomerId" column="CustomerId" type="Guid" not-null="true" />
    <property name="TotalAmount" column="TotalAmount" type="Decimal" precision="18" scale="2" />
    <property name="CreatedDate" column="CreatedDate" type="DateTime" not-null="true" />
    <property name="Status" column="Status" type="Int32" not-null="true" />

    <bag name="LineItems" cascade="all-delete-orphan" inverse="true" lazy="true">
      <key column="OrderId" />
      <one-to-many class="LineItem" />
    </bag>
  </class>

  <class name="LineItem" table="LineItems" lazy="true">
    <id name="Id" column="Id" type="Guid">
      <generator class="guid.comb" />
    </id>

    <property name="OrderId" column="OrderId" type="Guid" not-null="true" />
    <property name="ProductId" column="ProductId" type="Guid" not-null="true" />
    <property name="Quantity" column="Quantity" type="Int32" not-null="true" />
    <property name="UnitPrice" column="UnitPrice" type="Decimal" precision="18" scale="2" />

    <many-to-one name="Order" class="Order" column="OrderId" insert="false" update="false" />
  </class>

</hibernate-mapping>
// NHibernate session configuration
var config = new Configuration()
    .SetProperty(NHibernate.Cfg.Environment.ConnectionString, connectionString)
    .SetProperty(NHibernate.Cfg.Environment.Dialect, "NHibernate.Dialect.MsSql2012Dialect")
    .AddFile("Mappings/Order.hbm.xml")
    .AddFile("Mappings/LineItem.hbm.xml")
    .AddFile("Mappings/Customer.hbm.xml")
    .AddFile("Mappings/Product.hbm.xml");
    // ... one line per entity. Miss one? Silent mapping failure.

var sessionFactory = config.BuildSessionFactory();

What goes wrong:

  • Rename a property in C#? The XML still references the old name. Runtime MappingException.
  • Add a new entity? Create an XML file, register it in the configuration. Forget the registration? Entity is unmapped. Discovered at runtime.
  • No IntelliSense in XML. No refactoring support. No compile-time validation.
  • The XML and the C# are two separate truth sources. They drift. They always drift.
Diagram

Three truth sources. Three places to update. Three places to make mistakes. Zero compiler help.


Era 3: Convention — EF Core

Entity Framework Core was a revolution. Conventions replaced most of the XML. If you follow the naming rules, EF figures out the mapping automatically.

public class AppDbContext : DbContext
{
    public DbSet<Order> Orders { get; set; } = null!;
    public DbSet<LineItem> LineItems { get; set; } = null!;
    public DbSet<Customer> Customers { get; set; } = null!;
    public DbSet<Product> Products { get; set; } = null!;

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Convention: Order maps to "Orders" table (pluralized DbSet name)
        // Convention: Properties map to columns by name
        // Convention: Navigation properties discovered automatically
        // Convention: Required relationships cascade delete

        // But some things need explicit configuration:
        modelBuilder.Entity<Order>(e =>
        {
            e.Property(o => o.TotalAmount).HasPrecision(18, 2);
            e.HasMany(o => o.LineItems)
                .WithOne()
                .HasForeignKey(li => li.OrderId)
                .OnDelete(DeleteBehavior.Cascade);
            e.Navigation(o => o.LineItems).AutoInclude();
        });
    }
}

The conventions handle 80% of the mapping. The remaining 20% requires explicit configuration in OnModelCreating. This is a massive improvement over XML.

But now the team needs to agree on conventions. Which conventions do we follow? Which do we override? How do we handle value objects? What about strongly-typed IDs? What cascade behavior do we use?

The Documentation

<!-- wiki/ef-core-conventions.md — the document every team writes -->

# EF Core Conventions and Standards

## Table Naming
- Tables are named after the `DbSet<T>` property (pluralized)
- Exception: join tables for many-to-many use `{Entity1}{Entity2}` format
- Override with `[Table("custom_name")]` only when matching legacy schemas

## Column Naming
- Columns match property names exactly (PascalCase)
- Exception: database uses snake_case → configure in OnModelCreating
- Foreign key columns: `{NavigationProperty}Id` (e.g., `CustomerId`)

## Strongly-Typed IDs
- All entities use strongly-typed IDs (e.g., `OrderId` not `Guid`)
- Value converters registered globally in OnModelCreating:
  ```csharp
  modelBuilder.Entity<Order>().Property(o => o.Id)
      .HasConversion(id => id.Value, v => new OrderId(v));
  • Convention: converter registration is in the entity configuration, not global

Value Objects

  • Value objects are marked with [Owned] attribute
  • Stored inline in the parent table (no separate table)
  • Column prefix: {PropertyName}_{ValueObjectProperty}
  • Example: Address.Street → column ShippingAddress_Street

Relationships

  • Required relationships: cascade delete (default)
  • Optional relationships: set null on delete
  • Collections: use IReadOnlyCollection<T> with private backing field
  • Navigation property auto-include: only for aggregate root → child entities

Aggregate Root Rules

  • Only aggregate roots have DbSet<T> properties
  • Child entities are accessed through the aggregate root only
  • No repository for child entities — only for aggregate roots
  • Aggregate roots must not reference other aggregate roots by navigation property (use ID references: CustomerId not Customer)

Repository Pattern

  • One repository per aggregate root
  • Interface: IXxxRepository in Domain project
  • Implementation: XxxRepository in Infrastructure project
  • Registration: Scoped lifetime via assembly scanning

Migrations

  • One migration per feature branch
  • Migration name: {YYYYMMDD}_{FeatureName} (e.g., 20260329_AddOrderStatus)
  • Seed data in separate migration, not in entity configuration
  • Always review generated migration SQL before applying

Performance

  • Use AsNoTracking() for read-only queries
  • Use Include() explicitly, don't rely on lazy loading
  • Use projection (Select()) for list queries
  • Configure query splitting for collections: .AsSplitQuery()

That's **65 lines** of documentation. And it's already partially outdated because last sprint the team decided to use `snake_case` for a new microservice, and nobody updated the wiki.

### The Enforcement Code

```csharp
// tests/ArchitectureTests/EfConventionTests.cs

public class EfConventionTests
{
    private readonly Assembly _domainAssembly = typeof(Order).Assembly;
    private readonly Assembly _infrastructureAssembly = typeof(AppDbContext).Assembly;

    [Fact]
    public void Aggregate_Roots_Must_Have_StronglyTyped_Ids()
    {
        var aggregateRoots = _domainAssembly.GetTypes()
            .Where(t => !t.IsAbstract && t.BaseType?.Name == "Entity`1");

        foreach (var root in aggregateRoots)
        {
            var idType = root.BaseType!.GetGenericArguments()[0];
            idType.Should().NotBe(typeof(Guid),
                $"Aggregate root {root.Name} must use a strongly-typed ID, not Guid");
            idType.Should().NotBe(typeof(int),
                $"Aggregate root {root.Name} must use a strongly-typed ID, not int");
        }
    }

    [Fact]
    public void Aggregate_Roots_Must_Not_Have_Public_Setters()
    {
        var aggregateRoots = _domainAssembly.GetTypes()
            .Where(t => !t.IsAbstract && t.BaseType?.Name == "Entity`1");

        foreach (var root in aggregateRoots)
        {
            var publicSetters = root.GetProperties(BindingFlags.Public | BindingFlags.Instance)
                .Where(p => p.SetMethod?.IsPublic == true
                    && p.Name != "Id"
                    && !p.SetMethod.ReturnParameter.GetRequiredCustomModifiers()
                        .Contains(typeof(System.Runtime.CompilerServices.IsExternalInit)));

            publicSetters.Should().BeEmpty(
                $"Aggregate root {root.Name} must not have public setters " +
                $"(found: {string.Join(", ", publicSetters.Select(p => p.Name))})");
        }
    }

    [Fact]
    public void Aggregate_Roots_Must_Not_Reference_Other_Aggregates_By_Navigation()
    {
        var aggregateRootTypes = _domainAssembly.GetTypes()
            .Where(t => !t.IsAbstract && t.BaseType?.Name == "Entity`1")
            .ToList();

        foreach (var root in aggregateRootTypes)
        {
            var navigationProperties = root.GetProperties()
                .Where(p => aggregateRootTypes.Contains(p.PropertyType)
                    && p.PropertyType != root);

            navigationProperties.Should().BeEmpty(
                $"Aggregate root {root.Name} references aggregate root " +
                $"{string.Join(", ", navigationProperties.Select(p => p.PropertyType.Name))} " +
                $"by navigation. Use ID reference instead.");
        }
    }

    [Fact]
    public void Collections_Must_Be_IReadOnlyCollection()
    {
        var aggregateRoots = _domainAssembly.GetTypes()
            .Where(t => !t.IsAbstract && t.BaseType?.Name == "Entity`1");

        foreach (var root in aggregateRoots)
        {
            var collectionProperties = root.GetProperties()
                .Where(p => p.PropertyType.IsGenericType
                    && typeof(IEnumerable<>).IsAssignableFrom(
                        p.PropertyType.GetGenericTypeDefinition())
                    && p.PropertyType != typeof(string));

            foreach (var prop in collectionProperties)
            {
                var genericDef = prop.PropertyType.GetGenericTypeDefinition();
                genericDef.Should().Be(typeof(IReadOnlyCollection<>),
                    $"{root.Name}.{prop.Name} must be IReadOnlyCollection<T>, " +
                    $"not {prop.PropertyType.Name}");
            }
        }
    }

    [Fact]
    public void Every_Aggregate_Root_Must_Have_Repository_Interface()
    {
        var aggregateRoots = _domainAssembly.GetTypes()
            .Where(t => !t.IsAbstract && t.BaseType?.Name == "Entity`1");

        var repoInterfaces = _domainAssembly.GetTypes()
            .Where(t => t.IsInterface && t.Name.EndsWith("Repository"))
            .Select(t => t.Name)
            .ToList();

        foreach (var root in aggregateRoots)
        {
            var expectedRepo = $"I{root.Name}Repository";
            repoInterfaces.Should().Contain(expectedRepo,
                $"Aggregate root {root.Name} must have a repository interface {expectedRepo}");
        }
    }

    [Fact]
    public void Every_Repository_Interface_Must_Have_Implementation()
    {
        var repoInterfaces = _domainAssembly.GetTypes()
            .Where(t => t.IsInterface && t.Name.EndsWith("Repository"));

        var repoImplementations = _infrastructureAssembly.GetTypes()
            .Where(t => !t.IsAbstract && !t.IsInterface)
            .SelectMany(t => t.GetInterfaces(), (t, i) => new { Type = t, Interface = i })
            .ToList();

        foreach (var repoInterface in repoInterfaces)
        {
            repoImplementations.Should().Contain(
                x => x.Interface == repoInterface,
                $"Repository interface {repoInterface.Name} must have an implementation " +
                $"in {_infrastructureAssembly.GetName().Name}");
        }
    }

    [Fact]
    public void Value_Objects_Must_Have_Owned_Attribute()
    {
        var valueObjects = _domainAssembly.GetTypes()
            .Where(t => t.IsSubclassOf(typeof(ValueObject)));

        foreach (var vo in valueObjects)
        {
            vo.GetCustomAttributes(typeof(OwnedAttribute), false)
                .Should().NotBeEmpty(
                    $"Value object {vo.Name} must have [Owned] attribute for EF Core mapping");
        }
    }

    [Fact]
    public void DbContext_Should_Only_Have_DbSets_For_Aggregate_Roots()
    {
        var dbSetProperties = typeof(AppDbContext).GetProperties()
            .Where(p => p.PropertyType.IsGenericType
                && p.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>));

        var aggregateRootTypes = _domainAssembly.GetTypes()
            .Where(t => !t.IsAbstract && t.BaseType?.Name == "Entity`1")
            .ToHashSet();

        foreach (var dbSetProp in dbSetProperties)
        {
            var entityType = dbSetProp.PropertyType.GetGenericArguments()[0];
            aggregateRootTypes.Should().Contain(entityType,
                $"DbSet<{entityType.Name}> exists but {entityType.Name} is not an aggregate root. " +
                $"Only aggregate roots should have DbSet properties.");
        }
    }
}

That's 130 lines of architecture tests. Tests that:

  • Use reflection (fragile across refactors)
  • Run at test time, not compile time
  • Must be maintained alongside the wiki (which says the same rules in prose)
  • Produce failures like "aggregate root Order has public setter" without telling you which setter or how to fix it
  • Must be updated every time the team changes a convention

The Convention Tax for Database Mapping

📄 wiki/ef-core-conventions.md              65 lines    (documentation)
📄 tests/EfConventionTests.cs              130 lines    (enforcement)
📄 DbContext + OnModelCreating               30 lines    (actual config)
📄 Repository implementations (per root)     40 lines    (actual code)
📄 Repository interface (per root)           10 lines    (actual code)
──────────────────────────────────────────────────────
Total per aggregate root:                  ~275 lines
Of which convention overhead:              ~195 lines   (71% overhead)

For 50 aggregate roots: 9,750 lines of convention overhead — documentation and tests that exist solely because the compiler doesn't know what an aggregate root is.


Era 4: Contention — [AggregateRoot] DSL

In the Contention era, the developer writes domain code with attributes. The Source Generator reads the attributes and generates everything else. The analyzer enforces DDD rules at compile time.

What the Developer Writes

using Cmf.Ddd;

[AggregateRoot]
public class Order : Entity<OrderId>
{
    public CustomerId CustomerId { get; private set; }

    [Money(precision: 18, scale: 2)]
    public decimal TotalAmount { get; private set; }

    public OrderStatus Status { get; private set; }

    public DateTime CreatedDate { get; private set; }

    private readonly List<LineItem> _lineItems = new();

    [HasMany(cascade: CascadeType.AllDeleteOrphan, autoInclude: true)]
    public IReadOnlyCollection<LineItem> LineItems => _lineItems.AsReadOnly();

    private Order() { } // EF constructor

    public Order(OrderId id, CustomerId customerId) : base(id)
    {
        CustomerId = customerId;
        Status = OrderStatus.Draft;
        CreatedDate = DateTime.UtcNow;
    }

    public void AddLineItem(ProductId productId, int quantity, decimal unitPrice)
    {
        var item = new LineItem(LineItemId.New(), Id, productId, quantity, unitPrice);
        _lineItems.Add(item);
        RecalculateTotal();
    }

    public void Submit()
    {
        if (!_lineItems.Any())
            throw new DomainException("Cannot submit an empty order");
        Status = OrderStatus.Submitted;
    }

    private void RecalculateTotal()
    {
        TotalAmount = _lineItems.Sum(li => li.Total);
    }
}

[Entity]
public class LineItem : Entity<LineItemId>
{
    public OrderId OrderId { get; private set; }
    public ProductId ProductId { get; private set; }
    public int Quantity { get; private set; }

    [Money(precision: 18, scale: 2)]
    public decimal UnitPrice { get; private set; }

    public decimal Total => Quantity * UnitPrice;

    private LineItem() { }

    public LineItem(LineItemId id, OrderId orderId, ProductId productId,
        int quantity, decimal unitPrice) : base(id)
    {
        OrderId = orderId;
        ProductId = productId;
        Quantity = quantity;
        UnitPrice = unitPrice;
    }
}

[ValueObject]
public record Money(decimal Amount, string Currency);

[ValueObject]
public record Address(string Street, string City, string PostalCode, string Country);

That's it. The developer writes domain code. No EF configuration. No repository. No factory. No DI registration. The attributes declare the DDD concepts. The Source Generator does the rest.

What the Source Generator Produces

// ── Generated: OrderEntityConfiguration.g.cs ──
// <auto-generated />
[GeneratedCode("Cmf.Ddd.Generators", "1.0.0")]
internal sealed class OrderEntityConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.ToTable("Orders");

        // Primary key with strongly-typed ID conversion
        builder.HasKey(e => e.Id);
        builder.Property(e => e.Id)
            .HasConversion(id => id.Value, v => new OrderId(v))
            .ValueGeneratedNever();

        // Properties with strongly-typed ID conversions
        builder.Property(e => e.CustomerId)
            .IsRequired()
            .HasConversion(id => id.Value, v => new CustomerId(v));

        // [Money] attribute → precision/scale configuration
        builder.Property(e => e.TotalAmount)
            .HasPrecision(18, 2);

        builder.Property(e => e.Status)
            .HasConversion<int>();

        builder.Property(e => e.CreatedDate)
            .IsRequired();

        // [HasMany] attribute → relationship configuration
        builder.HasMany(typeof(LineItem), "_lineItems")
            .WithOne()
            .HasForeignKey(nameof(LineItem.OrderId))
            .OnDelete(DeleteBehavior.Cascade);

        // autoInclude: true from [HasMany] attribute
        builder.Navigation("_lineItems").AutoInclude();
    }
}

// ── Generated: LineItemEntityConfiguration.g.cs ──
[GeneratedCode("Cmf.Ddd.Generators", "1.0.0")]
internal sealed class LineItemEntityConfiguration : IEntityTypeConfiguration<LineItem>
{
    public void Configure(EntityTypeBuilder<LineItem> builder)
    {
        builder.ToTable("LineItems");

        builder.HasKey(e => e.Id);
        builder.Property(e => e.Id)
            .HasConversion(id => id.Value, v => new LineItemId(v))
            .ValueGeneratedNever();

        builder.Property(e => e.OrderId)
            .IsRequired()
            .HasConversion(id => id.Value, v => new OrderId(v));

        builder.Property(e => e.ProductId)
            .IsRequired()
            .HasConversion(id => id.Value, v => new ProductId(v));

        builder.Property(e => e.UnitPrice)
            .HasPrecision(18, 2);

        // Computed property — ignore in EF
        builder.Ignore(e => e.Total);
    }
}

// ── Generated: IOrderRepository.g.cs ──
[GeneratedCode("Cmf.Ddd.Generators", "1.0.0")]
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct = default);
    Task<IReadOnlyList<Order>> GetAllAsync(CancellationToken ct = default);
    Task AddAsync(Order entity, CancellationToken ct = default);
    Task UpdateAsync(Order entity, CancellationToken ct = default);
    Task DeleteAsync(OrderId id, CancellationToken ct = default);
    Task<bool> ExistsAsync(OrderId id, CancellationToken ct = default);
}

// ── Generated: OrderRepository.g.cs ──
[GeneratedCode("Cmf.Ddd.Generators", "1.0.0")]
internal sealed class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _context;

    public OrderRepository(AppDbContext context) => _context = context;

    public async Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct)
        => await _context.Set<Order>().FirstOrDefaultAsync(e => e.Id == id, ct);

    public async Task<IReadOnlyList<Order>> GetAllAsync(CancellationToken ct)
        => await _context.Set<Order>().ToListAsync(ct);

    public async Task AddAsync(Order entity, CancellationToken ct)
    {
        await _context.Set<Order>().AddAsync(entity, ct);
        await _context.SaveChangesAsync(ct);
    }

    public async Task UpdateAsync(Order entity, CancellationToken ct)
    {
        _context.Set<Order>().Update(entity);
        await _context.SaveChangesAsync(ct);
    }

    public async Task DeleteAsync(OrderId id, CancellationToken ct)
    {
        var entity = await GetByIdAsync(id, ct);
        if (entity is not null)
        {
            _context.Set<Order>().Remove(entity);
            await _context.SaveChangesAsync(ct);
        }
    }

    public async Task<bool> ExistsAsync(OrderId id, CancellationToken ct)
        => await _context.Set<Order>().AnyAsync(e => e.Id == id, ct);
}

// ── Generated: AppDbContext.g.cs (partial) ──
[GeneratedCode("Cmf.Ddd.Generators", "1.0.0")]
public partial class AppDbContext
{
    // DbSet for aggregate roots only (not for child entities)
    public DbSet<Order> Orders { get; set; } = null!;

    partial void OnModelCreatingGenerated(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfiguration(new OrderEntityConfiguration());
        modelBuilder.ApplyConfiguration(new LineItemEntityConfiguration());
    }
}

// ── Generated: DomainServiceRegistration.g.cs (partial) ──
[GeneratedCode("Cmf.Ddd.Generators", "1.0.0")]
public static partial class DomainServiceRegistration
{
    static partial void AddOrderServices(IServiceCollection services)
    {
        services.AddScoped<IOrderRepository, OrderRepository>();
    }
}

// ── Generated: OrderFactory.g.cs ──
[GeneratedCode("Cmf.Ddd.Generators", "1.0.0")]
public static class OrderFactory
{
    public static Order Create(CustomerId customerId)
        => new Order(OrderId.New(), customerId);
}

What the Analyzer Enforces

The Roslyn Analyzer runs as you type — red squiggles in the IDE, compile errors in CI.

// DDD001: Public setter on aggregate root
error DDD001: Property 'Order.Status' has a public setter.
  Aggregate roots must encapsulate state. Use 'private set' or 'init'.
  Location: Order.cs(8,12)

// DDD002: Missing DDD attribute
warning DDD002: Class 'Payment' inherits Entity<PaymentId> but has no
  [AggregateRoot] or [Entity] attribute. Add an attribute to enable code generation.
  Location: Payment.cs(3,14)

// DDD003: Cross-aggregate navigation
error DDD003: Property 'Order.Customer' references aggregate root 'Customer'
  by navigation. Aggregate roots must reference each other by ID.
  Use 'CustomerId' (which you already have) instead of 'Customer'.
  Location: Order.cs(15,20)

// DDD004: Mutable collection
error DDD004: Property 'Order.LineItems' returns List<LineItem>.
  Collections on aggregate roots must use IReadOnlyCollection<T>
  with a private backing field. Example:
    private readonly List<LineItem> _lineItems = new();
    [HasMany(...)]
    public IReadOnlyCollection<LineItem> LineItems => _lineItems.AsReadOnly();
  Location: Order.cs(22,5)

// DDD005: DbSet for non-aggregate
error DDD005: DbSet<LineItem> exists in AppDbContext but LineItem is not
  marked [AggregateRoot]. Only aggregate roots should have DbSet properties.
  Child entities are accessed through their parent aggregate.
  Location: AppDbContext.cs(5,12)

// DDD006: Missing value converter
warning DDD006: Property 'Order.CustomerId' uses strongly-typed ID 'CustomerId'
  but no value converter is configured. The [AggregateRoot] source generator
  handles this automatically — this warning indicates the generator may not
  have run. Rebuild the project.
  Location: Order.cs(4,12)

Notice what the analyzer does that convention tests cannot:

  1. Points to the exact line and column — not "some property in some class"
  2. Explains what's wrong AND how to fix it — not just "convention violated"
  3. Runs as you type — not after you save, build, and run tests
  4. Cannot be skipped — it's a compile error, not a test you can [Skip]

The Convention Tax — Visualized

Diagram
Artifact Convention Contention
Documentation 65 lines (wiki) 0 — [AggregateRoot] is self-documenting
Enforcement 130 lines (tests) 0 — DDD001-DDD006 analyzers at compile time
EF Configuration 30 lines per entity 0 — SG generates from attributes
Repository interface 10 lines per root 0 — SG generates
Repository implementation 40 lines per root 0 — SG generates
Factory 10 lines per root 0 — SG generates
DI registration 1 line per root 0 — SG generates
Developer writes per root ~90 lines + 195 shared Domain class + attributes
Convention overhead 195 lines (shared) + 91 per root 0 lines

For 50 aggregate roots:

  • Convention: 195 shared + (91 × 50) = 4,745 lines of convention overhead
  • Contention: 0 lines of overhead. 50 domain classes with [AggregateRoot]. Done.

What Happens When You Add a New Aggregate Root?

Diagram

Convention requires 9 steps across 6 files plus wiki updates. Forget step 5 (DbSet)? The entity is unmapped. Forget step 6 (DI)? The repository throws at runtime. Forget step 7 (wiki)? The next developer doesn't know Payment exists.

Contention requires 1 step: create the class with [AggregateRoot]. Build. The SG generates the other 5 files. The analyzer ensures the class follows DDD rules. The wiki is unnecessary because the attribute IS the documentation.


The Meta-Pattern

This part demonstrated the pattern that recurs in every domain:

  1. Convention requires three synchronized artifacts: documentation, enforcement code, and implementation
  2. Contention requires one artifact: the attribute. The SG generates the implementation. The analyzer provides the enforcement.
  3. The savings multiply with scale: 50 aggregates × 91 lines of per-root overhead = 4,550 lines of code that Convention requires and Contention eliminates

The DDD domain is the most dramatic example because aggregate roots generate the most boilerplate: EF configuration, repository interface, repository implementation, factory, DI registration — all from a single [AggregateRoot] attribute.

But the pattern is the same everywhere. The next parts will show it for testing, architecture enforcement, configuration, error handling, and more.

This is the core insight of the CMF DDD DSL: the domain model IS the source of truth. Everything else is derived. If it can be derived, it should be generated — not written by hand and not policed by convention tests.


What the Convention Era Gets Right

Convention is not wrong. EF Core's conventions are excellent defaults. The problem is not the conventions themselves — it's the gap between the convention and the compiler.

EF Core knows that DbSet<Order> means "Order maps to a table." But it doesn't know that Order is an aggregate root. It doesn't know that aggregate roots shouldn't have public setters. It doesn't know that child entities shouldn't have their own DbSet. Those rules exist in wiki pages and test suites — not in the type system.

Contention closes the gap. [AggregateRoot] tells the compiler what EF Core's conventions cannot express. The SG generates what the conventions imply. The analyzer enforces what the conventions describe in prose.

Convention over Configuration was progress. Contention over Convention is the destination — at least for domains where the overhead of documentation and enforcement exceeds the cost of building a Source Generator.

For database mapping and DDD, that threshold was crossed long ago. The From Mud to DDD series shows just how much convention overhead accumulates in a real brownfield migration.


Next: Part VI: Testing and Requirements — where the Convention Tax is most visible, because every test must be linked to a requirement, and the only way to enforce that link is either a naming convention or a type system.