If you’ve dipped your toes into Domain-Driven Design (DDD), you’ve likely heard the term Aggregates thrown around.
They’re a cornerstone of DDD, but they can feel a bit abstract at first, **like a puzzle piece you’re not quite sure where to place**.
Today, we’re going to unpack what Aggregates are, why they’re essential, when to use them, and where they fit in your domain.
We’ll cap it off with a practical example and some ideas to inspire your next project.
Let’s dive in!
What Are Aggregates?
In DDD, an Aggregate is a cluster of related objects (entities and Value Objects) that work together as a single unit.
At the heart of every Aggregate is an Aggregate Root, an entity that serves as the entry point and gatekeeper for the whole group.
The Aggregate defines a boundary around these objects, ensuring they stay consistent and enforcing the business rules that govern them.
Think of an Aggregate as a small, self-contained kingdom. The Aggregate Root is the queen or king, and the other objects (entities or Value Objects) are loyal subjects.
You can only interact with the kingdom through the ruler. This setup ensures that all changes within the boundary follow the domain’s invariants (fancy word for “rules that must always hold true”).
Why Use Aggregates?
Aggregates are all about consistency and simplification. Here’s why they matter:
- Consistency: They guarantee that changes to the objects inside the boundary respect business rules, avoiding invalid states.
- Encapsulation: By limiting access to the Aggregate Root, you hide internal complexity and reduce the risk of misuse.
- Transaction Boundary: An Aggregate defines what gets updated together in a single transaction, keeping your data integrity intact.
- Scalability: Smaller, well-defined Aggregates make it easier to reason about your domain and scale your system.
Without Aggregates, you might end up with a tangled mess of objects, where changes to one part accidentally break another. They’re like the guardrails that keep your domain model on track.
When Should You Reach for Aggregates?
Aggregates come into play when you’re modeling a domain with complex relationships and rules that need to stay consistent. Use them when:
- You have a group of objects that must adhere to invariants (e.g., an order can’t have items without a valid customer).
- You need to define clear boundaries in a large domain to avoid chaos.
- You’re dealing with transactional consistency: changes to multiple objects should succeed or fail together.
- You want to simplify how other parts of the system interact with your domain.
They’re not for everything, though. If you’re just dealing with a standalone entity or a simple Value Object with no dependencies, you probably don’t need an Aggregate.
Where Do Aggregates Fit In?
Aggregates live in the heart of your domain layer in a DDD architecture.
They’re the building blocks that enforce your business logic, sitting between your application services and the persistence layer (like a database).
They’re where the real domain magic happens; where rules are upheld, and the model reflects the problem you’re solving.
Let’s see this in action with a real-world example.
A Real-World Example: Managing an Order in an E-Commerce System
Imagine you’re building an e-commerce platform.
A core part of your domain is the Order
, which includes line items and a customer reference.
You need to ensure that an order can’t be placed without at least one item and that the total doesn’t exceed a customer’s credit limit.
This is a perfect candidate for an Aggregate, with Order
as the Aggregate Root.
Here’s how it might look:
public class Order
{
public int OrderId { get; private set; }
public int CustomerId { get; private set; }
private readonly List<OrderItem> _items = new();
public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
public decimal Total => _items.Sum(item => item.Quantity * item.UnitPrice);
public OrderStatus Status { get; private set; }
private Order(int customerId)
{
CustomerId = customerId;
Status = OrderStatus.Pending;
}
public static Order Create(int customerId)
{
return new Order(customerId);
}
public void AddItem(int productId, decimal unitPrice, int quantity)
{
if (quantity <= 0)
throw new ArgumentException("Quantity must be positive.");
var item = new OrderItem(productId, unitPrice, quantity);
_items.Add(item);
// Simulate checking customer's credit limit (in a real app, this might query a service)
const decimal creditLimit = 1000m;
if (Total > creditLimit)
throw new InvalidOperationException("Order total exceeds customer credit limit.");
}
public void PlaceOrder()
{
if (!_items.Any())
throw new InvalidOperationException("Cannot place an order with no items.");
Status = OrderStatus.Placed;
}
}
public class OrderItem
{
public int ProductId { get; }
public decimal UnitPrice { get; }
public int Quantity { get; }
internal OrderItem(int productId, decimal unitPrice, int quantity)
{
ProductId = productId;
UnitPrice = unitPrice;
Quantity = quantity;
}
}
public enum OrderStatus
{
Pending,
Placed
}
Let’s break this down:
- Aggregate Root:
Order
is the root, controlling access toOrderItem
objects via a read-only list. - Invariants: The
AddItem
method ensures the total stays within the credit limit, andPlaceOrder
checks for at least one item. - Encapsulation:
OrderItem
has an internal constructor, so onlyOrder
can create instances. - Consistency: All changes (adding items, placing the order) happen through the Aggregate Root, keeping the state valid.
Here’s how you’d use it:
var order = Order.Create(42); // Customer Id = 42
order.AddItem(101, 25.99m, 2); // Product 101, $25.99 each, Quantity 2
order.AddItem(102, 10.00m, 1); // Product 102, $10.00 each, Quantity 1
order.PlaceOrder();
Console.WriteLine($"Order total: {order.Total}, Status: {order.Status}");
And the output should read:
Order total: 61.98, Status: Placed
Try adding too many items, and you’ll hit the credit limit exception. Try placing an empty order, and it’ll complain. The Aggregate ensures everything stays consistent.
More Aggregate Examples to Spark Ideas
Here are a few more Aggregates to get your creative juices flowing, including some simpler ones using record
for supporting Value Objects.
1. Reservation (Hospitality Domain)
A Reservation
Aggregate might include a guest ID, room number, and dates. The root ensures the dates don’t overlap with other reservations for the same room.
2. Bank Account
A BankAccount
Aggregate could track transactions and balance, enforcing rules like “no overdraft beyond a limit.” The transactions would be internal entities or Value Objects.
3. Task (With Record for Value Object)
In a task management app, a Task
Aggregate might include a title and priority (as a Value Object):
public class Task
{
public int TaskId { get; private set; }
public string Title { get; private set; }
public Priority Priority { get; private set; }
public Task(string title, Priority priority)
{
Title = title;
Priority = priority;
}
}
public record Priority(int Level)
{
public Priority(int level) : this(level)
{
if (level is < 1 or > 5)
throw new ArgumentException("Priority must be between 1 and 5.");
}
}
Usage: var task = new Task("Fix bug", new Priority(3));
.
The Priority
record keeps it simple and immutable.
4. Shopping Cart (With Record)
A ShoppingCart Aggregate could manage items with a CartItem Value Object:
public class ShoppingCart
{
public int CartId { get; private set; }
private readonly List<CartItem> _items = new();
public IReadOnlyList<CartItem> Items => _items.AsReadOnly();
public void AddItem(int productId, int quantity)
{
_items.Add(new CartItem(productId, quantity));
}
}
public record CartItem(int ProductId, int Quantity);
The CartItem
record is a lightweight Value Object within the Aggregate.
Wrapping Up
Aggregates are the unsung guardians of your domain in DDD.
They enforce consistency, draw clear boundaries, and simplify how you manage complex relationships. All while keeping your business rules front and center.
Whether you’re crafting an e-commerce order, a bank account, or a simple task list, Aggregates give your domain the structure it needs to thrive.
Next time you’re designing a domain model, ask yourself: where do my consistency boundaries lie? Define your Aggregates, appoint their roots, and watch your code become more robust and expressive.
Happy coding! ⚡