Skip to content

Aggregate Design (WHY + WHAT)

This document covers Aggregate design principles for correctly setting consistency boundaries to prevent concurrency conflicts and data integrity issues. For Entity/Aggregate implementation, see 06b-entity-aggregate-core.md.

“A DbUpdateConcurrencyException occurs with every order processing.” “Putting all related data in a single Entity has made transactions slow.” “I understand that multiple Aggregates should not be changed in a single transaction, but how do we ensure data consistency?”

These problems are typical symptoms that appear when Aggregate boundaries are set incorrectly. Aggregate is the most important design decision in DDD, and this boundary determines the system’s concurrency, performance, and maintainability.

This document covers the following topics:

  1. Why Aggregates are consistency boundaries - Invariant protection and transaction principles
  2. Four core rules of Aggregate design - Invariant protection, small Aggregates, ID references, eventual consistency
  3. Criteria for distinguishing Value Object/Entity/Aggregate Root - Decision flowchart and judgment criteria
  4. Split/merge decisions - Signals and criteria for boundary readjustment during operation
  5. Anti-pattern identification and avoidance - God Aggregate, direct references, external invariant validation, etc.

A basic understanding of the following concepts is needed to understand this document:

A single Aggregate boundary decision determines the system’s concurrency, performance, and maintainability. The core principles are: keep boundaries small, reference between Aggregates only by ID, and handle changes outside the boundary through domain events.

// Aggregate Root definition
[GenerateEntityId]
public class Order : AggregateRoot<OrderId> { }
// Invariant protection (inside Aggregate)
public Fin<Unit> DeductStock(Quantity quantity) { ... }
// Domain event publishing
AddDomainEvent(new CreatedEvent(Id, productId, quantity, totalAmount));
// Cross-Aggregate reference (ID only)
public ProductId ProductId { get; private set; }

1. Aggregate Design:

  1. Identify invariants of domain concepts
  2. Set boundaries as the minimum object group that protects invariants
  3. Designate the Aggregate Root (single entry point for external access)
  4. Reference other Aggregates only by ID

2. Aggregate Split/Merge Decisions:

  1. Concurrency conflicts, change frequency imbalance, invariant independence -> consider splitting
  2. Always changed together, mutual invariant dependency, eventual consistency not possible -> consider merging
ConceptDescription
Consistency boundaryProtects invariants within the Aggregate in a single transaction
Transaction principleOne transaction = one Aggregate change
ID referenceNo direct object references between Aggregates, store only EntityId
Eventual consistencyCross-Aggregate changes are handled asynchronously via domain events
Small AggregatesInclude only the minimum data needed for invariant protection

The most important decision in DDD tactical design is where to place the Aggregate boundary. If this decision is wrong:

  • Concurrency conflicts due to large Aggregates
  • Performance degradation from overly broad transaction scope
  • Difficulty making changes due to tight coupling between Aggregates

This guide maps DDD design principles to Functorium framework implementation, providing the rationale for design decisions.

For example, if the product catalog and inventory are placed in a single Aggregate, concurrency conflicts occur whenever an admin’s product name edit and a customer’s order processing happen simultaneously. Separating them into separate Aggregates allows each to be changed independently, eliminating conflicts. This illustrates how a single Aggregate boundary decision determines the stability of the production environment.

An Aggregate is a group of objects that guarantees consistency as a single unit. All invariants within the Aggregate are protected within a single transaction.

┌─────────────────────────────────┐
│ Aggregate │
│ │
│ ┌──────────────┐ │
│ │ Aggregate │ invariant protection │ ← transaction boundary
│ │ Root │─────────── │
│ └──────┬───────┘ │
│ │ │
│ ┌────┴────┐ │
│ │ │ │
│ Child Value │
│ Entity Object │
│ │
└─────────────────────────────────┘

Invariants are business rules that must always hold true. Aggregates protect these invariants internally without exposing them externally.

The key point to note in the following code is that the DeductStock() method returns failure as Fin<Unit> instead of throwing an exception when stock is insufficient.

// Inventory Aggregate invariant: stock cannot be negative
// Error type definition: public sealed record InsufficientStock : DomainErrorType.Custom;
public Fin<Unit> DeductStock(Quantity quantity)
{
if (quantity > StockQuantity)
return DomainError.For<Inventory, int>(
new InsufficientStock(),
currentValue: StockQuantity,
message: $"Insufficient stock. Current: {StockQuantity}, Requested: {quantity}");
StockQuantity = StockQuantity.Subtract(quantity);
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new StockDeductedEvent(Id, ProductId, quantity));
return unit;
}

One transaction = one Aggregate change is the principle.

One Transaction changes one Aggregate
┌─────────────────────────┐
│ Transaction │
│ Inventory.DeductStock │
│ Repository.Save │
└─────────────────────────┘
One Transaction changes multiple Aggregates
┌──────────────────────────────────┐
│ Transaction │
│ Inventory.DeductStock │
│ Order.Create │ <- concurrency conflict risk
│ Customer.UpdateCreditLimit │
└──────────────────────────────────┘
ComponentRoleFunctorium Mapping
Aggregate RootSingle entry point for external accessAggregateRoot<TId>
Child EntityInternal Entity managed by RootEntity<TId>
Value ObjectImmutable valueSimpleValueObject<T>, ValueObject
AspectEntityValue Object
IdentifierID-based equalityValue-based equality
MutabilityMutable (state can change)Immutable
LifecycleLong-lived (Repository tracked)Short-lived (ephemeral)
Domain eventsCan publish (AggregateRoot)Cannot publish
ExamplesOrder, User, ProductMoney, Email, Address
Usage ScenarioBase ClassCharacteristics
General EntityEntity<TId>ID-based equality
Aggregate RootAggregateRoot<TId>Domain event management

Without Entities, the following problems occur:

// Problem 1: Identifier is unclear
public class Order
{
public Guid Id { get; set; } // Guid? int? string?
public decimal Amount { get; set; }
}
// Problem 2: Can be confused with IDs of other types
void ProcessOrder(Guid orderId, Guid customerId);
ProcessOrder(customerId, orderId); // Order mistake - no compile error!
// Problem 3: Equality comparison is unclear
var order1 = GetOrder(id);
var order2 = GetOrder(id);
order1 == order2; // false? (reference comparison)

Entities solve these problems:

// Solution: Type-safe ID and ID-based equality
[GenerateEntityId]
public class Order : Entity<OrderId>
{
public Money Amount { get; private set; }
private Order(OrderId id, Money amount) : base(id)
{
Amount = amount;
}
}
// Prevent mistakes with compile errors
void ProcessOrder(OrderId orderId, CustomerId customerId);
ProcessOrder(customerId, orderId); // Compile error!
// ID-based equality
var order1 = GetOrder(id);
var order2 = GetOrder(id);
order1 == order2; // true (same ID)
using Functorium.Domains.Entities;
[GenerateEntityId] // Auto-generates OrderId
public class Order : AggregateRoot<OrderId>
{
public Money Amount { get; private set; }
public CustomerId CustomerId { get; private set; }
// Default constructor for ORM
#pragma warning disable CS8618
private Order() { }
#pragma warning restore CS8618
// Internal constructor
private Order(OrderId id, Money amount, CustomerId customerId) : base(id)
{
Amount = amount;
CustomerId = customerId;
}
// Create: Receives already validated Value Objects directly
public static Order Create(Money amount, CustomerId customerId)
{
var id = OrderId.New();
return new Order(id, amount, customerId);
}
// CreateFromValidated: Direct pass-through of already validated/normalized data
// Restores the Aggregate from data read from the DB.
// Validation/normalization is skipped since the data already passed validation at save time.
public static Order CreateFromValidated(OrderId id, Money amount, CustomerId customerId)
=> new(id, amount, customerId);
// Domain operation
public Fin<Unit> UpdateAmount(Money newAmount)
{
Amount = newAmount;
AddDomainEvent(new OrderAmountUpdatedEvent(Id, newAmount));
return unit;
}
}

We have examined the Aggregate concept and its components. In the next section, we will learn the four core rules to follow when implementing these concepts in code.


Rule 1: Protect Invariants Within Aggregate Boundaries

Section titled “Rule 1: Protect Invariants Within Aggregate Boundaries”

All invariants within an Aggregate are protected through the Aggregate Root. Child Entities cannot be directly modified from outside.

// ✅ Manage Tags through Aggregate Root (Product)
public sealed class Product : AggregateRoot<ProductId>
{
private readonly List<Tag> _tags = [];
public IReadOnlyList<Tag> Tags => _tags.AsReadOnly();
public Product AddTag(Tag tag)
{
// Invariant: prevent duplicate Tags
if (_tags.Any(t => t.Id == tag.Id))
return this;
_tags.Add(tag);
AddDomainEvent(new TagAssignedEvent(tag.Id, tag.Name));
return this;
}
public Product RemoveTag(TagId tagId)
{
var tag = _tags.FirstOrDefault(t => t.Id == tagId);
if (tag is null)
return this;
_tags.Remove(tag);
AddDomainEvent(new TagRemovedEvent(tagId));
return this;
}
}
// ❌ Directly modifying child Entity from outside
product.Tags.Add(newTag); // Compile error because IReadOnlyList

Aggregates should include only the minimum data needed for invariant protection.

// ✅ Small Aggregate: includes only what is needed
public sealed class Customer : AggregateRoot<CustomerId>
{
public CustomerName Name { get; private set; }
public Email Email { get; private set; }
public Money CreditLimit { get; private set; }
}
// ❌ Large Aggregate: includes everything related
public class Customer : AggregateRoot<CustomerId>
{
public CustomerName Name { get; private set; }
public Email Email { get; private set; }
public List<Order> Orders { get; } // No invariant for Customer to protect
public List<Address> Addresses { get; } // Can be separated into its own Aggregate
public List<PaymentMethod> Payments { get; } // Can be separated into its own Aggregate
}

Why should it be small?

ProblemLarge AggregateSmall Aggregate
ConcurrencyFrequent conflictsMinimal conflicts
PerformanceFull load requiredLoad only what is needed
MemoryHigh usageLow usage
TransactionWide scopeNarrow scope

Rule 3: Reference Other Aggregates Only by ID

Section titled “Rule 3: Reference Other Aggregates Only by ID”

Between Aggregates, only EntityId is stored. Direct object references are not used.

// ✅ Reference by ID only (Order → Product)
public sealed class Order : AggregateRoot<OrderId>
{
// Cross-Aggregate reference (references Product by ID value)
public ProductId ProductId { get; private set; }
public Quantity Quantity { get; private set; }
public Money UnitPrice { get; private set; }
public Money TotalAmount { get; private set; }
}
// ❌ Direct object reference
public class Order : AggregateRoot<OrderId>
{
public Product Product { get; private set; } // Tight coupling!
}

Why reference only by ID?

  1. Aggregate independence: Each Aggregate is loaded/saved independently
  2. Loose coupling: Avoids direct references between Entities
  3. Performance: Loads related Aggregates only when needed

Rule 4: Use Eventual Consistency Outside Boundaries

Section titled “Rule 4: Use Eventual Consistency Outside Boundaries”

Business rules that span multiple Aggregates are handled through domain events via eventual consistency.

// Stock deduction on order creation requires changing a separate Aggregate (Product)
// → Handled asynchronously via domain events
// Event publishing from Order Aggregate
public static Order Create(
ProductId productId,
Quantity quantity,
Money unitPrice,
ShippingAddress shippingAddress)
{
var totalAmount = unitPrice.Multiply(quantity);
var order = new Order(OrderId.New(), productId, quantity, unitPrice, totalAmount, shippingAddress);
order.AddDomainEvent(new CreatedEvent(order.Id, productId, quantity, totalAmount));
return order;
}
// Updating Inventory Aggregate in Event Handler (separate transaction)
// public class OnOrderCreated : IDomainEventHandler<Order.CreatedEvent>
// {
// public async ValueTask Handle(Order.CreatedEvent @event, CancellationToken ct)
// {
// // Call Inventory.DeductStock
// }
// }

Note: Since multiple Aggregates cannot be changed simultaneously in a single transaction, Cross-Aggregate side effects are handled via event handlers (eventual consistency). For practical exceptions such as simultaneously creating related Aggregates within the same Bounded Context, see Section 4: Transaction Boundary Practical Guidelines.

Now that we understand the design rules, let us learn the criteria for classifying domain concepts as Value Object, Entity, or Aggregate Root.


Distinguishing Aggregate vs Entity vs Value Object

Section titled “Distinguishing Aggregate vs Entity vs Value Object”
Does this domain concept need a unique identifier?
├── No → Value Object
│ (Money, Email, Address, Quantity...)
└── Yes → Entity
Is this Entity independently stored/queried?
├── Yes → Aggregate Root
│ (Customer, Product, Order...)
└── No → Child Entity (inside Aggregate)
(Tag, OrderItem...)

The following table compares the three building blocks across seven criteria. The key differences are the presence of a unique identifier and the ability to be independently queried.

CriterionValue ObjectEntity (Child)Aggregate Root
Unique identifierNonePresentPresent
EqualityValue-basedID-basedID-based
MutabilityImmutableMutableMutable
Independent queryNot possibleNot possible (via Root)Possible
RepositoryNoneNonePresent
Domain eventsCannot publishCannot publishCan publish
LifecycleDepends on owning EntityDepends on RootIndependent
FunctoriumSimpleValueObject<T>Entity<TId>AggregateRoot<TId>
Domain ConceptClassificationRationale
CustomerAggregate RootIndependent lifecycle, own invariants (Email validity, CreditLimit), has Repository
ProductAggregate RootIndependent lifecycle, own invariants (Tag duplication prevention), manages child Entity (Tag)
InventoryAggregate RootIndependent lifecycle, own invariants (stock >= 0), IConcurrencyAware concurrency control
OrderAggregate RootIndependent lifecycle, Cross-Aggregate reference (ProductId), own invariants (TotalAmount calculation)
TagChild EntityHas own ID, but accessed only through Aggregate Root (Product). No independent Repository
MoneyValue ObjectNo identifier, value-based equality, immutable
EmailValue ObjectNo identifier, value-based equality, immutable
QuantityValue ObjectNo identifier, value-based equality, immutable
ShippingAddressValue ObjectNo identifier, value-based equality, immutable

We have confirmed the classification criteria and decision flow. In the next section, we will analyze actual Aggregates in LayeredArch.Domain and examine practical examples of boundary setting.


Practical Examples of Aggregate Boundary Setting

Section titled “Practical Examples of Aggregate Boundary Setting”

We analyze three Aggregates in LayeredArch.Domain.

Customer Aggregate: Simple Aggregate with Root Only

Section titled “Customer Aggregate: Simple Aggregate with Root Only”
┌─────────────────────────────────┐
│ Customer Aggregate │
│ │
│ ┌──────────────────┐ │
│ │ Customer (Root) │ │
│ │ - CustomerName │ ← VO │
│ │ - Email │ ← VO │
│ │ - Money │ ← VO │
│ └──────────────────┘ │
│ │
└─────────────────────────────────┘

Invariants:

  • CustomerName, Email, CreditLimit are each self-validated by their Value Objects

Boundary Rationale:

  • Customer has an independent lifecycle
  • The simplest form of Aggregate with no child Entities
  • Connected to Order only via ID reference (Order does not own CustomerId — in this example, Order references ProductId)
[GenerateEntityId]
public sealed class Customer : AggregateRoot<CustomerId>, IAuditable
{
public CustomerName Name { get; private set; }
public Email Email { get; private set; }
public Money CreditLimit { get; private set; }
public static Customer Create(
CustomerName name,
Email email,
Money creditLimit)
{
var customer = new Customer(CustomerId.New(), name, email, creditLimit);
customer.AddDomainEvent(new CreatedEvent(customer.Id, name, email));
return customer;
}
}

Product + Inventory Aggregate: Separating Catalog and Stock

Section titled “Product + Inventory Aggregate: Separating Catalog and Stock”

This is a case where stock (high-frequency changes) was separated into its own Aggregate to reduce concurrency conflicts.

┌──────────────────────────────────────┐ ┌─────────────────────────────┐
│ Product Aggregate (Catalog) │ │ Inventory Aggregate (Stock) │
│ │ │ │
│ ┌────────────────────┐ │ │ ┌──────────────────────┐ │
│ │ Product (Root) │ │ │ │ Inventory (Root) │ │
│ │ - ProductName │ <- VO │ │ │ - ProductId │ ID ref│
│ │ - ProductDesc │ ← VO │ │ │ - Quantity │ ← VO │
│ │ - Money (Price) │ <- VO │ │ │ - RowVersion │ concur│
│ └────────┬───────────┘ │ │ └──────────────────────┘ │
│ │ 1:N │ │ │
│ ┌────────┴───────────┐ │ └─────────────────────────────┘
│ │ Tag (Child Entity) │ │
│ │ - TagName │ ← VO │
│ └────────────────────┘ │
│ │
└──────────────────────────────────────┘

Product Invariants:

  • Tag duplication prevention (checked by ID in AddTag)

Inventory Invariants:

  • Stock quantity >= 0 (protected in DeductStock, IConcurrencyAware optimistic concurrency)

Boundary Rationale:

  • Product manages the lifecycle of Tags (Tags cannot exist without Product)
  • Stock changes with every order (high frequency) but catalog changes are infrequent -> separate Aggregates
  • Inventory references Product by ProductId (ID reference, not object reference)
// Product: Catalog information management
[GenerateEntityId]
public sealed class Product : AggregateRoot<ProductId>, IAuditable
{
private readonly List<Tag> _tags = [];
public IReadOnlyList<Tag> Tags => _tags.AsReadOnly();
// Invariant protection: prevent Tag duplication
public Product AddTag(Tag tag)
{
if (_tags.Any(t => t.Id == tag.Id))
return this;
_tags.Add(tag);
AddDomainEvent(new TagAssignedEvent(tag.Id, tag.Name));
return this;
}
}
// Inventory: Stock management (optimistic concurrency control)
[GenerateEntityId]
public sealed class Inventory : AggregateRoot<InventoryId>, IAuditable, IConcurrencyAware
{
#region Error Types
public sealed record InsufficientStock : DomainErrorType.Custom;
#endregion
public ProductId ProductId { get; private set; }
public Quantity StockQuantity { get; private set; }
public byte[] RowVersion { get; private set; } = [];
// Invariant protection: stock >= 0
public Fin<Unit> DeductStock(Quantity quantity)
{
if (quantity > StockQuantity)
return DomainError.For<Inventory, int>(
new InsufficientStock(),
currentValue: StockQuantity,
message: $"Insufficient stock. Current: {StockQuantity}, Requested: {quantity}");
StockQuantity = StockQuantity.Subtract(quantity);
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new StockDeductedEvent(Id, ProductId, quantity));
return unit;
}
}

When signals appear that Aggregate boundaries are not appropriate in an operating system, consider splitting or merging.

Split Signals — Consider splitting if any of the following apply. The most common signal is frequent concurrency conflicts.

SignalSymptomExample
Frequent concurrency conflictsRepeated DbUpdateConcurrencyExceptionFull Product lock on every order
Change frequency imbalanceOnly some attributes change frequentlyCatalog (low freq) vs Stock (high freq)
Invariant independenceNo interdependent invariants between attribute groupsPrice changes do not affect stock rules

Merge Signals — Consider merging if all of the following conditions apply:

SignalSymptomExample
Always changed togetherTwo Aggregates always modified simultaneously in same UsecaseWhen A is modified, B must be too
Mutual invariant dependencyA invariant depends on B stateAggregate constraint
Separate transactions impossibleEventual consistency cannot meet business needsImmediate consistency required

Split Case: Product -> Product + Inventory

Section titled “Split Case: Product -> Product + Inventory”

Before — Single Product Aggregate:

┌────────────────────────────────────┐
│ Product Aggregate │
│ │
│ ProductName, Description, Price │ <- Low-freq changes (admin)
│ StockQuantity │ <- High-freq changes (every order)
│ DeductStock(), HasLowStock() │
│ │
│ Problem: Full Product concurrency │
│ conflicts during order processing │
└────────────────────────────────────┘

The Product + Inventory diagram above shows the result after splitting.

Split Rationale:

  • Catalog info (Name, Description, Price) and stock (StockQuantity) are invariant-independent — price changes do not affect stock rules
  • Stock changes every order (high freq), catalog only by admins (low freq) — change frequency imbalance
  • After separation, IConcurrencyAware (RowVersion) applied only to Inventory — detects only stock conflicts

Connection Method:

  • Inventory references Product by ProductId via ID reference (not object reference, see Cross-Aggregate Relationships)
  • When creating Product in Application Layer, Inventory is also created (same Usecase)
  • Stock deduction is requested directly to Inventory Aggregate

The principle from Section 1 is one transaction = one Aggregate change. In practice, patterns are classified as follows.

Pattern Classification:

PatternAllowedExampleRationale
Single Aggregate changeDeductStockCommand: Changes only InventoryFollows principle
Read + single Aggregate changeCreateOrderCommand: Read Product -> Create OrderReads cause no contention
Concurrent creation (same BC)Exception allowedCreateProductCommand: Create Product + Inventory simultaneouslySee conditions below
Concurrent change (existing)Order creation + Inventory deduction during order processingConcurrency conflict risk

Conditions for Allowing Concurrent Creation ExceptionAll of the following must be met:

  1. Within the same Bounded Context: Do not create Aggregates from different BCs simultaneously
  2. Only at creation time: New Aggregate creation, not existing Aggregate state change
  3. No mutual invariants: No invariants between the two Aggregates that depend on each other’s state

The key point to note in the following code is that while Product and Inventory can be created simultaneously, changing the state of an existing Aggregate while simultaneously creating another Aggregate is prohibited.

// ✅ Concurrent creation allowed: Product + Inventory (CreateProductCommand)
// - Same BC, creation time, no mutual invariants
FinT<IO, Response> usecase =
from exists in _productRepository.Exists(new ProductNameUniqueSpec(productName))
from _ in guard(!exists, /* ... */)
from createdProduct in _productRepository.Create(product)
from createdInventory in _inventoryRepository.Create(
Inventory.Create(createdProduct.Id, stockQuantity))
select new Response(/* ... */);
// ❌ Concurrent change prohibited: Order creation + Inventory deduction
// - Inventory is an existing Aggregate state change -> must be handled in separate transaction
FinT<IO, Response> usecase =
from inventory in _inventoryRepository.GetByProductId(productId)
from _1 in inventory.DeductStock(quantity) // Existing Aggregate change!
from updated in _inventoryRepository.Update(inventory)
from order in _orderRepository.Create(
Order.Create(productId, quantity, unitPrice, shippingAddress)) // Simultaneously creating another Aggregate
select new Response(/* ... */);

The IConcurrencyAware interface is selectively applied to high-contention Aggregates.

// Implementing IConcurrencyAware on Aggregate Root
public sealed class Inventory : AggregateRoot<InventoryId>, IAuditable, IConcurrencyAware
{
public byte[] RowVersion { get; private set; } = [];
// ...
}
// See 13-adapters.md for EF Core Configuration and Mapper mapping

Application Decision Criteria:

SituationIConcurrencyAware AppliedReason
Stock deduction (order processing)AppliedMultiple users deducting simultaneously
Catalog info modificationNot neededOnly admins, low frequency
Order status changeDependsEvaluate concurrent state change possibility
Customer info modificationNot neededOnly self-modified, low conflict risk

When a concurrency conflict occurs in an Aggregate with IConcurrencyAware applied, it is handled with the following flow.

Error Flow:

Request -> Handler -> UoW.SaveChanges()
├─ Success -> Normal response
└─ DbUpdateConcurrencyException
→ AdapterError("ConcurrencyConflict")
→ Pipeline
-> Error response (delegated to client)

Current Strategy: Fail-Fast

// EfCoreUnitOfWork: Converts concurrency exception to AdapterError, returns without retry
// Error type definition: public sealed record ConcurrencyConflict : AdapterErrorType.Custom;
public virtual FinT<IO, Unit> SaveChanges(CancellationToken cancellationToken = default)
{
return IO.liftAsync(async () =>
{
try
{
await _dbContext.SaveChangesAsync(cancellationToken);
return Fin.Succ(unit);
}
catch (DbUpdateConcurrencyException ex)
{
return AdapterError.FromException<EfCoreUnitOfWork>(
new ConcurrencyConflict(), ex);
}
});
}

Strategy Comparison:

StrategyImplementationSuitable Situations
Fail-Fast (current)Immediately returns error on conflict, client decides retryLow conflict frequency, client has retry logic
Application retry (not implemented)Auto-retry N times in Handler then failHigh conflict frequency, retry always safe for idempotent operations (e.g., operations with same side effects like query-then-update)

Fail-Fast Selection Rationale:

  • Handlers focus on business logic — retry policy is an infrastructure concern
  • Whether retry is safe (idempotency) differs per Usecase — blanket auto-retry is risky
  • If conflict frequency increases, consider Aggregate splitting first (resolving root cause)

Order Aggregate: Cross-Aggregate Reference + Value Calculation

Section titled “Order Aggregate: Cross-Aggregate Reference + Value Calculation”
┌──────────────────────────────────────┐
│ Order Aggregate │
│ │
│ ┌───────────────────┐ │
│ │ Order (Root) │ │
│ │ - ProductId ─────────→ Product Aggregate (ID ref)
│ │ - Quantity │ ← VO │
│ │ - Money (Unit) │ ← VO │
│ │ - Money (Total) │ <- VO (calculated) │
│ │ - ShippingAddr │ ← VO │
│ └───────────────────┘ │
│ │
└──────────────────────────────────────┘

Invariants:

  • TotalAmount = UnitPrice x Quantity (calculated at creation)

Boundary Rationale:

  • Order has an independent lifecycle
  • References Product Aggregate only by ProductId (no object reference)
  • Product validation (IProductCatalog) is performed in Application Layer before Order creation
[GenerateEntityId]
public sealed class Order : AggregateRoot<OrderId>, IAuditable
{
// Cross-Aggregate reference: store only ID
public ProductId ProductId { get; private set; }
public Quantity Quantity { get; private set; }
public Money UnitPrice { get; private set; }
public Money TotalAmount { get; private set; }
public ShippingAddress ShippingAddress { get; private set; }
public static Order Create(
ProductId productId,
Quantity quantity,
Money unitPrice,
ShippingAddress shippingAddress)
{
// Invariant: TotalAmount = UnitPrice × Quantity
var totalAmount = unitPrice.Multiply(quantity);
var order = new Order(OrderId.New(), productId, quantity, unitPrice, totalAmount, shippingAddress);
order.AddDomainEvent(new CreatedEvent(order.Id, productId, quantity, totalAmount));
return order;
}
}

The mistake of putting everything related into a single Aggregate.

// ❌ God Aggregate
public class Customer : AggregateRoot<CustomerId>
{
public CustomerName Name { get; private set; }
public List<Order> Orders { get; } // Should be a separate Aggregate
public List<Product> WishList { get; } // Should be a separate Aggregate
public List<Review> Reviews { get; } // Should be a separate Aggregate
public List<PaymentMethod> Payments { get; } // Should be a separate Aggregate
}
// ✅ Small Aggregate + ID reference
public sealed class Customer : AggregateRoot<CustomerId>
{
public CustomerName Name { get; private set; }
public Email Email { get; private set; }
public Money CreditLimit { get; private set; }
// Order, WishList etc. are each independent Aggregates
}

Decision Criteria: “Is this data absolutely necessary to protect the Aggregate Root’s invariants?”

Direct Entity References Between Aggregates

Section titled “Direct Entity References Between Aggregates”
// ❌ Direct Entity reference between Aggregates
public class Order : AggregateRoot<OrderId>
{
public Product Product { get; private set; } // Direct reference
public Customer Customer { get; private set; } // Direct reference
}
// ✅ Reference by ID only
public sealed class Order : AggregateRoot<OrderId>
{
public ProductId ProductId { get; private set; } // ID reference
// Use Domain Port when Customer info is needed
}
// ❌ Stock validation in Application Layer
public class DeductStockUsecase
{
public async Task Handle(DeductStockCommand cmd)
{
var inventory = await _inventoryRepo.GetByProductId(cmd.ProductId);
// Invariant validation is outside the Aggregate!
if (inventory.StockQuantity < cmd.Quantity)
throw new InsufficientStockException();
inventory.StockQuantity -= cmd.Quantity; // Direct modification!
}
}
// ✅ Invariant protection inside Aggregate Root
public class DeductStockUsecase
{
public async Task Handle(DeductStockCommand cmd)
{
var inventory = await _inventoryRepo.GetByProductId(cmd.ProductId);
// State change through Aggregate Root method
var result = inventory.DeductStock(cmd.Quantity);
// Handle error if result is Fail
}
}
// ❌ Unnecessarily making Tag an Aggregate Root
public class Tag : AggregateRoot<TagId>
{
public TagName Name { get; private set; }
// Tag does not need independent query/save
// Accessing through Product is sufficient
}
// ✅ Tag is sufficient as a child Entity
public sealed class Tag : Entity<TagId>
{
public TagName Name { get; private set; }
}

Decision Criteria: “Does this Entity need an independent Repository?”


Frequent DbUpdateConcurrencyException Occurrences

Section titled “Frequent DbUpdateConcurrencyException Occurrences”

Cause: A single Aggregate contains too much data, causing unrelated changes to lock the same Aggregate.

Resolution: Consider Aggregate splitting. Separating attribute groups with different change frequencies (e.g., catalog info vs stock) into separate Aggregates can reduce concurrency conflicts. Apply IConcurrencyAware selectively only to high-contention Aggregates.

Attempting to Change Multiple Aggregates in a Single Transaction

Section titled “Attempting to Change Multiple Aggregates in a Single Transaction”

Cause: Violating the “one transaction = one Aggregate change” principle. Changing multiple Aggregates simultaneously creates concurrency conflict risk and transaction scope expansion problems.

Resolution: Handle Cross-Aggregate changes via eventual consistency through domain events. Concurrent creation is exceptionally allowed only within the same BC, only at creation time, and only when there are no mutual invariants.

Directly Modifying Child Entities Without Going Through the Aggregate Root

Section titled “Directly Modifying Child Entities Without Going Through the Aggregate Root”

Cause: The Aggregate’s invariants are being bypassed from outside. This occurs when child Entity collections are exposed as public or mutable types.

Resolution: Expose collections as IReadOnlyList<T> and ensure state changes are only performed through Aggregate Root methods. Refer to the _tags.AsReadOnly() pattern.


Q1. What is the difference between Aggregate Root and regular Entity?

Section titled “Q1. What is the difference between Aggregate Root and regular Entity?”

Aggregate Root inherits AggregateRoot<TId>, can publish domain events, and has an independent Repository. Regular Entity inherits Entity<TId>, is accessible only through the Aggregate Root, and has no independent Repository.

CharacteristicAggregate RootRegular Entity
Base classAggregateRoot<TId>Entity<TId>
Domain eventsCan publishCannot
RepositoryPresentNone
External accessDirectThrough Root only

Q2. How do you determine Aggregate boundaries?

Section titled “Q2. How do you determine Aggregate boundaries?”

Key question: “Is this Entity independently stored/queried?” If an independent lifecycle is needed, it is an Aggregate Root; if it depends on another Root, it is a child Entity. Additionally, ask “Is this data absolutely necessary to protect the Root’s invariants?” to determine inclusion.

Q3. Does using eventual consistency via domain events cause data inconsistency?

Section titled “Q3. Does using eventual consistency via domain events cause data inconsistency?”

Eventual consistency, unlike immediate consistency, allows temporary inconsistency. Consistency is guaranteed once the event handler completes processing. Consider Aggregate merging only when business requirements absolutely require immediate consistency.

Q4. Should IConcurrencyAware be applied to all Aggregates?

Section titled “Q4. Should IConcurrencyAware be applied to all Aggregates?”

No. Apply it only to high-contention Aggregates where multiple users change simultaneously (e.g., stock deduction). It is unnecessary for Aggregates that only admins change infrequently (e.g., catalog info, customer info).

Q5. Under what conditions is the concurrent creation exception allowed?

Section titled “Q5. Under what conditions is the concurrent creation exception allowed?”

It is allowed within the same Bounded Context, only at new Aggregate creation time, and only when there are no mutual invariants between the two Aggregates. Simultaneously changing existing Aggregate state and creating/changing another Aggregate is prohibited.