In the previous article about the point where CRUD stops being enough I wrote that DDD becomes useful when a system no longer only stores data but must protect rules.
In the article about domain boundaries in a growing system the next question was where one part of the domain ends and another begins.
Aggregates became the next step in that reasoning for me.
The easiest mistake is to look at database tables, see a foreign key, and assume the related records belong to one aggregate.
In practice, that is not what an aggregate means. A database relationship tells us that data is related. An aggregate answers a different question: which changes must remain consistent together?
The Database Can Suggest the Wrong Boundary
products id name status registrations id product_id status access_records id product_id subject_id statusIf I look only at the schema, it is easy to say: "everything points to product, therefore it is one aggregate."
But the schema does not tell me which rules must be true at the same moment.
It also does not show whether registration has its own lifecycle, whether access changes independently, or whether those parts evolve for different reasons.
That is why I start aggregate design from invariants, not from foreign keys.
First Question: Which Rule Must Never Be Broken
Example rule: An active productmust always havea valid configuration. If one command changesboth status and configuration,they belong to oneconsistency decision.If the system must never observe "product is active, but required configuration is missing," those pieces need protection within one consistent change.
That is an argument for one aggregate.
But if another rule says "a subject may receive access to an active product," the access record does not necessarily need to participate in that same change.
The product can remain active regardless of how many access records currently exist.
The Aggregate Root Controls Changes
final class Product{ public function activate(): void { if (! $this->configuration->isValid()) { throw new DomainException( 'Product is not ready for activation.', ); } $this->status = ProductStatus::ACTIVE; }}An aggregate root is not useful because the name sounds architectural.
Its purpose is to stop external code from changing internal parts independently and bypassing the rules.
If configuration belongs to the same aggregate, a command should not update configuration in one place and status somewhere else while allowing an invalid intermediate state.
Related Entities Can Be Separate Aggregates
Product aggregate Product | +-- configuration +-- lifecycle rules Access aggregate Access | +-- subject +-- validity +-- access rules This is one of the most important DDD lessons for me.
Access can know a ProductId without automatically pulling the whole Product aggregate inside itself.
It needs an identifier to know what it refers to. It does not need to own the product lifecycle.
The relationship remains, while responsibility boundaries stay clear.
An Aggregate Defines a Strong Consistency Boundary
Inside one aggregate: load |validate rules |change state |save |commit Across aggregates: change AInside an aggregate I expect strong consistency: when the command finishes, its invariants must be valid.
Across aggregates, the situation is different. If one change logically triggers another, that becomes a use-case coordination problem.
Sometimes both aggregates may still be changed inside one database transaction. Even then, they do not have to become one model.
A transaction and an aggregate answer different questions.
An Aggregate That Is Too Large Quickly Becomes Expensive
One giant aggregate Product +-- configuration +-- registrations +-- access records +-- history +-- settings +-- policies Every small change:load everythinglock everythingknow everythingsave everythingIf I build an aggregate around the rule "everything has product_id," it quickly starts knowing too much.
Even a simple product-name change may require loading state related to access, registrations, or other independent processes.
Locking, conflicts, and reasons for change all increase.
That is a strong signal that the aggregate contains several different consistency boundaries rather than one.
But an Aggregate Can Also Be Too Small
Too small: ProductStatusProductConfiguration saved independently Possible result: status = activeconfiguration = invalidThe goal is not to create as many tiny aggregates as possible.
If two pieces together form one invariant, saving them independently creates a real opportunity for invalid state.
I therefore look for the smallest model that can still protect its own rule.
I Ask: Who Owns the Decision
This simple question often clarifies the boundary quickly.
Who decides whether a product can be activated? The Product.
Who decides whether a particular access can be granted? The Access model or the model responsible for access.
If one model constantly has to inspect the internal rules of another to make its decision, the boundary may be wrong.
A Repository Loads and Saves the Aggregate as a Unit
$product = $products->get( $productId,); $product->activate(); $products->save( $product,);In this approach, a repository is not merely a wrapper around Eloquent queries.
Its practical role is to load enough aggregate state to enforce the rules and persist the result as one model.
If every command requires me to load five supposedly independent entities and manually reconstruct the rules between them, I should recheck the aggregate boundary.
Across Aggregates I Prefer an Identifier Over the Whole Object
final class Access{ public function __construct( private ProductId $productId, private SubjectId $subjectId, private AccessStatus $status, ) { }}This reduces accidental coupling.
Access does not gain the ability to mutate Product merely because their tables are related.
If a use case needs to check Product state, the application layer can obtain the required information and coordinate the action.
In AVN Center, I Reached Aggregates Through Rules Rather Than Tables
This matched my experience with AVN Center very closely.
At first it is easy to think in tables and relationships. As rules grow, another question becomes more important: which part of the system owns the decision, and which state must it protect?
In the practical product-registry example I showed one more concrete step from that work.
Here I intentionally keep the example generalized because the useful part is the reasoning, not the internal map of AVN Center.
An Aggregate and a Database Transaction Are Related but Not the Same
Aggregate answers: "What must stay consistentas one model?" Transaction answers: "Which database changescommit or roll back together?"In a simple use case, one aggregate is often loaded, changed, and saved within one transaction.
But I do not define the aggregate by whichever tables happen to be updated together today.
The domain rule defines the boundary. The transaction is a technical mechanism for persisting the change reliably.
How I Check Whether Two Entities Really Belong in One Aggregate
I ask a few simple questions:
- Which rule would be broken if these parts changed independently?
- Must the system observe their new state at the same time?
- Who owns the decision to change?
- Does one side have its own lifecycle?
- Can one side refer to the other by identifier only?
- Does the shared model force us to load or lock unrelated state?
- Does the aggregate protect one set of invariants rather than merely copy the foreign-key structure?
The Biggest Shift for Me Was to Stop Designing Aggregates from Tables
Tables still matter. Indexes, foreign keys, and database constraints do not disappear.
But when I design an aggregate, I no longer put the database schema first.
I start by stating the rule. Then I determine which state is required to verify and protect that rule. Only after that do I decide how that state should be persisted.
This order has helped me avoid turning every SQL relationship into one giant object graph.
An Aggregate Is Defined by Rules, Not by Arrows Between Tables
Aggregates became clear to me only when I stopped thinking of them as "a group of related entities."
A more useful practical definition is this: an aggregate is the smallest boundary that can protect an important invariant by itself.
If two entities merely share a foreign key, that is not enough.
If they must change together so that a rule can never be broken, then their relationship is stronger.
I do not start an aggregate from a table. I start with the question: which invalid state must the system never be allowed to leave behind?
A foreign key shows a relationship between data. An aggregate shows which rules must remain consistent together.



