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

Aggregates and Composition

"The aggregate boundary is a consistency boundary. Everything inside it lives and dies together. Everything outside it is a reference, not a possession."


DDD Aggregates in 60 Seconds

In Domain-Driven Design, an aggregate is a cluster of entities that are treated as a single unit for data changes. The aggregate root is the entry point — all access to entities inside the aggregate goes through the root.

The critical rule: entities inside an aggregate share the root's lifecycle. When you delete an Order, you delete its OrderItems. When you delete a Customer, you do not delete their Orders — those are separate aggregates with a reference between them.

This lifecycle distinction maps directly to EF Core's DeleteBehavior:

DDD Relationship Lifecycle EF Core DeleteBehavior Meaning
Composition Child dies with parent Cascade Delete parent → delete children
Aggregation Shared reference Restrict Cannot delete parent while children exist
Association Independent lifecycle NoAction No automatic cascade or restriction

Entity.Dsl encodes this mapping in three attributes: [Composition], [Aggregation], and [Association]. The developer declares the DDD relationship; the generator emits the correct EF Core configuration.


The Marketplace Aggregates

Our marketplace has four aggregates so far:

Diagram

Solid arrows are composition (lifecycle ownership). Dashed arrows are aggregation (shared references).


Composition: Order Owns OrderItems

An Order and its OrderItems are a single aggregate. When the Order is deleted, the items must be deleted too. This is composition — the parent owns the child's lifecycle.

The Domain Classes

[AggregateRoot("Order")]
[Table("Orders")]
public partial class Order
{
    [PrimaryKey]
    public Guid Id { get; set; }

    [Required]
    [MaxLength(50)]
    public string OrderNumber { get; set; } = "";

    public DateTimeOffset OrderDate { get; set; }

    [Precision(18, 2)]
    public decimal Total { get; set; }

    public Guid CustomerId { get; set; }

    [Aggregation]
    [HasOne(WithMany = "Orders", ForeignKey = "CustomerId")]
    public Customer Customer { get; set; } = null!;

    [Composition]
    [HasMany(WithOne = "Order", ForeignKey = "OrderId")]
    public List<OrderItem> Items { get; set; } = new();
}

[Entity("OrderItem")]
[Table("OrderItems")]
public partial class OrderItem
{
    [PrimaryKey]
    public int Id { get; set; }

    public Guid OrderId { get; set; }

    [HasOne(WithMany = "Items")]
    public Order Order { get; set; } = null!;

    public Guid ProductId { get; set; }

    [Aggregation]
    [HasOne(ForeignKey = "ProductId")]
    public Product Product { get; set; } = null!;

    public int Quantity { get; set; }

    [Precision(18, 2)]
    public decimal UnitPrice { get; set; }

    [ComputedColumn("[Quantity] * [UnitPrice]", Stored = true)]
    public decimal LineTotal { get; set; }
}

Notice the pattern:

  • [Composition] on Order.Items declares that Order owns its OrderItems
  • [HasMany(WithOne = "Order", ForeignKey = "OrderId")] tells the generator the navigation and FK details
  • [Aggregation] on Order.Customer declares that Order references (but does not own) the Customer
  • [Aggregation] on OrderItem.Product declares that OrderItem references (but does not own) the Product

Generated Output: OrderConfigurationBase.g.cs (Relationship Methods)

The generator reads these attributes and emits:

// Composition: Order owns OrderItems → Cascade
protected virtual void ConfigureItems(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Order> builder)
{
    builder.HasMany(e => e.Items)
        .WithOne(e => e.Order)
        .HasForeignKey(e => e.OrderId)
        .OnDelete(DeleteBehavior.Cascade);
}

// Aggregation: Order references Customer → Restrict
protected virtual void ConfigureCustomer(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Order> builder)
{
    builder.HasOne(e => e.Customer)
        .WithMany(e => e.Orders)
        .HasForeignKey(e => e.CustomerId)
        .OnDelete(DeleteBehavior.Restrict);
}

And on the OrderItem side:

// Aggregation: OrderItem references Product → Restrict
protected virtual void ConfigureProduct(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.OrderItem> builder)
{
    builder.HasOne(e => e.Product)
        .HasForeignKey(e => e.ProductId)
        .OnDelete(DeleteBehavior.Restrict);
}

The [Composition] attribute produces DeleteBehavior.Cascade. The [Aggregation] attribute produces DeleteBehavior.Restrict. The developer never writes .OnDelete() — the DDD attribute encodes the intent, and the generator maps it to EF Core.


Aggregation: Order References Customer

The Customer is a separate aggregate. Orders reference customers, but deleting a customer should not cascade-delete their orders (that would be catastrophic for financial records). Instead, the database should prevent deletion of a customer who has orders.

The Customer Entity

[AggregateRoot("Customer")]
[Table("Customers")]
public partial class Customer
{
    [PrimaryKey]
    public Guid Id { get; set; }

    [Required]
    [MaxLength(200)]
    public string Name { get; set; } = "";

    [Required]
    [MaxLength(254)]
    public string Email { get; set; } = "";

    [MaxLength(20)]
    public string? Phone { get; set; }

    public List<Order> Orders { get; set; } = new();
}

The Customer class does not need [Aggregation] or [HasMany] on its Orders property — the relationship is already defined from the Order side (the [HasOne(WithMany = "Orders")] on Order.Customer). Entity.Dsl resolves bidirectional navigations from either end.

What Restrict Means in Practice

With DeleteBehavior.Restrict, attempting to delete a Customer who has Orders throws a database exception:

Microsoft.EntityFrameworkCore.DbUpdateException:
  The DELETE statement conflicted with the REFERENCE constraint "FK_Orders_Customers_CustomerId".

This is the correct behavior. You want the database to stop you from accidentally orphaning financial records. If you need to "delete" a customer, use soft-delete (Part VI) or archive them.


Composition: Store Owns StoreSettings

A Store has exactly one StoreSettings — a one-to-one composition. The settings are meaningless without the store, so they share the store's lifecycle.

The Domain Classes

[AggregateRoot("Store")]
[Table("Stores")]
public partial class Store
{
    [PrimaryKey]
    public Guid Id { get; set; }

    [Required]
    [MaxLength(200)]
    public string Name { get; set; } = "";

    [Required]
    [MaxLength(500)]
    public string Description { get; set; } = "";

    [MaxLength(2000)]
    public string? WebsiteUrl { get; set; }

    [Composition]
    [HasOne(ForeignKey = "StoreId")]
    public StoreSettings? Settings { get; set; }

    public List<Product> Products { get; set; } = new();
}

[Entity("StoreSettings")]
[Table("StoreSettings")]
public partial class StoreSettings
{
    [PrimaryKey]
    public Guid Id { get; set; }

    public Guid StoreId { get; set; }

    [HasOne]
    public Store Store { get; set; } = null!;

    [MaxLength(3)]
    [DefaultValue(Value = "USD")]
    public string Currency { get; set; } = "USD";

    [MaxLength(50)]
    [DefaultValue(Value = "en-US")]
    public string Locale { get; set; } = "en-US";

    [DefaultValue(Value = "0.00")]
    [Precision(5, 2)]
    public decimal TaxRate { get; set; }

    public bool AcceptsCreditCards { get; set; }
    public bool AcceptsBankTransfers { get; set; }
}

Generated Output: One-to-One Composition

// Composition: Store owns StoreSettings → Cascade
protected virtual void ConfigureSettings(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Store> builder)
{
    builder.HasOne(e => e.Settings)
        .WithOne(e => e.Store)
        .HasForeignKey<global::Marketplace.Domain.StoreSettings>(e => e.StoreId)
        .OnDelete(DeleteBehavior.Cascade);
}

For one-to-one relationships, EF Core requires HasForeignKey<TDependent> with the dependent type explicitly specified. The generator infers this from the ForeignKey property on [HasOne].


The Product Aggregate

Product references its Store (aggregation — products are not deleted when a store is deleted, they are orphaned or reassigned). Product will later gain compositions (ProductVariant) and associations (Category), but for now:

[AggregateRoot("Product")]
[Table("Products")]
public partial class Product
{
    [PrimaryKey]
    public Guid Id { get; set; }

    [Required]
    [MaxLength(200)]
    public string Name { get; set; } = "";

    [MaxLength(2000)]
    public string? Description { get; set; }

    [Precision(18, 2)]
    public decimal Price { get; set; }

    [Required]
    [MaxLength(50)]
    public string Sku { get; set; } = "";

    [DefaultValue(Value = "true")]
    public bool IsActive { get; set; }

    public Guid StoreId { get; set; }

    [Aggregation]
    [HasOne(WithMany = "Products", ForeignKey = "StoreId")]
    public Store Store { get; set; } = null!;
}

Generated Output

// Aggregation: Product references Store → Restrict
protected virtual void ConfigureStore(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
    builder.HasOne(e => e.Store)
        .WithMany(e => e.Products)
        .HasForeignKey(e => e.StoreId)
        .OnDelete(DeleteBehavior.Restrict);
}

You cannot delete a Store that has Products. The database enforces this. If you want to close a store, soft-delete the products first (Part VI), then soft-delete the store.


Association: The Third Relationship Type

We have not yet used [Association] in the marketplace domain — it will appear in Part V with the Product-Category many-to-many. But to complete the picture:

// Hypothetical: a Product can have optional related products
[Association]
[HasOne(ForeignKey = "RelatedProductId")]
public Product? RelatedProduct { get; set; }
// Generated: Association → NoAction
builder.HasOne(e => e.RelatedProduct)
    .HasForeignKey(e => e.RelatedProductId)
    .OnDelete(DeleteBehavior.NoAction);

NoAction means the database does nothing when the referenced entity is deleted. If you delete the related product, the FK column becomes a dangling reference. This is appropriate for loosely-coupled, optional relationships where the application handles cleanup.


Overriding the Default Delete Behavior

Sometimes the default is not what you want. Entity.Dsl lets you override it with the OnDelete parameter on the relationship attribute:

// Composition normally cascades, but here we restrict instead
// (maybe we want to force explicit deletion of children first)
[Composition]
[HasMany(WithOne = "Order", ForeignKey = "OrderId", OnDelete = "Restrict")]
public List<OrderItem> Items { get; set; } = new();
// Aggregation normally restricts, but here we set null instead
// (maybe we allow orphaned orders when a customer is deleted)
[Aggregation]
[HasOne(WithMany = "Orders", ForeignKey = "CustomerId", OnDelete = "SetNull")]
public Customer? Customer { get; set; }

Valid OnDelete values: "Cascade", "Restrict", "SetNull", "NoAction", "ClientCascade", "ClientSetNull".

The explicit OnDelete always wins over the DDD-inferred default. This is the escape hatch for when the domain semantics and the persistence requirements diverge.


Convention-Based Relationship Inference

Entity.Dsl can also infer relationships from property types without explicit [HasMany]/[HasOne] attributes:

Property Type Inference
T where T has [Entity] HasOne<T> (reference navigation)
ICollection<T> / List<T> where T has [Entity] HasMany<T> (collection navigation)
T? where T has [Entity] HasOne<T> optional (IsRequired = false)

So this:

[AggregateRoot("Customer")]
[Table("Customers")]
public partial class Customer
{
    [PrimaryKey]
    public Guid Id { get; set; }

    public List<Order> Orders { get; set; } = new();
}

The generator sees List<Order> and infers a HasMany<Order> relationship. The FK is inferred from the convention {NavigationType}Id — the generator looks for CustomerId on Order.

The explicit attributes ([HasMany], [HasOne], [Composition], [Aggregation]) are needed when:

  • The convention does not match your FK naming
  • You need to specify the inverse navigation
  • You want to declare lifecycle ownership (composition vs aggregation)
  • You need custom OnDelete behavior

What Gets Wrong When Delete Behavior Is Manual

When developers hand-write EF Core configurations, delete behavior is one of the most common sources of bugs:

Bug 1: Accidental Cascade

// Developer forgot to set OnDelete — EF Core defaults to Cascade
// for non-nullable FKs. Deleting a Customer cascade-deletes all Orders.
builder.HasOne(e => e.Customer)
    .WithMany(e => e.Orders)
    .HasForeignKey(e => e.CustomerId);
    // Missing: .OnDelete(DeleteBehavior.Restrict)

Entity.Dsl prevents this: [Aggregation] always emits Restrict. The developer would have to explicitly override with OnDelete = "Cascade" to get cascade behavior on a reference — a conscious decision, not a forgotten line.

Bug 2: Orphaned Records

// Developer set NoAction, but the application does not clean up
// orphaned OrderItems when their Product is deleted.
builder.HasOne(e => e.Product)
    .HasForeignKey(e => e.ProductId)
    .OnDelete(DeleteBehavior.NoAction);
    // Now deleting a Product leaves OrderItems pointing at nothing

Entity.Dsl prevents this: [Aggregation] emits Restrict, which means the database stops you from deleting a Product that has OrderItems referencing it. The developer must handle the Product's OrderItems before deletion.

Bug 3: Circular Cascade

// Developer accidentally sets Cascade on both sides of a relationship.
// SQL Server rejects this with "may cause cycles or multiple cascade paths."

Entity.Dsl's defaults prevent this by design: composition (Cascade) only applies to parent-child relationships within an aggregate. Cross-aggregate references use Restrict or NoAction. Cycles require at least two Cascade relationships, which the convention prevents.


The Orchestrator with Relationships

With relationships added, the orchestrator in OrderConfigurationBase.g.cs calls the relationship methods alongside the property methods:

public void Configure(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Order> builder)
{
    PreConfigure(builder);
    ConfigureTable(builder);
    ConfigurePrimaryKey(builder);
    ConfigureOrderNumber(builder);
    ConfigureOrderDate(builder);
    ConfigureTotal(builder);
    ConfigureCustomer(builder);     // ← Aggregation: Restrict
    ConfigureItems(builder);        // ← Composition: Cascade
    PostConfigure(builder);
}

Every relationship is a virtual method. The developer can override ConfigureCustomer to change the delete behavior, add an index, or adjust any relationship detail — without touching the rest of the configuration.


Generated Repositories

Each aggregate root gets its own repository. Entities inside the aggregate (OrderItem, StoreSettings) do not — they are accessed through their aggregate root's repository and UnitOfWork.

// Generated: IOrderRepository.g.cs
public interface IOrderRepository : global::FrenchExDev.Net.Entity.Dsl.Abstractions.IRepository<global::Marketplace.Domain.Order>
{
    global::System.Threading.Tasks.ValueTask<global::Marketplace.Domain.Order?> FindByIdAsync(object id);
}

// Generated: ICustomerRepository.g.cs
public interface ICustomerRepository : global::FrenchExDev.Net.Entity.Dsl.Abstractions.IRepository<global::Marketplace.Domain.Customer>
{
    global::System.Threading.Tasks.ValueTask<global::Marketplace.Domain.Customer?> FindByIdAsync(object id);
}

// Generated: IStoreRepository.g.cs
public interface IStoreRepository : global::FrenchExDev.Net.Entity.Dsl.Abstractions.IRepository<global::Marketplace.Domain.Store>
{
    global::System.Threading.Tasks.ValueTask<global::Marketplace.Domain.Store?> FindByIdAsync(object id);
}

The UnitOfWork grows with each aggregate root:

// Generated: IMarketplaceDbContextUnitOfWork.g.cs
public interface IMarketplaceDbContextUnitOfWork
    : global::FrenchExDev.Net.Entity.Dsl.Abstractions.IUnitOfWork<global::Marketplace.Domain.MarketplaceDbContext>
{
    global::Marketplace.Domain.Repositories.IProductRepository Products { get; }
    global::Marketplace.Domain.Repositories.IOrderRepository Orders { get; }
    global::Marketplace.Domain.Repositories.ICustomerRepository Customers { get; }
    global::Marketplace.Domain.Repositories.IStoreRepository Stores { get; }
}

Accessing Children Through the Root

OrderItems do not have their own repository. You access them through the Order:

public async Task AddItemToOrderAsync(Guid orderId, Guid productId, int quantity, decimal unitPrice)
{
    var order = await _uow.Orders.Query
        .Include(o => o.Items)
        .FirstAsync(o => o.Id == orderId);

    order.Items.Add(new OrderItem
    {
        ProductId = productId,
        Quantity = quantity,
        UnitPrice = unitPrice
    });

    await _uow.SaveChangesAsync();
}

This enforces the aggregate boundary: you cannot modify OrderItems without going through the Order. The Order's invariants (minimum order value, maximum item count) can be checked in the Order entity before saving.


The DbContext with All Entities

The generated DbContext now includes DbSets for all entities — aggregate roots and their children:

// Generated: MarketplaceDbContextBase.g.cs (excerpt)
public Microsoft.EntityFrameworkCore.DbSet<global::Marketplace.Domain.Product> Products { get; set; } = null!;
public Microsoft.EntityFrameworkCore.DbSet<global::Marketplace.Domain.Order> Orders { get; set; } = null!;
public Microsoft.EntityFrameworkCore.DbSet<global::Marketplace.Domain.OrderItem> OrderItems { get; set; } = null!;
public Microsoft.EntityFrameworkCore.DbSet<global::Marketplace.Domain.Customer> Customers { get; set; } = null!;
public Microsoft.EntityFrameworkCore.DbSet<global::Marketplace.Domain.Store> Stores { get; set; } = null!;
public Microsoft.EntityFrameworkCore.DbSet<global::Marketplace.Domain.StoreSettings> StoreSettings { get; set; } = null!;

And RegisterConfigurations applies all configurations:

protected virtual void RegisterConfigurations(Microsoft.EntityFrameworkCore.ModelBuilder modelBuilder)
{
    modelBuilder.ApplyConfiguration(new global::Marketplace.Domain.Configuration.ProductConfigurationRegistration());
    modelBuilder.ApplyConfiguration(new global::Marketplace.Domain.Configuration.OrderConfigurationRegistration());
    modelBuilder.ApplyConfiguration(new global::Marketplace.Domain.Configuration.OrderItemConfigurationRegistration());
    modelBuilder.ApplyConfiguration(new global::Marketplace.Domain.Configuration.CustomerConfigurationRegistration());
    modelBuilder.ApplyConfiguration(new global::Marketplace.Domain.Configuration.StoreConfigurationRegistration());
    modelBuilder.ApplyConfiguration(new global::Marketplace.Domain.Configuration.StoreSettingsConfigurationRegistration());
}

Loading Aggregates Correctly

A common mistake with aggregates is loading children independently instead of through the root. Entity.Dsl's repository design encourages the correct pattern:

The Right Way: Through the Aggregate Root

// Load Order with its Items through the UnitOfWork
var order = await _uow.Orders.Query
    .Include(o => o.Items)
        .ThenInclude(i => i.Product)
    .Include(o => o.Customer)
    .FirstOrDefaultAsync(o => o.Id == orderId);

// Modify items through the Order
order.Items.Add(new OrderItem { ... });
order.Items.RemoveAll(i => i.Quantity == 0);
order.Total = order.Items.Sum(i => i.LineTotal);

await _uow.SaveChangesAsync();

The Wrong Way: Bypassing the Aggregate Root

// Anti-pattern: accessing OrderItems directly bypasses aggregate invariants
var item = await context.OrderItems.FirstAsync(i => i.Id == itemId);
item.Quantity = 0;  // No order total recalculation!
await context.SaveChangesAsync();

Entity.Dsl does not generate repositories for non-root entities (OrderItem, StoreSettings). This is intentional — it nudges the developer toward the correct aggregate pattern. If you find yourself needing direct access to a child entity, reconsider your aggregate boundaries.

When Direct Child Access Is Needed

Sometimes you legitimately need to query child entities directly (e.g., "find all OrderItems for a specific product across all orders"). In these cases, the DbContext's DbSet is available:

// Direct query via DbContext — use sparingly
var items = await _uow.Context.OrderItems
    .Where(i => i.ProductId == productId)
    .Include(i => i.Order)
    .ToListAsync();

The UoW exposes the Context property for exactly these edge cases. But if you find yourself using it frequently, the design signal is clear: either the entity should be its own aggregate root, or the query belongs in a view or projection.


Summary: The Lifecycle Convention

Attribute DDD Meaning EF Core Output When To Use
[Composition] Parent owns child OnDelete(DeleteBehavior.Cascade) Entities inside the same aggregate
[Aggregation] Shared reference OnDelete(DeleteBehavior.Restrict) Cross-aggregate references
[Association] Independent lifecycle OnDelete(DeleteBehavior.NoAction) Loosely coupled, optional relationships
(none) No DDD opinion EF Core default (infer from nullability) When you trust EF Core's convention

One attribute declares the DDD intent. The generator produces the correct EF Core configuration. The developer never writes .OnDelete() manually — unless they need to override the default, in which case the OnDelete parameter on the relationship attribute is the escape hatch.

In the next part, we add value objects and owned types to the domain — Address, Money, and the decision between [Owned], [ComplexType], and [Entity].