Chapter 3: The Boundary of Integrity - Aggregates
Of course. Now that we know how to create domain objects with behavior (Rich Domain Model) and meaning (Value Objects), the next step is to define how they work together to ensure data integrity. We now arrive at one of the most important strategic patterns in DDD: the Aggregate.
The Scenario 📝
- System: An e-commerce application.
- Business Rule (Invariant): "The total price of an
Ordermust always be equal to the sum of allOrderItemswithin it." - Problem: How do we ensure this rule is never broken?
The Problematic Design: No Clear Boundary
csharp
public class Order
{
public int Id { get; private set; }
public decimal TotalPrice { get; set; } // Public set
// MISTAKE: Exposing a mutable List to the outside world
public List<OrderItem> Items { get; private set; }
public Order()
{
Items = new List<OrderItem>();
}
}With this design, another developer could write the following code in any service:
csharp
var order = orderRepository.GetById(123);
var newItem = new OrderItem(...);
// Directly add to the list, bypassing all of Order's rules
order.Items.Add(newItem);
// SAVING AN ORDER IN AN INVALID STATE!
// order.TotalPrice is now incorrect and no longer matches the sum of its items.
orderRepository.Save(order);Analysis of the Problem 🧐
The Order object cannot protect its own integrity. Its internal components (Items) can be modified from the outside without its knowledge. The business rule (invariant) has been violated.
The Solution: Use an Aggregate to Protect Integrity ✅
The Logic: An
Aggregateis a cluster of related domain objects (entities and value objects) that are treated as a single unit for data changes.Aggregate Root: A single, specific entity within the aggregate that serves as the "entry point." All external interactions with the aggregate must go through the Root. In this case,Orderis the Aggregate Root.- Boundary: Everything inside (like
OrderItem) is protected by the Aggregate Root.
Optimized Code:
csharppublic class Order // This is the Aggregate Root { public int Id { get; private set; } public Money TotalPrice { get; private set; } // 1. Encapsulate the list of items, preventing external modification private readonly List<OrderItem> _items = new(); public IReadOnlyList<OrderItem> Items => _items.AsReadOnly(); // 2. Provide public methods to modify the aggregate public void AddItem(Product product, int quantity) { if (product.IsInStock == false) { throw new Exception("Product is out of stock."); } var newItem = new OrderItem(product.Id, quantity, product.Price); _items.Add(newItem); // 3. Ensure the invariant is always true AFTER EVERY BEHAVIOR this.RecalculateTotalPrice(); } public void RemoveItem(Guid orderItemId) { _items.RemoveAll(item => item.Id == orderItemId); this.RecalculateTotalPrice(); } private void RecalculateTotalPrice() { // Logic to calculate the total price... var total = _items.Sum(item => item.Price.Amount * item.Quantity); this.TotalPrice = new Money(total, "USD"); // Assuming USD for simplicity } }
Analysis of the Result ✨
- Data Integrity: Now, no one can add an
OrderItemto anOrderwithout going through theAddItem()method. This method ensures all business rules are followed andTotalPriceis always updated. The aggregate is always in a valid state. - Centralized Logic: All logic related to managing items in an order is located within the
Orderclass. - Clear Access Rules:
- The outside world should only have a Repository for the Aggregate Root (e.g.,
IOrderRepository), never anIOrderItemRepository. - A database transaction should only update a single aggregate at a time to ensure consistency.
- The outside world should only have a Repository for the Aggregate Root (e.g.,
Conclusion:
- The Aggregate is a core strategic pattern in DDD that helps you manage complexity by creating consistent boundaries.
- The Aggregate Root acts as a guardian, ensuring that all business rules (invariants) within the aggregate are always maintained.