
Understanding Aggregates and Boundaries in DDD - Defining Consistency Boundaries
Author - Abdul Rahman (Bhai)
DDD
22 Articles
Table of Contents
What we gonna do?
In this article, we'll explore Aggregates and their boundaries in Domain-Driven Design. An aggregate is a cluster of domain objects that can be treated as a single unit for data changes. Understanding how to define aggregate boundaries is crucial for building consistent, scalable domain models in .NET applications.
We'll learn how to identify aggregate roots, define transaction boundaries, maintain consistency within aggregates, and determine when to split large aggregates into smaller ones. Proper aggregate design prevents inconsistent states, reduces concurrency conflicts, and makes your domain model easier to understand and maintain.
Through practical examples, we'll discover the rules that govern aggregates: external references must go through the root, consistency boundaries align with transaction boundaries, and each aggregate should be independently loadable and saveable.
Why we gonna do?
The Growing Entity Problem
As applications evolve, entities tend to accumulate references to related objects. This leads to what's known as the "god object" problem where one entity holds references to everything related to it:
// Problem: Everything is connected to everything
public class License
{
public Guid Id { get; set; }
public string LicenseKey { get; set; }
// Direct references to related entities
public Customer Customer { get; set; } // ❌ Brings in customer data
public Product Product { get; set; } // ❌ Brings in product catalog
public List<Payment> Payments { get; set; } // ❌ Loads payment history
public List<Invoice> Invoices { get; set; } // ❌ Loads billing data
public List<SupportTicket> Tickets { get; set; } // ❌ Loads support system
public List<AuditLog> AuditTrail { get; set; } // ❌ Entire audit history
}
// Loading a license pulls in the entire database
var license = _repository.GetById(licenseId);
// Behind the scenes: 6+ table joins, thousands of records loaded
Consequences of Poor Boundaries:
- Performance Issues: Loading one object requires fetching hundreds of related records
- Memory Bloat: Object graphs consume excessive memory
- Concurrency Conflicts: Multiple users editing different aspects lock the entire object
- Unclear Transactions: What should be updated in one transaction vs. multiple transactions?
- Testing Complexity: Must set up entire object graph for simple tests
The Consistency Boundary Challenge
Without clear aggregate boundaries, it's unclear which objects must be consistent with each other at all times:
// Problem: Unclear which changes must happen together
public void UpgradeLicense(Guid licenseId)
{
var license = _repository.GetLicense(licenseId);
// Should these all happen in the same transaction?
license.Product = _productRepo.GetByName("Pro Edition"); // Change 1
license.MaxActivations = 10; // Change 2
var invoice = new Invoice { ... };
_invoiceRepo.Add(invoice); // Change 3
var notification = new Notification { ... };
_notificationRepo.Add(notification); // Change 4
_emailService.SendUpgradeConfirmation(license.Customer); // Change 5
// If any of these fails, what should roll back?
// Should email be sent even if save fails?
}
Problems:
- Unclear which operations are atomic
- Risk of partial updates leaving system in inconsistent state
- Different developers make different decisions about transaction scope
- External services (email) mixed with database transactions
Why Aggregates Solve These Problems
Aggregates provide clear answers to these questions:
- What data travels together? Objects within an aggregate are loaded/saved as a unit
- What must be consistent? Invariants are enforced within aggregate boundaries
- What's a transaction? One transaction modifies one aggregate
- How do parts communicate? Only through the aggregate root
- When to use eventual consistency? Between aggregates, not within
How we gonna do?
Step 1: Understand Aggregate Components
An aggregate consists of three main parts:
// 1. Aggregate Root - The main entity external code interacts with
public class Order // <-- This is the Aggregate Root
{
public Guid Id { get; private set; } // Root has identity
// 2. Internal Entities - Live inside the aggregate
private readonly List<OrderLine> _lines = new(); // <-- Internal entities
// 3. Value Objects - Describe or measure things
public Money TotalPrice { get; private set; } // <-- Value object
public OrderStatus Status { get; private set; } // <-- Value object
// External code can only access internal entities through the root
public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
}
// Internal entity - only accessible through Order root
public class OrderLine // Not an aggregate root!
{
public Guid Id { get; private set; }
public string ProductName { get; private set; }
public Money UnitPrice { get; private set; }
public int Quantity { get; private set; }
public Money LineTotal => UnitPrice * Quantity;
// Constructor is internal - only Order can create lines
internal OrderLine(Guid id, string productName, Money unitPrice, int quantity)
{
Id = id;
ProductName = productName;
UnitPrice = unitPrice;
Quantity = quantity;
}
}
Step 2: Define Aggregate Boundaries
Use these rules to determine what belongs inside an aggregate:
Rule 1: External objects reference the aggregate root only
// ✅ CORRECT: Reference the Order (root)
public class Customer
{
public Guid Id { get; private set; }
private readonly List<Guid> _orderIds = new(); // Store IDs, not objects
public IReadOnlyCollection<Guid> OrderIds => _orderIds.AsReadOnly();
}
// ❌ WRONG: Don't reference internal entities directly
public class Customer
{
// This violates aggregate boundaries!
public List<OrderLine> FavoriteItems { get; set; } // ❌ Wrong!
}
Rule 2: The root enforces invariants for the whole aggregate
public class Order
{
private readonly List<OrderLine> _lines = new();
private Money? _discountCoupon;
// Invariant: Order must have at least one line
// Root enforces this rule
public void AddLine(string productName, Money unitPrice, int quantity)
{
if (quantity <= 0)
throw new ArgumentException("Quantity must be positive");
// Check for duplicate products (business rule)
if (_lines.Any(l => l.ProductName == productName))
throw new InvalidOperationException(
$"Product {productName} already in order");
var line = new OrderLine(Guid.NewGuid(), productName, unitPrice, quantity);
_lines.Add(line);
RecalculateTotal(); // Maintain consistency
}
public void RemoveLine(Guid lineId)
{
var line = _lines.FirstOrDefault(l => l.Id == lineId);
if (line == null)
throw new InvalidOperationException("Line not found");
_lines.Remove(line);
// Enforce invariant
if (_lines.Count == 0)
throw new InvalidOperationException(
"Cannot remove last line. Cancel order instead.");
RecalculateTotal();
}
private void RecalculateTotal()
{
var subtotal = _lines.Sum(l => l.LineTotal.Amount);
var discount = _discountCoupon?.Amount ?? 0;
TotalPrice = Money.Usd(subtotal - discount);
}
}
Rule 3: Changes happen through the root
// ✅ CORRECT: Modify through root
var order = _orderRepository.GetById(orderId);
order.AddLine("Software License", Money.Usd(299), 1);
order.ApplyDiscount(Money.Usd(30));
_orderRepository.Save(order);
// ❌ WRONG: Don't access internal entities directly
var order = _orderRepository.GetById(orderId);
var line = order.Lines.First();
line.Quantity = 10; // ❌ Can't do this! Line has private setter
Rule 4: Aggregate is loaded and saved as a unit
// Repository works with the aggregate root
public interface IOrderRepository
{
Order GetById(Guid orderId); // Loads entire aggregate
void Save(Order order); // Saves entire aggregate
void Delete(Order order); // Deletes entire aggregate
}
// Implementation handles the entire graph
public class OrderRepository : IOrderRepository
{
private readonly DbContext _context;
public Order GetById(Guid orderId)
{
// Load order with all its lines in one query
return _context.Orders
.Include(o => o.Lines) // All internal entities loaded together
.FirstOrDefault(o => o.Id == orderId);
}
public void Save(Order order)
{
// EF Core tracks changes to entire aggregate
_context.Orders.Update(order);
_context.SaveChanges(); // Saves in one transaction
}
}
Rule 5: One transaction = One aggregate modification
// ✅ CORRECT: Single aggregate per transaction
public void PlaceOrder(Guid orderId)
{
var order = _orderRepository.GetById(orderId);
order.Confirm(); // Changes only Order aggregate
_orderRepository.Save(order);
// Publish event for other aggregates to react
_eventBus.Publish(new OrderConfirmedEvent(orderId));
}
// ❌ WRONG: Modifying multiple aggregates in one transaction
public void PlaceOrderAndUpdateInventory(Guid orderId)
{
var order = _orderRepository.GetById(orderId);
order.Confirm();
var inventory = _inventoryRepository.GetByProductId(productId);
inventory.Reserve(quantity); // ❌ Different aggregate!
_orderRepository.Save(order);
_inventoryRepository.Save(inventory); // ❌ Multiple aggregates!
}
Step 3: Identify Aggregate Boundaries
Use this decision framework:
Ask these questions:
1. "Must these objects be consistent at all times?"
Yes → Same aggregate
No → Different aggregates
2. "Do they have separate lifecycles?"
Yes → Different aggregates
No → Same aggregate
3. "Can they be loaded/saved independently?"
Yes → Different aggregates
No → Same aggregate
4. "Would multiple users edit them concurrently?"
Yes → Different aggregates (reduce conflicts)
No → Same aggregate
5. "Is this causing performance or memory issues?"
Yes → Consider splitting into different aggregates
No → Can remain in same aggregate
Example Application:
// Order Aggregate - includes lines that must be consistent
public class Order
{
public Guid Id { get; private set; }
public Guid CustomerId { get; private set; } // Reference by ID only!
public OrderStatus Status { get; private set; }
private readonly List<OrderLine> _lines = new(); // Part of aggregate
public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
public Money TotalPrice { get; private set; }
}
// Customer Aggregate - separate lifecycle
public class Customer
{
public Guid Id { get; private set; }
public string Name { get; private set; }
public Email EmailAddress { get; private set; }
// References Order aggregate by ID only
private readonly List<Guid> _orderIds = new();
public IReadOnlyCollection<Guid> OrderIds => _orderIds.AsReadOnly();
}
// Inventory Aggregate - independent consistency boundary
public class Inventory
{
public Guid Id { get; private set; }
public Guid ProductId { get; private set; }
public int QuantityAvailable { get; private set; }
public int QuantityReserved { get; private set; }
public void Reserve(int quantity)
{
if (QuantityAvailable < quantity)
throw new InsufficientInventoryException();
QuantityAvailable -= quantity;
QuantityReserved += quantity;
}
}
Step 4: Recognize When to Split Aggregates
Watch for these warning signs that an aggregate is too large:
- Size Issues: Loading the aggregate pulls hundreds of records
- Concurrency Conflicts: Users frequently get "object modified" errors
- No True Invariants: Parts don't actually need to be updated together
- Different Lifecycles: Some parts live much longer than others
- Performance Problems: Operations are slow due to large object graph
// BEFORE: Too large - Order includes everything
public class Order
{
public Guid Id { get; private set; }
private readonly List<OrderLine> _lines = new();
private readonly List<Payment> _payments = new(); // ❌ Different lifecycle
private readonly List<Shipment> _shipments = new(); // ❌ Different lifecycle
private readonly List<Invoice> _invoices = new(); // ❌ Different lifecycle
private readonly List<SupportTicket> _tickets = new(); // ❌ Definitely different!
}
// AFTER: Split into separate aggregates
public class Order // Aggregate 1
{
public Guid Id { get; private set; }
private readonly List<OrderLine> _lines = new(); // ✅ Core data
public Money TotalPrice { get; private set; }
}
public class Payment // Aggregate 2
{
public Guid Id { get; private set; }
public Guid OrderId { get; private set; } // Reference by ID
public Money Amount { get; private set; }
public PaymentStatus Status { get; private set; }
}
public class Shipment // Aggregate 3
{
public Guid Id { get; private set; }
public Guid OrderId { get; private set; } // Reference by ID
public Address DeliveryAddress { get; private set; }
public ShipmentStatus Status { get; private set; }
}
Step 5: Connect Aggregates with Domain Events
When operations span multiple aggregates, use domain events for eventual consistency:
// Step 1: Order aggregate publishes event
public class Order
{
private readonly List<IDomainEvent> _domainEvents = new();
public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
public void Confirm()
{
if (Status == OrderStatus.Confirmed)
throw new InvalidOperationException("Order already confirmed");
Status = OrderStatus.Confirmed;
// Raise event - don't modify other aggregates directly
_domainEvents.Add(new OrderConfirmedEvent(Id, CustomerId, Lines));
}
}
// Step 2: Event handler updates other aggregates
public class OrderConfirmedEventHandler : IEventHandler<OrderConfirmedEvent>
{
private readonly IInventoryRepository _inventoryRepo;
public async Task Handle(OrderConfirmedEvent evt)
{
// Update Inventory aggregate separately
foreach (var line in evt.Lines)
{
var inventory = await _inventoryRepo.GetByProductId(line.ProductId);
inventory.Reserve(line.Quantity);
await _inventoryRepo.Save(inventory);
}
}
}
Summary
- Aggregates are clusters of domain objects treated as a single unit for data changes
- The aggregate root is the only entry point—external code references the root, not internal entities
- One transaction should modify one aggregate; use domain events to coordinate changes across aggregates
- Aggregate boundaries define consistency boundaries—objects within the aggregate must be consistent at all times
- Load and save entire aggregates as a unit through repositories that work with the root
- Split large aggregates when you see performance issues, concurrency conflicts, or lack of true invariants
- Reference other aggregates by ID only, never by direct object reference
- Invariants (business rules) are enforced by the root across the entire aggregate
- Use eventual consistency between aggregates with domain events, immediate consistency within aggregates
- Proper aggregate design improves performance, reduces conflicts, and makes transaction boundaries clear