Aggregates, Invariants, and Consistency
This is the fourth deep-dive in a series on Domain-Driven Design. It builds on the concepts introduced in Domain-Driven Design: A Practical Overview.
An aggregate is a cluster of domain objects that are treated as a single unit for the purpose of data changes. That’s the textbook definition, and it’s accurate, but it undersells the point: the aggregate is a consistency boundary. Everything inside the boundary must be valid after every operation. Everything outside the boundary is eventually consistent.
Getting this boundary right is the hard modeling decision in DDD. Draw it too large and you get contention and performance problems. Draw it too small and your invariants scatter across services. Draw it correctly and your domain model enforces its own rules without external coordination.
The aggregate root
Every aggregate has exactly one aggregate root. It is the entity through which all access to the aggregate’s internals flows. External code never reaches in and modifies a child entity directly. It asks the root to do it.
Here’s a real aggregate root from StormBoard, a collaborative event storming application:
public class Board : AggregateRoot<BoardId>
{
private readonly List<Sticky> _stickies = new();
private readonly List<Connection> _connections = new();
private readonly List<Boundary> _boundaries = new();
public string Name { get; private set; }
public BoardStatus Status { get; private set; }
public BoardType Type { get; private set; }
public IReadOnlyList<Sticky> Stickies => _stickies.AsReadOnly();
public IReadOnlyList<Connection> Connections => _connections.AsReadOnly();
public IReadOnlyList<Boundary> Boundaries => _boundaries.AsReadOnly();
}
Three things to notice. First, the child collections are backed by private List<T> fields. External code sees IReadOnlyList<T>, so it can read but not add, remove, or reorder. Second, all setters are private set. State changes happen through methods on the aggregate root, not through property assignment. Third, the root inherits from AggregateRoot<BoardId>, which provides identity, equality, and domain event support.
This is the fundamental pattern: private state, public behavior.
Factory methods: Create and Hydrate
Aggregates in a DDD system need two construction paths. One creates a brand-new aggregate with a fresh identity. The other reconstitutes an existing aggregate from persisted data. Mixing these concerns into a single constructor creates confusion about whether an ID should be generated or passed in.
The solution is two factory methods: Create for new aggregates, Hydrate for reconstitution.
public static Board Create(ProductId projectId, string name, string createdBy,
BoardType type = BoardType.ProcessModeling)
{
DomainGuard.AgainstNull(projectId, nameof(projectId));
DomainGuard.AgainstNullOrWhiteSpace(name, nameof(name));
DomainGuard.AgainstNullOrWhiteSpace(createdBy, nameof(createdBy));
return new Board(BoardId.New(), projectId, name, createdBy, DateTime.UtcNow,
type: type);
}
public static Board Hydrate(BoardId id, ProductId projectId, string name,
string createdBy, DateTime createdAt, IEnumerable<Sticky> stickies,
BoardStatus status = BoardStatus.Draft, string? description = null,
IEnumerable<Connection>? connections = null,
IEnumerable<Boundary>? boundaries = null,
BoardType type = BoardType.ProcessModeling)
{
var board = new Board(id, projectId, name, createdBy, createdAt,
status: status, description: description, type: type);
board._stickies.AddRange(stickies);
if (connections is not null) board._connections.AddRange(connections);
if (boundaries is not null) board._boundaries.AddRange(boundaries);
return board;
}
The constructor is private. You cannot create a Board without going through one of these factory methods. Create generates a new BoardId, sets the creation timestamp, and validates all inputs. Hydrate takes an existing ID and reconstitutes the aggregate with all its children. No validation, because this data already passed validation when it was originally created.
The pattern makes the two use cases explicit. Application code calls Create. The persistence layer calls Hydrate. There’s no ambiguity.
Invariant enforcement
An invariant is a business rule that must always be true. The aggregate boundary exists to protect these rules. If an invariant can be broken by modifying a child entity directly, the boundary is wrong.
Here’s how StormBoard’s Board aggregate enforces invariants when adding a sticky note:
public Sticky AddSticky(string text, Position position, StickyType type,
decimal? width = null, decimal? height = null,
BoardId? linkedBoardId = null)
{
GuardAgainstReadOnly();
if (!AllowedStickyTypes().Contains(type))
throw new DomainException(
$"Sticky type '{type}' is not available in the current phase.");
if (linkedBoardId is not null && type != StickyType.InternalService)
throw new DomainException(
"Only InternalService stickies can be linked to another board.");
var sticky = Sticky.Create(text, position, type,
width ?? Sticky.DefaultWidth, height ?? Sticky.DefaultHeight,
linkedBoardId);
_stickies.Add(sticky);
return sticky;
}
Three invariants are enforced in this single method. First, GuardAgainstReadOnly() prevents modifications to completed or draft boards. Second, the sticky type must be allowed in the board’s current phase. During event discovery, only DomainEvent stickies are permitted. Third, only InternalService stickies can link to other boards.
These rules cannot be bypassed. There is no other way to add a sticky to a board. The aggregate root is the sole gatekeeper.
The GuardAgainstReadOnly method encodes the board’s lifecycle rules:
private void GuardAgainstReadOnly()
{
if (Status == BoardStatus.Completed)
throw new DomainException("Cannot modify a completed board.");
if (Status == BoardStatus.Draft)
throw new DomainException(
"Cannot modify a board in Draft status. Start the session first.");
}
State transitions as domain logic
Aggregates often have lifecycle states with rules about valid transitions. These rules belong in the aggregate, not in a service or controller.
StormBoard boards progress through phases: Event Discovery, Command Mapping, Policy Discovery, Hot Spot Review.
public void AdvancePhase()
{
if (Type != BoardType.ProcessModeling)
throw new DomainException(
"Only Process Modeling boards support phase advancement.");
Status = Status switch
{
BoardStatus.EventDiscovery => BoardStatus.CommandMapping,
BoardStatus.CommandMapping => BoardStatus.PolicyDiscovery,
BoardStatus.PolicyDiscovery => BoardStatus.HotSpotReview,
_ => throw new DomainException("Board is not in an advanceable phase.")
};
}
The state machine is explicit. You can read the valid transitions from the switch expression. Invalid transitions throw with a clear message. There’s no need for a separate state machine library or a transition table in a database. The aggregate owns its lifecycle.
Entities within aggregates
Child entities have their own identity but live within the aggregate’s boundary. They are created through the aggregate root and are never loaded independently from a repository.
Here’s the Sticky entity, a child of the Board aggregate:
public class Sticky : Entity<StickyId>
{
public string Text { get; private set; }
public Position Position { get; private set; }
public StickyType Type { get; private set; }
public DateTime UpdatedAt { get; private set; }
private Sticky(StickyId id, string text, Position position,
StickyType type, decimal width, decimal height,
DateTime createdAt, DateTime updatedAt) : base(id)
{ ... }
public static Sticky Create(string text, Position position, StickyType type,
decimal width = DefaultWidth, decimal height = DefaultHeight)
{
DomainGuard.AgainstNullOrWhiteSpace(text, nameof(text));
DomainGuard.AgainstLessThan(width, MinWidth, nameof(width));
DomainGuard.AgainstLessThan(height, MinHeight, nameof(height));
var now = DateTime.UtcNow;
return new Sticky(StickyId.New(), text, position, type,
width, height, now, now);
}
public void UpdateText(string text)
{
DomainGuard.AgainstNullOrWhiteSpace(text, nameof(text));
Text = text;
UpdatedAt = DateTime.UtcNow;
}
}
The Sticky validates its own data. Text can’t be empty, dimensions have minimums. But the business rules about when a sticky can be added or what types are valid in the current phase are enforced by the Board aggregate root, because those rules require knowledge of the board’s state.
This is the principle: entities validate their own data; the aggregate root validates cross-entity business rules.
Connections and cross-entity rules
Some invariants span multiple entities within the same aggregate. In StormBoard, connections link two stickies with a labeled relationship: “triggers,” “activates,” “populates.” The rules about which sticky types can connect are complex:
public Connection AddConnection(StickyId fromStickyId,
StickyId toStickyId, string? label = null)
{
GuardAgainstReadOnly();
var fromSticky = _stickies.FirstOrDefault(s => s.Id == fromStickyId)
?? throw new DomainException("Source sticky not found on this board.");
var toSticky = _stickies.FirstOrDefault(s => s.Id == toStickyId)
?? throw new DomainException("Target sticky not found on this board.");
if (!ConnectionRules.IsValidPair(fromSticky.Type, toSticky.Type))
throw new DomainException(
$"Invalid connection: '{fromSticky.Type}' cannot connect to '{toSticky.Type}'.");
var connection = Connection.Create(fromStickyId, toStickyId,
label ?? ConnectionRules.GetDefaultLabel(fromSticky.Type, toSticky.Type));
_connections.Add(connection);
return connection;
}
The aggregate root resolves both stickies, validates the connection pair against domain rules, and assigns a default label based on the sticky types. This logic requires access to both entities. It cannot live in Sticky or Connection alone. It lives in Board because Board is the consistency boundary.
Sizing an aggregate
The key design decision is what goes inside the boundary and what stays outside.
Too large. If you put everything in one aggregate (boards, organizations, products, analysis results) you get a god object. Every operation loads the entire object graph. Concurrent modifications cause conflicts. Performance degrades.
Too small. If every entity is its own aggregate, cross-entity invariants can’t be enforced transactionally. You need eventual consistency and compensating actions for rules that should be immediate.
Right-sized. The aggregate boundary should be the smallest cluster of objects that must be consistent after every operation. A board with its stickies, connections, and boundaries is a good aggregate because:
- Adding a sticky requires checking the board’s phase (cross-entity rule)
- Adding a connection requires verifying both stickies exist on the board (cross-entity rule)
- Advancing the phase affects which sticky types are allowed (aggregate-wide state)
An organization with its members is a separate aggregate. A product with its boards is yet another. They reference each other by ID, not by direct object reference. Loading a board does not load its organization.
The base classes
The AggregateRoot<TId> base class provides identity, equality, and domain event infrastructure:
public abstract class AggregateRoot<TId> : Entity<TId>, IAggregateRoot
where TId : notnull
{
private readonly List<IDomainEvent> _domainEvents = [];
public IReadOnlyCollection<IDomainEvent> DomainEvents
=> _domainEvents.AsReadOnly();
protected void RaiseDomainEvent(IDomainEvent domainEvent)
{
ArgumentNullException.ThrowIfNull(domainEvent);
_domainEvents.Add(domainEvent);
}
public void ClearDomainEvents() => _domainEvents.Clear();
}
And Entity<TId> provides identity-based equality:
public abstract class Entity<TId> : IEquatable<Entity<TId>>
where TId : notnull
{
public TId Id { get; protected init; }
protected Entity(TId id) => Id = id;
protected Entity() => Id = default!;
public bool Equals(Entity<TId>? other)
{
if (other is null) return false;
if (ReferenceEquals(this, other)) return true;
if (GetType() != other.GetType()) return false;
return EqualityComparer<TId>.Default.Equals(Id, other.Id);
}
}
Two entities are equal if they have the same type and the same ID, regardless of their current property values. A Board loaded five minutes ago is the same Board loaded now, even if its name changed. That’s identity-based equality, and it’s fundamental to how entities work.
The parameterless constructor exists for ORM compatibility. EF Core needs it to instantiate entities during materialization, but your application code should never call it. Factory methods are the public API.
Common mistakes
Exposing mutable collections. Returning List<Sticky> instead of IReadOnlyList<Sticky> lets external code add, remove, or clear children without going through the aggregate root. The invariants are bypassed. Always use _stickies.AsReadOnly().
Public setters on aggregate state. If Status has a public setter, anyone can set a board to Completed without going through the state machine. Private setters enforce that state changes happen through behavior methods.
Loading aggregates you don’t own. If a handler for the Organization aggregate loads a Board aggregate to check something, you’ve created an implicit coupling. Use a query or pass the needed data as a value.
Cross-aggregate transactions. If creating an Order and updating Inventory must happen in the same database transaction, either they belong in the same aggregate or you need to rethink the consistency requirement. Most cross-aggregate operations can be eventually consistent.
Anemic aggregates. If your aggregate root is just a bag of properties with getters and setters, and all the business logic lives in a service class, you don’t have an aggregate. You have a data structure. The behavior belongs on the aggregate.
The aggregate is the smallest unit of consistency in a domain model. It protects invariants, owns its lifecycle, and encapsulates its children behind a root entity. Every repository operation loads and saves a complete aggregate, never a partial one.
The next post covers domain events: how aggregates communicate that something happened, and how those events drive workflows across aggregate boundaries.
Found this useful?
If this post helped you, consider buying me a coffee.
Comments