Whetstone.
Decode your own reviews"new("revert") is an anaemic model in disguise"
Module 8 · Lesson 513 min

"new("revert") is an anaemic model in disguise"

This is the anaemic domain model from Module 1, Lesson 4, caught in your own code, by Nick, on PR #272 (the revert-decision work). It’s the origin of the D1 and D2 conventions, so this comment quite literally became the rule the team writes to.

Your design had the application layer query the database, distil the result into booleans (snapshotExists, hasActiveDecision), and pass those booleans into a ForRevertFlow(...) factory that built the aggregate with a literal "revert" identity. The conventions that came out of it, D1 and D2:

Walk through why this is the anaemic trap wearing a costume. The “aggregate” you built didn’t hold its own state, it received a summary of state the caller had already computed. So it couldn’t answer anything about itself; it could only validate the booleans it was handed. The behaviour and the state had been pulled out of the object and left in the application layer, exactly the “data bag plus a service that does the work” shape from Lesson 4. It looked like an aggregate (it had a factory, it had methods) but it was a stateless function in a trench coat.

The fix is the “load whole, then ask” instinct from the foundations lesson. Load the real aggregate from persistence, with the snapshot and decision state it needs, as itself. Then call aggregate.RevertDecision(revokedAt) and let it decide from its own loaded state. The application layer stops computing domain facts and goes back to pure orchestration: load, act, save.

Notice that on this one you did the rework and it became doctrine, your fix taught the team. That’s the strong version of receiving review: not just complying, but internalising the principle so the next design starts right. The last lesson is Nisha and Nick on layering: where logic belongs, and why a retry ended up in the wrong place.

Practice

Try it yourself

Recall

Why the factory was anaemic

Reconstruct the smell from D1/D2.

You'd built the aggregate with a ForRevertFlow(...) factory that took pre-fetched booleans and used a literal "revert" identity. Why did Nick call that an anaemic domain model in disguise?

Reveal answer

Because the application layer distilled DB state into booleans (snapshotExists, hasActiveDecision) and handed them to the factory, so the aggregate became a stateless validator over caller-supplied data, it couldn't answer questions about its own state, and the invariants leaked into the app layer and into WHERE clauses. The new("revert") identity was the tell: an aggregate's id should identify the INSTANCE (the flight), not the use case (revert). Behaviour and state had been pulled out of the object, leaving a bag of setters.

Quiz

What an aggregate id identifies

An aggregate root's identity should identify:

  1. AThe use case that created it (revert, confirm)
  2. BThe instance it represents (this specific flight)
  3. CThe database table it lives in
  4. DThe user who triggered the operation
Show answer

Correct answer: B — The instance it represents (this specific flight)

An aggregate's id identifies the INSTANCE, this specific flight, not the operation being performed on it. A literal "revert" identity names a use case, which is the tell that the object isn't a real aggregate with real identity, it's a stateless function dressed as one.

Recall

The fix, as an instinct

Turn D1 into a design-time reflex.

What's the correct shape, stated as a rule you can apply before writing the code?

Reveal answer

Load the aggregate whole from persistence with the state it needs, then ask it to do things (aggregate.RevertDecision(revokedAt)), and let it answer from its own loaded state. The application layer just orchestrates: load, act, save. Don't pre-compute domain state in the app layer and pass it in. 'Load whole, then ask' keeps the behaviour and the invariants on the object where they belong.

Sign in to track your progress →