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 { }[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; }
}[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));
}
}// <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));
}
}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; }
}[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);
}
}// <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# 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 OrdersThe migration names are auto-derived from the DDD operation -- not from column-level changes. AddDiscountToOrder is more meaningful than AddColumn_Discount_Amount_Discount_Currency.