An aggregate root is the entity that controls changes to a group of domain objects that must stay consistent together. In C#, it exposes business operations, protects child collections, and validates state transitions. Load and save through the root, while persistence handles transactions and detects conflicting concurrent updates.
What Is an Aggregate Root?
In Domain-Driven Design, an aggregate is a cluster of related objects treated as a single unit for data consistency. The aggregate root is the entry point - the only object through which external code interacts with the aggregate.
An Order aggregate root contains LineItem entities.
You never modify a line item directly - you always go through the Order.
The Rules
Rule 1: External Access Through the Root Only
External code can read an exposed child entity, but commands that change it must pass through the root:
// Wrong - accessing LineItem directly
var lineItem = await _lineItemRepository.GetByIdAsync(lineItemId);
lineItem.ChangeQuantity(5);
// Correct - going through the aggregate root
var order = await _orderRepository.GetByIdAsync(orderId);
order.ChangeLineItemQuantity(lineItemId, 5);
The root controls all modifications to maintain consistency.
Rule 2: One Aggregate per Transaction
Prefer one aggregate per transaction when the business accepts eventual consistency. For inventory, use a pending reservation state until stock is actually reserved:
// Shared transaction when immediate consistency is required
var order = await _orderRepository.GetByIdAsync(orderId);
var inventory = await _inventoryRepository.GetByIdAsync(productId);
order.Confirm();
inventory.Reduce(quantity);
await _unitOfWork.SaveChangesAsync(ct);
// Separate transactions when eventual consistency is acceptable
var order = await _orderRepository.GetByIdAsync(orderId);
order.RequestConfirmation(); // Raises an inventory reservation request
await _unitOfWork.SaveChangesAsync(ct);
// A reliable handler reserves stock, then confirms or rejects the order.
Use domain events for cross-aggregate side effects. An event alone does not make delivery reliable or prevent overselling. Persist messages through an outbox, reserve stock with concurrency protection, and make retries idempotent. If the business needs both changes immediately, an atomic transaction can be appropriate; the guideline is not a database restriction.
Rule 3: Enforce Invariants Inside the Aggregate
The aggregate root is responsible for enforcing all domain invariants:
These snippets use application-defined Money, event, and error types.
Money represents non-negative USD amounts here; multi-currency orders need an explicit currency policy.
The first excerpt focuses on the add operation, using the LineItem type shown later:
public class Order : AggregateRoot
{
private readonly List<LineItem> _lineItems = [];
private const int MaxLineItems = 50;
public OrderStatus Status { get; private set; } = OrderStatus.Draft;
public Money TotalAmount { get; private set; } = Money.Zero();
public IReadOnlyCollection<LineItem> LineItems => _lineItems.AsReadOnly();
public void AddLineItem(Guid productId, int quantity, Money unitPrice)
{
if (Status != OrderStatus.Draft)
throw new DomainException(
"Can only add items to draft orders.");
if (quantity <= 0)
throw new DomainException(
"Quantity must be positive.");
var existingItem = _lineItems.FirstOrDefault(
li => li.ProductId == productId);
if (existingItem is null && _lineItems.Count >= MaxLineItems)
throw new DomainException(
$"Order cannot have more than {MaxLineItems} items.");
if (existingItem is not null && existingItem.UnitPrice != unitPrice)
throw new DomainException("Existing item has a different price.");
ArgumentNullException.ThrowIfNull(unitPrice);
var newTotal = Money.Create(
TotalAmount.Amount + unitPrice.Amount * quantity);
if (existingItem is not null)
{
existingItem.IncreaseQuantity(quantity);
}
else
{
_lineItems.Add(new LineItem(
Guid.NewGuid(), Id, productId, quantity, unitPrice));
}
TotalAmount = newTotal;
}
}
Check the line limit only when adding a new product, so an existing line can still grow when the order has 50 lines. Calculate values that can fail before mutating the collection.
Rule 4: Reference Other Aggregates by ID
Aggregates don't hold direct references to other aggregates:
// Wrong - direct reference to another aggregate
public class Order : AggregateRoot
{
public Customer Customer { get; private set; } // Another aggregate!
}
// Correct - reference by ID
public class Order : AggregateRoot
{
public Guid CustomerId { get; private set; } // Just the ID
}
This makes dependencies explicit and keeps aggregate loading separate. A navigation property alone does not cause EF Core to load another entity.
Implementation
The Base Class
public abstract class AggregateRoot : Entity
{
private readonly List<IDomainEvent> _domainEvents = [];
public IReadOnlyCollection<IDomainEvent> DomainEvents =>
_domainEvents.AsReadOnly();
protected void RaiseDomainEvent(IDomainEvent domainEvent)
{
_domainEvents.Add(domainEvent);
}
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
}
public abstract class Entity
{
public Guid Id { get; protected init; }
}
Implementing Order Operations
Put these operations in Order.cs, alongside your domain's event and value-object definitions:
public class Order : AggregateRoot
{
private readonly List<LineItem> _lineItems = [];
private Order() { } // EF Core
public Guid CustomerId { get; private set; }
public OrderStatus Status { get; private set; } = OrderStatus.Draft;
public Money TotalAmount { get; private set; } = Money.Zero();
public string? CancellationReason { get; private set; }
public DateTime CreatedAt { get; private set; }
public IReadOnlyCollection<LineItem> LineItems => _lineItems.AsReadOnly();
public static Order Create(Guid customerId, IDateTimeProvider dateTime)
{
if (customerId == Guid.Empty)
throw new DomainException("Customer ID is required.");
ArgumentNullException.ThrowIfNull(dateTime);
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = customerId,
Status = OrderStatus.Draft,
TotalAmount = Money.Zero(),
CreatedAt = dateTime.UtcNow
};
order.RaiseDomainEvent(new OrderCreatedDomainEvent(order.Id));
return order;
}
public void AddLineItem(
Guid productId, int quantity, Money unitPrice)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Order is not in draft status.");
var lineItem = new LineItem(
Guid.NewGuid(), Id, productId, quantity, unitPrice);
var newTotal = Money.Create(
TotalAmount.Amount + unitPrice.Amount * quantity);
_lineItems.Add(lineItem);
TotalAmount = newTotal;
}
public void RemoveLineItem(Guid lineItemId)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Order is not in draft status.");
var lineItem = _lineItems.FirstOrDefault(
li => li.Id == lineItemId)
?? throw new DomainException("Line item not found.");
_lineItems.Remove(lineItem);
RecalculateTotal();
}
public void Confirm()
{
if (Status != OrderStatus.Draft)
throw new DomainException("Only draft orders can be confirmed.");
if (_lineItems.Count == 0)
throw new DomainException("Cannot confirm an empty order.");
Status = OrderStatus.Confirmed;
RaiseDomainEvent(new OrderConfirmedDomainEvent(Id, TotalAmount));
}
public void Cancel(string reason)
{
if (Status == OrderStatus.Cancelled)
throw new DomainException("Order is already cancelled.");
if (Status == OrderStatus.Shipped)
throw new DomainException("Cannot cancel a shipped order.");
if (string.IsNullOrWhiteSpace(reason))
throw new DomainException("Cancellation reason is required.");
Status = OrderStatus.Cancelled;
CancellationReason = reason;
RaiseDomainEvent(new OrderCancelledDomainEvent(Id, reason));
}
private void RecalculateTotal()
{
TotalAmount = Money.Create(
_lineItems.Sum(li => li.UnitPrice.Amount * li.Quantity));
}
}
The Internal Entity
public class LineItem : Entity
{
internal LineItem(
Guid id, Guid orderId, Guid productId,
int quantity, Money unitPrice)
{
if (productId == Guid.Empty)
throw new DomainException("Product ID is required.");
if (quantity <= 0)
throw new DomainException("Quantity must be positive.");
ArgumentNullException.ThrowIfNull(unitPrice);
Id = id;
OrderId = orderId;
ProductId = productId;
Quantity = quantity;
UnitPrice = unitPrice;
}
private LineItem() { } // EF Core
public Guid OrderId { get; private set; }
public Guid ProductId { get; private set; }
public int Quantity { get; private set; }
public Money UnitPrice { get; private set; } = null!;
internal void IncreaseQuantity(int amount)
{
if (amount <= 0)
throw new DomainException("Amount must be positive.");
Quantity = checked(Quantity + amount);
}
}
The internal constructor and method restrict access to the domain assembly, not to Order alone.
Other domain code must follow the root's boundary as a design rule.
EF Core can use the private constructor when materializing persisted rows; configure the collection and value-object mappings in infrastructure.
Repository per Aggregate
Each aggregate root gets one repository:
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(Guid id, CancellationToken ct);
void Add(Order order);
void Remove(Order order);
}
Commands use IOrderRepository rather than an independent ILineItemRepository.
Its implementation must load the children needed to enforce the operation's invariants, for example with Include.
Read-only queries can project child data directly.
Sizing Aggregates
Keep aggregates small:
// Too large - the entire e-commerce domain in one aggregate
public class Store : AggregateRoot
{
public List<Order> Orders { get; }
public List<Product> Products { get; }
public List<Customer> Customers { get; }
}
// Right size - focused on one consistency boundary
public class Order : AggregateRoot
{
private readonly List<LineItem> _lineItems = [];
public IReadOnlyCollection<LineItem> LineItems => _lineItems.AsReadOnly();
}
See Aggregate Design in DDD for detailed sizing guidelines.
Summary
Aggregate root rules:
- External access through root only - all modifications go through the aggregate root
- Prefer one aggregate per transaction - use reliable events when delayed cross-aggregate updates are acceptable
- Enforce invariants inside - the aggregate is always in a valid state
- Reference other aggregates by ID - not by direct object reference
- One repository per aggregate - load and save the entire aggregate
- Keep aggregates small - focus on consistency boundaries
The aggregate root is the consistency boundary of your domain. Design it carefully. Guarding methods protect one in-memory instance; EF Core concurrency control is still needed when two requests update the same aggregate. Ensure a token on the root changes for child-only edits too, otherwise a root token alone will not detect those conflicts.
Thanks for reading.
And stay awesome!
Frequently Asked Questions
What is an aggregate root in DDD?
The aggregate root is the single entity that acts as the entry point to an aggregate. All external code interacts with the aggregate through the root, which enforces the business rules that keep the aggregate consistent.
Why should each transaction modify only one aggregate?
One aggregate per transaction is a useful default when the business accepts eventual consistency. Reliable domain events coordinate later updates. If both changes must be atomic, a shared transaction can be justified and the boundaries should be reviewed.
Does every entity need its own repository?
No. Only aggregate roots get repositories. Internal entities like order line items are loaded and saved as part of their aggregate.
How do I prevent external code from creating internal entities?
Give the child an internal constructor and internal mutation methods to restrict access to the domain assembly. Other code in that assembly still has access, so routing changes through the root remains a design rule.
Should aggregate roots expose collections publicly?
Expose a read-only wrapper such as AsReadOnly around a private list. Returning the list itself as IReadOnlyCollection allows a cast back. Child mutation methods must also be protected.



