Skip to content
Leo Jung
Go back

[DDD Intro #5] Aggregate: The boundary of consistency

Edit page

Among DDD’s tactical patterns, Aggregate is one of the most important and most misunderstood concepts. Entity and Value Object are relatively intuitive. Aggregate feels vague when we first encounter it.

Is it a group of related objects? Is it a transaction unit? Is it an object graph loaded through ORM relationships? Is the Aggregate Root simply the representative Entity?

To understand Aggregate properly, we must first ask “what must remain consistent?” rather than “what should be grouped?”

An Aggregate is not a unit for grouping objects neatly. An Aggregate is a consistency boundary that protects business invariants.

Invariants come first

An invariant is a business rule that the system must always preserve. It is different from simple input validation. It matters that a value should not be null, but when thinking about Aggregates in DDD, what matters more is a rule that must not be broken from a domain perspective.

Take a subscription service.

An active subscription must have a current access period.
An expired subscription must not grant content access.
A free trial can be offered only once per user.
If payment failures exceed a certain threshold, the subscription is suspended.
A subscription scheduled for cancellation expires when the current period ends.

Some of these rules must be immediately consistent inside one Aggregate. Others can become eventually consistent through collaboration with another Aggregate or context.

This distinction is the core of Aggregate design.

What must change together in the same transaction?
What can be reflected later through an event?
Which rule is fatal if it breaks from a domain perspective?

Without these questions, if we decide “subscription and payment are related, so put them in the same Aggregate,” the Aggregate easily grows too large.

Aggregate is a boundary of change, not a relationship

Many people understand Aggregate through object relationships.

A subscription has a plan, payment information, access rights, coupons, and refund history, so we may think all of them should go inside the Subscription Aggregate.

Subscription
- Plan
- PaymentMethod
- Payments
- Entitlements
- Coupons
- Refunds
- SupportTickets

In reality, all of these are related. But being related does not mean they must belong to the same Aggregate.

Aggregate is not a boundary of “things convenient to query together.” It is not a boundary of “things that are related.” Aggregate is a boundary of “changes that must remain consistent together.”

Subscription and payment are closely related, but they do not always need to be in the same Aggregate. A payment can fail, be retried, and be refunded. Payment history can grow. Payment policies can change at a different pace from subscription policies.

In that case, it may be better to keep Subscription and Payment as separate Aggregates and connect them through events for payment success or failure.

Payment Aggregate
  -> publishes PaymentCompleted event

Subscription Aggregate
  -> renews based on the payment completion event

This does not mean every system must do this. The important point is to judge by consistency, not by relationship.

The role of Aggregate Root

An Aggregate needs an entry point that can be accessed from the outside. That is the Aggregate Root.

An Aggregate Root is not merely the representative object with an ID. The Aggregate Root is the object responsible for the consistency inside the Aggregate. External objects should not directly modify internal Entities or Value Objects. All changes should go through the Root.

Suppose a Subscription Aggregate contains value objects such as SubscriptionPeriod and RenewalPolicy. If external code modifies these values directly, it can bypass Aggregate rules.

A bad flow looks like this:

subscription.getCurrentPeriod().extendByOneMonth();
subscription.setStatus(ACTIVE);

This code hides why the subscription period was extended, whether it was extendable, and which event should occur.

A better flow is to request a domain behavior from the Root.

subscription.renew(paymentResult);

Now Subscription can check its own state, renew the period, and record any required events. External code does not need to know the internal structure.

The Aggregate Root is not a shell that wraps internal objects. It is the gate that prevents domain rules from being bypassed.

Why Aggregates should be small

Aggregates should be designed small. The reason is simple. As an Aggregate grows, change cost increases, transaction conflicts become more frequent, and domain responsibility becomes blurred.

A large Aggregate is convenient at first. Once loaded, all data is available, and many things can be handled in one transaction. But over time, problems become visible.

First, performance problems appear. A huge object graph must be loaded for a small change.

Second, concurrency problems appear. Different users may change different parts, but they still conflict over the same Aggregate version.

Third, responsibilities mix together. If subscription renewal, payment failure, refund, customer support compensation, and entitlement adjustment all enter one Aggregate, the model becomes heavy.

Fourth, boundaries become vague. It becomes hard to tell what belongs to subscription and what belongs to payment.

It is easy to make an Aggregate large, but hard to split it later. So it is better to start small around real invariants.

Reference other Aggregates by ID

One principle often mentioned in DDD implementation is that an Aggregate should reference another Aggregate by ID rather than by object reference.

Suppose Subscription directly holds a Subscriber Aggregate.

public class Subscription {
    private Subscriber subscriber;
}

This structure looks convenient. But when Subscription is loaded, Subscriber may be loaded too, and changes to Subscriber may be handled inside the same object graph. The boundaries between the two Aggregates become vague.

Referencing by ID makes the boundary clearer.

public class Subscription {
    private SubscriberId subscriberId;
}

This does not break the relationship. It protects the boundary through the reference style. Subscription focuses on subscription rules, and Subscriber focuses on subscriber rules. If needed, an Application Service can load both Aggregates and coordinate the use case.

ID references can be inconvenient. But that inconvenience can be an important signal. If two Aggregates must always be changed together, the boundary should be reconsidered. If they do not need to change together, ID references keep the model healthier.

Collaboration between Application Service and Aggregate

An Aggregate does not have to do everything alone. An Application Service coordinates the use case flow.

Consider a subscription renewal flow.

public void renewSubscription(SubscriptionId subscriptionId) {
    Subscription subscription = subscriptionRepository.findById(subscriptionId);
    PaymentResult paymentResult = paymentClient.pay(subscription.billingAmount());

    subscription.renew(paymentResult);

    subscriptionRepository.save(subscription);
}

In this example, the Application Service loads the subscription, calls an external payment system, asks the domain object to renew, and saves the change. But Subscription decides whether it is in a renewable state, how the period extends when payment succeeds, and which state it enters when payment fails.

A bad structure is when the Application Service judges every domain rule.

if (subscription.getStatus() == ACTIVE && paymentResult.isSuccess()) {
    subscription.setPeriod(nextPeriod);
    subscription.setStatus(ACTIVE);
} else if (paymentResult.isFailed()) {
    subscription.setFailureCount(subscription.getFailureCount() + 1);
    if (subscription.getFailureCount() >= 3) {
        subscription.setStatus(SUSPENDED);
    }
}

Then the Aggregate becomes a data bundle, and domain rules scatter into the Application Service. In DDD, an Aggregate should have behavior. It should protect the invariants it owns.

Aggregate and transaction

Aggregate is deeply connected to transaction boundaries. A change to one Aggregate is usually handled within one transaction. Invariants inside the Aggregate are immediately consistent.

Conversely, if many requirements try to change several Aggregates in one transaction, be careful. Is immediate consistency truly required? Or can it be handled as eventual consistency through domain events?

For example, when payment is completed, the subscription period must be renewed. This may need to happen in one transaction. But payment history storage, receipt email, marketing analysis, and recommendation model updates do not all need to be in the same transaction.

They can be separated into follow-up work through events.

SubscriptionRenewed
- Renew entitlement
- Send receipt email
- Reflect in marketing analytics

An Aggregate is not a structure that tries to complete every result immediately. It is a structure that distinguishes consistency that must be preserved from consistency that can be aligned later.

Questions for Aggregate design

These questions help when designing Aggregates:

Which invariants must this Aggregate protect?
Which changes must happen in one transaction?
Which internal state must not be changed directly from the outside?
Is an ID reference enough for relationships with other Aggregates?
Is this Aggregate responsible for too many use cases?
Are we growing the Aggregate for query convenience?

The last question is especially important. Just because several pieces of information appear together on a screen does not mean they belong to the same Aggregate. Query requirements can be solved with a separate read model or query. Aggregates should be designed by consistency of change, not by query convenience.

Closing

Aggregate is the core of DDD implementation. But if we understand it as a unit that groups all related objects, it easily fails. An Aggregate is not an object graph. It is not an ORM relationship. It is not a bundle of data shown together on a screen.

An Aggregate is a boundary of consistency. It protects business invariants, prevents external code from bypassing internal rules, and decides which changes it owns within one transaction.

A good Aggregate is small and clear. It knows the rules it must protect, exposes meaningful behaviors to the outside, and collaborates loosely with other Aggregates.

In the next post, we will look at how to express important changes that happen inside an Aggregate and connect them with other concerns. That concept is Domain Event.


Edit page
Share this post:

Previous Post
[DDD Intro #6] Domain Event: Expressing what happened in the domain
Next Post
[DDD Intro #4] Bounded Context: A model is consistent only within a boundary