Aggregates in Domain-Driven Design
Consistency Boundaries in the Domain Model
An object can enforce the rules it owns. It checks its own state, validates what is passed in, and rejects anything that would leave it inconsistent. But some rules involve more than one object. No single object owns all the state needed to enforce them, so the code that checks them ends up somewhere else.
Domain-driven design addresses this with aggregates: groups of objects treated as a single unit, with one root responsible for enforcing the rules that span the whole.1
The previous articles established the domain model at the center of the core subdomain and introduced entities and value objects as its building blocks. This article adds the next layer: the consistency boundaries that group objects together when a rule cannot be enforced by any single object alone.
Rules That Span Multiple Entities
Entities and value objects each enforce their own invariants: rules that must hold for the object to be in a valid state. A CourseTitle rejects empty strings at construction. A Price prevents combining amounts in different currencies. Those rules belong to one object and that object enforces them.
Some rules do not fit that shape. On the learning platform, a course cannot be published without at least one lesson. That rule involves a course and its lessons together. There is no single entity that naturally owns the responsibility for enforcing it.
When this rule lives in an application service, it gets enforced in that one service method. But the same course can be affected by other code paths: a background job that migrates data, an admin tool that bypasses the main flow, a test that sets up state directly. Each new entry point is another place where the check might be missing. When the rule changes, every one of those places needs updating. Some will be missed.
The Aggregate and Its Root
An aggregate is a group of domain objects treated as a single unit for changes. The aggregate root is the only entry point to the group. All changes go through the root. Rules that span the group are the root’s responsibility to enforce.
On the learning platform, Course is an aggregate root. Lesson is an entity inside the aggregate. The only way to add or remove a lesson is through the course. There is no code path that reaches a lesson directly and modifies it independently of the course.
This gives the root a natural place to enforce the rules. Calling course.publish() checks that at least one lesson exists before changing the publication status. If the check fails, the operation is rejected and the course stays unpublished. Calling course.addLesson() enforces whatever rules govern lesson addition: a maximum lesson count, a required title, a uniqueness constraint. The rule lives on the root, and the root is the only way in.
The aggregate root is itself an entity. Its identifier is how the aggregate is referenced from outside. Internal entities like Lesson also have identifiers, but those are scoped to the aggregate. External code holds references to the course, not to the lessons inside it. A LessonId is meaningful within the aggregate, not beyond it.
The developer experience shifts noticeably. When the rule about publishing changes, there is one place to update it: the Course root. A new background job or admin tool that touches a course cannot bypass the check because there is no other way to change the course’s state. The rule is in one place and enforced everywhere.
The Consistency Boundary
An aggregate defines a transactional consistency boundary. All changes to the group succeed or fail together. Calling course.addLesson() creates the Lesson and adds it to the Course as a single operation. A Lesson cannot exist independently of its Course, and the Course’s invariants are checked every time the group changes.
Objects outside the aggregate reference it by identity. A billing record that needs to know which course was purchased holds a CourseId, not a Course. It does not hold a reference to the root or to anything inside it.
The reason matters more than the rule. If external code held a direct object reference to a Lesson inside a Course, it could modify the lesson without going through Course. The invariants the root is supposed to enforce would be bypassed with no indication that anything went wrong. The aggregate would be in an inconsistent state that none of its own methods could have produced. Identity references close this gap. The only way to change something inside an aggregate is through its root.
Designing Aggregate Boundaries
The boundary is drawn around the invariants, not around the relationships between entities.2 Relationships are easy to spot. Nearly everything in a domain model connects to something else. But a relationship is not a reason to group things. The reason to group is a shared rule that must hold whenever anything in the group changes.
On the learning platform, Enrollment might appear to belong inside Course. An enrollment is for a course, and courses have students enrolled in them. That relationship suggests a connection. But the invariants tell a different story. Enrollment has its own rules that are independent of the course. A student cannot enroll twice in the same course, enrollment status transitions are constrained, and enrollment has its own lifecycle. None of these have anything to do with whether the course has enough lessons to be published.
Enrollment is its own aggregate root. It holds a CourseId to reference the course it belongs to. Changes to an enrollment go through the Enrollment root. Changes to a course go through the Course root. Neither can bypass the other’s invariants.
That relationship alone would lead to the wrong conclusion. Navigating from a course to its enrollments is a query concern, not a consistency concern. Reading which students are enrolled does not require Course and Enrollment to change together or enforce shared rules. Draw the boundary where the rules are, not where the relationships are.
Keeping Aggregates Small
An aggregate’s boundary determines the scope of every transaction that touches it. The wider the boundary, the more entities lock together, load together, and conflict with each other.
A large aggregate means a wide consistency boundary. Every change to any part of the group updates the same aggregate. On the learning platform, if Enrollment were inside Course, two students enrolling simultaneously would both attempt to modify the same course record. With optimistic locking, one enrollment succeeds and the other fails with a version conflict that requires a retry. With Enrollment as its own aggregate, each enrollment updates an independent record. The two operations never conflict.
Loading is the other cost. When the aggregate loads, the whole group loads with it. Retrieving a course to check its publication status would load every lesson. Retrieving a course to display its title risks loading every enrollment along with it. Most operations need a small slice of the group but pay the cost of loading the whole thing.
The signal that an aggregate has grown too large usually appears under load: transactions that should run in parallel start waiting on each other, or queries that need one field start pulling in the entire model. When those signals appear, look at what the aggregate is actually protecting. If entities inside it share no invariant with the root, they belong in their own aggregate.
What Comes Next
Aggregates enforce the rules within their boundaries, and nothing outside can change their state directly. When a course is published, though, the notification context needs to know. The recommendation engine may need to update its index. Neither can reach inside the course aggregate to see what happened.
A single transaction should only modify one aggregate. The aggregate records what happened, and other parts of the system react. The next article covers domain events and how aggregates communicate what has changed without coupling to the parts of the system that care.
This article is part of the Domain-Driven Design series