0% прочитано

DDD Aggregates: Not Every Database Relationship Belongs in One Aggregate

A foreign key does not mean two entities must change as one unit. In practice, an aggregate is defined by the rules that must stay consistent in one change, not by the database schema.

13 жовтня 2026 р. 10 хв читанняLaravel

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?

In this article, an aggregate is a consistency boundary. Inside it, the rules must remain valid after every change.

The Database Can Suggest the Wrong Boundary

Three tables are related through product_id
products  id  name  status registrations  id  product_id  status access_records  id  product_id  subject_id  status

If 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

The invariant defines what must change consistently
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

Product protects the rule of its aggregate
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.

The relationship stays through an identifier while the rules remain separate
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

Not everything related must change inside one aggregate
Inside one aggregate: load  |validate rules  |change state  |save  |commit  Across aggregates: change A

Inside 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 increases coupling
One giant aggregate Product  +-- configuration  +-- registrations  +-- access records  +-- history  +-- settings  +-- policies  Every small change:load everythinglock everythingknow everythingsave everything

If 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

If one rule needs both parts, artificial separation is dangerous
Too small: ProductStatusProductConfiguration saved independently  Possible result: status = activeconfiguration = invalid

The 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

External code works through the root instead of changing internal parts separately
$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

Access knows which Product it refers to without owning Product
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

The two concepts may align in code, but they mean different things
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.