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

Composition, Ownership, and Persistence

DDD composition semantics drive EF Core mapping. The three relationship types -- Composition, Association, Aggregation -- each produce specific EF Core configurations. Optional [Table], [Column], and [Index] attributes override conventions when needed.

The Three Relationship Types

[MetaConcept("Composition")]
[AttributeUsage(AttributeTargets.Property)]
public sealed class CompositionAttribute : Attribute { }

[MetaConcept("Association")]
[AttributeUsage(AttributeTargets.Property)]
public sealed class AssociationAttribute : Attribute { }

[MetaConcept("Aggregation")]
[AttributeUsage(AttributeTargets.Property)]
public sealed class AggregationAttribute : Attribute { }

[Composition] (filled diamond) -- lifecycle ownership. The parent controls the child's existence. What the generator produces depends on the target type and the property's multiplicity:

Property Type Target Type Generated EF Core
T [ValueObject] OwnsOne<T>() (embedded in parent table)
IReadOnlyList<T> [ValueObject] OwnsMany<T>() (embedded collection)
T [Entity] HasOne<T>().IsRequired().OnDelete(Cascade)
IReadOnlyList<T> [Entity] HasMany<T>().OnDelete(Cascade)

[Association] -- cross-aggregate reference. No lifecycle control. Aggregates are independent consistency boundaries, so associations never cascade:

Property Type Generated EF Core
T HasOne<T>().WithMany().OnDelete(SetNull)
T? HasOne<T>().WithMany().IsRequired(false).OnDelete(SetNull)
IReadOnlyList<T> HasMany<T>().WithMany() (join table auto-generated)

[Aggregation] (empty diamond) -- weak ownership. There is an ownership direction, but the child survives parent deletion:

Property Type Generated EF Core
T HasOne<T>().WithMany().OnDelete(SetNull)
IReadOnlyList<T> HasMany<T>().WithOne().OnDelete(SetNull)

Compile-Time Ownership Validation

Stage 1 of the source generator pipeline validates ownership rules (see also Roslyn Analyzers & Quality Gates for the broader quality enforcement strategy):

  • Every [ValueObject] must be owned via [Composition] somewhere in the model. If a value object is declared but never composed into an aggregate, the generator produces a compiler error: DSL005: ValueObject 'Money' is not owned by any aggregate via [Composition].
  • Every [Entity] within an aggregate must be reachable via a [Composition] chain from the [AggregateRoot]. A floating entity produces: DSL006: Entity 'OrderLine' is not reachable via [Composition] from any AggregateRoot.
  • Cross-aggregate [Composition] is forbidden. An aggregate root cannot compose an entity that belongs to another aggregate: DSL007: [Composition] cannot cross aggregate boundaries. 'Product' belongs to 'Catalog' context but is composed from 'Order' in 'Ordering' context.

Complete Example

[AggregateRoot("Order", BoundedContext = "Ordering")]
public partial class Order
{
    [EntityId]
    public partial OrderId Id { get; }

    [Property("OrderDate", Required = true)]
    public partial DateTime OrderDate { get; }

    [Property("Status", Required = true)]
    public partial OrderStatus Status { get; }

    [Composition] // 1-to-many entity → HasMany + Cascade
    public partial IReadOnlyList<OrderLine> Lines { get; }

    [Composition] // 1-to-1 entity → HasOne + Cascade
    public partial PaymentDetails Payment { get; }

    [Composition] // ValueObject → OwnsOne (embedded)
    public partial ShippingAddress ShippingAddress { get; }

    [Composition] // ValueObject collection → OwnsMany
    public partial IReadOnlyList<OrderTag> Tags { get; }

    [Association] // Cross-aggregate ref → no cascade
    public partial CustomerId CustomerId { get; }
}

[Entity("OrderLine")]
public partial class OrderLine
{
    [EntityId]
    public partial OrderLineId Id { get; }

    [Property("Sku", Required = true, MaxLength = 50)]
    public partial string Sku { get; }

    [Property("Quantity", Required = true)]
    public partial int Quantity { get; }

    [Composition] // ValueObject → OwnsOne
    public partial Money UnitPrice { get; }
}

[Entity("PaymentDetails")]
public partial class PaymentDetails
{
    [EntityId]
    public partial PaymentDetailsId Id { get; }

    [Property("Method", Required = true)]
    public partial PaymentMethod Method { get; }

    [Composition] // ValueObject → OwnsOne
    public partial Money Amount { get; }

    [Property("TransactionRef")]
    public partial string? TransactionRef { get; }
}

The EF Core generator derives the following configuration from the composition tree:

// <auto-generated/>
namespace MyStore.Infrastructure.Postgres.Configurations;

public sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.ToTable("Orders", "ordering");
        builder.HasKey(e => e.Id);

        // Strongly-typed ID conversion
        builder.Property(e => e.Id)
            .HasConversion(id => id.Value, v => new OrderId(v));

        builder.Property(e => e.OrderDate).IsRequired();
        builder.Property(e => e.Status).IsRequired()
            .HasConversion<string>();

        // [Composition] IReadOnlyList<OrderLine> → HasMany + Cascade
        builder.HasMany(e => e.Lines)
            .WithOne()
            .HasForeignKey("OrderId")
            .OnDelete(DeleteBehavior.Cascade);

        // [Composition] PaymentDetails → HasOne + Cascade (1-to-1)
        builder.HasOne(e => e.Payment)
            .WithOne()
            .HasForeignKey<PaymentDetails>("OrderId")
            .IsRequired()
            .OnDelete(DeleteBehavior.Cascade);

        // [Composition] ShippingAddress (ValueObject) → OwnsOne
        builder.OwnsOne(e => e.ShippingAddress, owned =>
        {
            owned.Property(a => a.Street).HasColumnName("ShippingAddress_Street")
                .IsRequired().HasMaxLength(200);
            owned.Property(a => a.City).HasColumnName("ShippingAddress_City")
                .IsRequired().HasMaxLength(100);
            owned.Property(a => a.ZipCode).HasColumnName("ShippingAddress_ZipCode")
                .IsRequired().HasMaxLength(20);
            owned.Property(a => a.Country).HasColumnName("ShippingAddress_Country")
                .IsRequired().HasMaxLength(2);
        });

        // [Composition] IReadOnlyList<OrderTag> (ValueObject) → OwnsMany
        builder.OwnsMany(e => e.Tags, owned =>
        {
            owned.ToTable("Order_Tags", "ordering");
            owned.WithOwner().HasForeignKey("OrderId");
        });

        // [Association] CustomerId → no cascade
        builder.Property(e => e.CustomerId)
            .HasConversion(id => id.Value, v => new CustomerId(v));
    }
}
Diagram
The Order aggregate drawn as a transaction boundary — composition lines stay inside the box, Money value objects hang off entities, and Customer sits outside as a plain association.

Convention Overrides

The default EF Core mapping is 100% derived from DDD semantics. For cases where conventions do not fit, optional attributes override specific settings:

[AggregateRoot("Order", BoundedContext = "Ordering")]
[Table("order_headers", Schema = "sales")]           // override table name
[Index("Status", "OrderDate")]                        // add index
public partial class Order
{
    [EntityId]
    [Column("order_id", TypeName = "uniqueidentifier")] // override column
    public partial OrderId Id { get; }

    [Property("Status", Required = true)]
    [HasConversion(typeof(OrderStatusConverter))]        // custom converter
    public partial OrderStatus Status { get; }
}

These overrides are optional. The default mapping derived from [Composition], [Association], and [Aggregation] is the primary interface. Override attributes are escape hatches, not the norm.

Aggregate Boundary = Transaction Boundary

One [AggregateRoot] equals one DbContext.SaveChangesAsync() scope. All entities reachable via [Composition] are saved atomically within a single transaction. Cross-aggregate [Association] references are eventually consistent, synchronized via domain events.

The generated repository enforces this boundary:

// <auto-generated/>
namespace MyStore.Infrastructure.Postgres.Repositories;

public sealed class OrderRepository : IOrderRepository
{
    private readonly OrderingDbContext _dbContext;
    private readonly IDomainEventBus _eventBus;

    public OrderRepository(OrderingDbContext dbContext, IDomainEventBus eventBus)
    {
        _dbContext = dbContext;
        _eventBus = eventBus;
    }

    public async Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct)
    {
        return await _dbContext.Orders
            .Include(o => o.Lines)
            .Include(o => o.Payment)
            .FirstOrDefaultAsync(o => o.Id == id, ct);
    }

    public async Task SaveAsync(Order aggregate, CancellationToken ct)
    {
        // Single SaveChanges = single transaction
        // Includes Order + all composed OrderLines + PaymentDetails
        // + ShippingAddress (owned) + Tags (owned)
        _dbContext.Orders.Update(aggregate);
        await _dbContext.SaveChangesAsync(ct);

        // Domain events published AFTER successful commit
        await _eventBus.PublishAsync(aggregate.DomainEvents, ct);
        aggregate.ClearDomainEvents();
    }

    public async Task DeleteAsync(Order aggregate, CancellationToken ct)
    {
        // Cascade delete handles all composed entities
        _dbContext.Orders.Remove(aggregate);
        await _dbContext.SaveChangesAsync(ct);
    }
}

Migration Story

When [Composition] relationships change, cmf migrate detects the model diff and generates an EF Core migration whose name reflects the DDD intent:

# Developer adds a Discount value object to Order:
#   [Composition] public partial Discount Discount { get; }

$ cmf migrate
  Detected model change: Order gained [Composition] Discount (ValueObject)
  Generated migration: 20260319_AddDiscountToOrder
  Applied OwnsOne<Discount> to OrderConfiguration

# Developer promotes OrderStatus from enum to Entity:
#   [Composition] public partial OrderStatusEntity Status { get; }

$ cmf migrate
  Detected model change: Order.Status changed from Property to [Composition] Entity
  Generated migration: 20260319_PromoteOrderStatusToEntity
  Created OrderStatusEntity table with FK to Orders

The migration names are auto-derived from the DDD operation -- not from column-level changes. AddDiscountToOrder is more meaningful than AddColumn_Discount_Amount_Discount_Currency.

⬇ Download