HomeDocumentationc_001_architectural-insigh
c_001_architectural-insigh
10

DDD Aggregates: What They Are and Why Your Domain Needs Them

March 9, 2026

The Concept, Without the Jargon

An aggregate in Domain-Driven Design is a cluster of related objects that are always treated as a single unit. Not a single database row, not a single class — a unit of consistency. The objects inside an aggregate change together, or not at all.

Every aggregate has one special object at its center called the aggregate root. Think of it as the front desk of a hotel. Guests don't wander freely into the back offices, the kitchen, or the staff rooms. Everything goes through the front desk. The front desk knows the rules. It decides what's allowed.

In code, this means external objects can reference the aggregate root, but never the internal objects directly. You go through the root. Always.

A Concrete Example: The Order

Take an Order. Inside it, you might have:

  • OrderItem objects (each product, its quantity, and line price)
  • a ShippingAddress (a value object — immutable, defined entirely by its data)
  • an order status (pending, confirmed, shipped)
  • a running total

The Order itself is the aggregate root. When you want to add a product, you don't reach into the items list directly. You call order.addItem(product, quantity). The Order then checks whether the order is still in a state that allows modifications, adds the item, and recalculates the total — all in one place, all enforced consistently.

What you don't do is grab orderItems from somewhere and push to it directly. The moment you allow that, you've split your business logic across two code paths, and the aggregate root can no longer guarantee anything.

Seeing It in Code

Let's make this tangible. Here's what the wrong approach looks like:

typescript
// ❌ bypassing the aggregate root
order.items.push(new OrderItem(product, quantity));
// the total is now wrong
// status was never checked
// nothing was enforced

And here's what going through the root looks like:

typescript
// ✅ going through the aggregate root
order.addItem(product, quantity);

Same outcome — a new item is on the order. But inside addItem, the Order checks that it's still in pending state, appends the item, and recalculates the total. All in one method. All guaranteed every time.

The second version looks simpler from the outside because all the complexity is inside the aggregate, where it belongs.

What Aggregates Are Really Protecting

And the thing that tends to click for developers with more experience: an aggregate isn't just about code organization. Invariants — the rules that must be respected within a part of your domain — benefit heavily from the use of aggregates:

For an Order, some invariants might be:

  • The total must always equal the sum of its line items.
  • No items can be added after the order is confirmed.
  • An order can't be confirmed without at least one item.

The aggregate root is the only object with enough authority — and enough built-in checks — to enforce all of these at once. If anything outside the root can modify internal state, those rules become suggestions instead of guarantees. And as we put it in the DDD overview, constraints that accumulate and live rent-free in developers' heads are, in general, how bugs take everyone by surprise in production.

This is why some experienced engineers might describe aggregates as consistency boundaries. Everything inside that boundary must be consistent — and that's a fight that programs (mobile apps, backends, modules, scripts), developers, and business owners must take on together.

What happens across boundaries — between one aggregate and another — is a separate thing, handled asynchronously and requiring another set of rules.

What an Aggregate Is Not

It's worth being direct about a few common misreads, because the term gets thrown around loosely.

An aggregate is not just a grouping of related classes, a collection of value-objects or some clever implementation of a big entity. You can have a UserProfile class that holds a name, a bio, and an avatar — but if none of those fields depend on each other to stay consistent, you don't need an aggregate.

You just have a data holder. You could think of it as an aggregate, implement it in code like an aggregate, or even decide with your team that it should behave like an aggregate.

That's not wrong, especially if you do it either for consistency or for keeping enforced business rules while trying to minimise every file's length, but it's not recommended as a best-practice either: it adds a little unnecessary overhead, and this is arguably one of the most important things that DDD should fight against and protect against.

Ask yourself, and maybe discuss with your team, 2 things:

Is this going to be an advantage because it will look and feel like the rest of the stuff in this app?

And

How do the business guys position themselves on this? Are they really inclined to enforce rules over it or we should simplify the approach?

An aggregate is also not a service, and not a module. Those are more primitive organizational concepts, inclined to deal with far broader aspects of architecture. An aggregate is a behavioral concept - it says that a portion of the logic should adhere to strict guidelines. It's about what methods get called on what object, with the goal to eliminate as many possible bugs as possible.

And probably the most important one: the size of an aggregate doesn't say anything about its validity. A single entity can be an aggregate root with nothing else inside it. A large aggregate with a dozen internal entities and value objects can make sense. What makes it an aggregate is whether it enforces an important boundary, consistent with its design and logic.

Ask the question again: what breaks if these things don't change together? If the answer is "nothing in particular," they probably don't belong in the same aggregate.

Another Example: Bank Account

If e-commerce isn't your domain, try this one.

A bank account has a balance, a transaction history, and an overdraft limit.

The BankAccount is the aggregate root — the single entry point for everything that touches this account. You are taking a risk if your program only does account.balance -= 200 alone, in isolation. But, by calling account.withdraw(200), the account checks whether the withdrawal is allowed, records the transaction, and updates the balance — everything in one operation, at once, all or nothing.

Is the balance in sync with the transaction history? And the transaction history doesn't solve much without the rules that govern it. Together, under the control of the aggregate root, they form a coherent picture of the account's state. Pull them apart, and you lose the ability to enforce any of the rules that make the domain make sense.

The Connection to Value Objects

If you've read our piece on Value Objects, you'll notice how naturally they fit here. Value Objects almost always live inside aggregates — as properties of the root or of its internal entities. The ShippingAddress on an order, the Money type on a bank account, the DateRange on a hotel booking. Value Objects handle the immutability and validation of individual concepts. The aggregate handles the consistency of the whole.

They're complementary. Neither replaces the other.

What This Means When You're Designing

The practical question that helps you find aggregate boundaries is this: what objects need to change together to keep the domain in a valid state?

Pro Tip
If changing one thing always requires changing another to avoid an invalid state, those things probably belong in the same aggregate. If two things can change independently without breaking any rules, they're likely separate aggregates — and any coordination between them happens at a higher level.

This is a domain modeling decision, not a technical one. And the answer comes from understanding the business — and using ubiquitous language, strong and aligned communication with the people who know those problems best. In technical terms, the problem space has to come before the solution space.

The Size Question Nobody Warns You About

Once you understand aggregates, the next instinct is often to make them large. If an Order contains items, you put everything order-related in there. If a User has a profile, preferences, subscriptions, and activity history — stick it all in one aggregate.

This feels safe. And for a while, it might be.

But the problem surfaces under concurrency. A large aggregate is a large lock. Every time two users — or two background jobs — try to modify the same aggregate simultaneously, one of them has to wait. At low traffic, you won't notice. At scale, this can get disastrous.

Then there's the loading cost. If your aggregate is enormous, every operation that touches it has to load the whole thing from storage. You wanted to update a preference, and you ended up hydrating the user's entire activity history to do it.

The practical guidance most experienced DDD practitioners land on is: start small, and let your invariants guide you. Two things belong in the same aggregate only if there's a real rule that requires them to change together. If you find yourself expanding an aggregate purely for convenience — because it makes querying easier, or because the data feels related — that's usually a sign they belong in separate aggregates.

Small aggregates that coordinate through well-defined channels tend to age better than large ones built around what seemed convenient at the time.

When Aggregates Need to Talk

Here's something the previous sections have been quietly assuming: that aggregates are isolated. But real systems aren't. When an order is confirmed, inventory needs to update. When a prescription is dispensed, a billing record has to be created. When a sprint is locked, open tasks need to be flagged.

The naive approach is to have one aggregate directly modify another. The Order confirmation method reaches into Inventory and decrements the stock count. It seems direct. It's actually a quiet disaster — because you've just coupled two consistency boundaries together, which means they're not really separate boundaries anymore.

The cleaner approach is domain events, and in Flutter one of the most common ways to implement this is with BLoC and GetIt. Instead of the Order acting on Inventory, the Order confirms itself and emits an OrderConfirmed event. Something else — an event handler, a background job, a message bus subscriber — picks that up and tells Inventory to do its thing. Separately and nicely. Through its own root.

This keeps each aggregate fully in charge of its own consistency, and decouples the timing of cross-boundary effects from the triggering operation. It also means each side can fail and recover independently, which matters a lot once your system is running in production and things inevitably don't go according to plan.

Domain events are a bigger topic than this article can fully cover — but even just knowing they exist changes how you think about aggregate design. Instead of asking "how do I connect these two things," you start asking "what does this aggregate announce, and who cares?"

How Aggregates Get Stored

One question that usually comes up around this point: if you can only go through the aggregate root, how does loading and saving actually work?

The answer connects directly to the repository pattern. Repositories are designed to load and save whole aggregates — not individual parts. You don't load an OrderItem in isolation. You load the Order, which brings its items along with it, because the two can't be meaningfully separated without losing the rules that govern them.

This is why the aggregate boundary and the repository boundary tend to align. The repository asks: "what's the smallest complete unit I can load that still guarantees consistency?" And the aggregate answers that question.

If you want to understand how repositories work in practice — and why they're worth the abstraction — the full piece on the repository pattern is worth reading alongside this one.

This Isn't Just a Backend Thing

Most DDD literature uses backend examples. Services, repositories, domain models persisted to databases. So it's easy to walk away thinking aggregates are a server-side architectural concern — something you reach for when you're designing a NestJS service or a Java monolith, not when you're building a Flutter app or managing React state.

That's a mistake worth correcting.

The problem aggregates solve — state that has rules, and too many places that can change it — is just as present in frontend code. A shopping cart where you push items directly into a list and update totals separately. A music player where currentTrack, isPlaying, and position drift out of sync because three different UI events modify them independently. A form wizard where the current step, validation state, and submitted data are scattered across context providers and local state and URL params.

All of those are the same problem. And the solution is the same: find the root, enforce changes through it, stop letting external code reach inside.

In Flutter, this might look like a CartBloc or a PlayerState class that exposes methods — addItem, skipTo, pause — and refuses to expose its internal fields as mutable state. In React, it might be a reducer that centralizes every mutation to a complex piece of state. The pattern doesn't care about the language or the platform. It cares about consistency.

What's interesting is that the argument for aggregates in frontend might actually be stronger than in backend. Backend systems tend to have clear transactional boundaries at the database layer — the database will at least stop you from persisting truly inconsistent data. Frontend has no such safety net. If your UI gets into an inconsistent state, it just... stays there. The user sees it. And debugging it usually means tracing backwards through a dozen setState calls trying to figure out how things got that way.

Backend devs who learn DDD should ask what those patterns look like in a UI. Frontend devs who've never heard of DDD are almost certainly already solving the same problems with less precise tools. The gap between the two is mostly vocabulary — and once you have the vocabulary, the solutions get cleaner on both sides.

One Pattern, Endless Domains

Aggregates show up everywhere once you start looking. A playlist that enforces no duplicate tracks. A medical prescription that prevents dosage modifications after it's been dispensed. A project sprint that won't accept new tasks after it's been locked.

The domain changes. The pattern doesn't. An aggregate root controls access, enforces the rules, and makes sure nothing inside its boundary can be left in a state that shouldn't exist.

It's not a complicated idea. It's just one of those things that makes a lot more sense once you understand what it's actually protecting.

Related Topics

domain driven design aggregatesddd aggregate root explainedaggregate root example typescriptwhen to use aggregates dddddd aggregate vs entity

Ready to build your app?

Turn your ideas into reality with our expert mobile app development services.