DDD from zero, in a language you knowAggregates own their invariants, and the anaemic trap
No narration yet
Module 1 · Lesson 414 min

Aggregates own their invariants, and the anaemic trap

You now know entities. An aggregate is a specific, important kind of entity: a cluster of related objects that you treat as a single unit for the purpose of changes, with one object designated as the aggregate root. The root is the only public entry point. Outside code holds a reference to the root and asks it to do things; it never reaches inside to poke the objects the root guards.

The reason aggregates exist is a single word: invariants. An invariant is a rule that must always be true. "An order line quantity is at least one." "You can't revert a decision that was never confirmed." "The total is the sum of the lines." The aggregate's whole job is to own its invariants and make sure no operation can ever leave it in a state where one is broken.

Here is where your Nest instinct collides with DDD, and it has a name. The default shape in a lot of TypeScript backends is a class-validator DTO (public fields plus decorators) and a Service that holds all the behaviour. The DTO is a bag of data; the service does the work. That structure is the anaemic domain model, and in DDD it's the primary smell.

"Anaemic" because the domain object has been drained of behaviour. It's just fields. All the blood (the rules, the decisions) lives in a service next to the object, operating on it from outside. The problem isn't that it doesn't work, it's that the object can't protect itself. Any caller that gets a reference can set any field to anything, and the rule that says they shouldn't lives in a service they might not go through.

The fix is the move you just did in the exercise: fold the behaviour back onto the object. Instead of OrderService.cancel(order) reaching in to check and set order.status, give Order a private status and a cancel() method that checks its own state and transitions itself. Now the rule lives on the thing it's a rule about, no external caller can set the status directly, and the order literally cannot be left in a state its own rules forbid. The service shrinks to orchestration: fetch the order, call order.cancel(), save.

There's a sharper version of this trap that Fusion review calls out by name, and it's worth pre-loading now because you'll meet the real comment in the final module. It's tempting, when the object needs some fact to make a decision, to pre-compute that fact in the calling layer and pass it in: the caller queries the database, distils it to a boolean like hasActiveDecision, and hands that boolean to the aggregate's method. That feels helpful. It's the anaemic trap in disguise.

Why? Because now the aggregate isn't answering from its own state, it's validating a boolean the caller gathered. It can't answer questions about itself. The invariant has leaked back out into the caller (and often into a database WHERE clause too, so the same rule now lives in two or three places). The DDD-correct move is: load the aggregate whole, with the state it needs, then ask it to act. It decides from what it holds. The rule stays in exactly one place, on the aggregate.

That's the concept. Fusion has a precise mechanical expression of all of it: action methods that return a Result, a base Entity that forbids accidental value-equality, and a hard rule against passing pre-computed booleans in. You'll see the mechanics in Module 4 and the real review comments in the final module. For now, hold the shape: aggregate owns its rules, behaviour lives on the object, never drain it into a service.

Practice

Try it yourself

Recall

Define an aggregate and an invariant

Two words that do a lot of work in DDD.

What is an aggregate, and what is an invariant?

Quiz

Spot the anaemic model

Which of these is the anaemic-domain-model smell DDD warns against?

Do

Fold service logic onto the object

Do the move that fixes an anaemic model. Scratch file or paper.

Tick every step to confirm you did it.

Recall

Don't pre-compute state for the object

A specific version of the anaemic trap that Fusion review names directly.

Why is it a smell for the calling layer to pre-compute booleans like `hasActiveDecision` and pass them into an aggregate's method, rather than letting the aggregate answer from its own loaded state?