When the Aggregate Gets Heavy
Splitting Behavior Without Anemia
This post is part of a series:
When the Aggregate Gets Heavy (this post)
When the Aggregate Gets Heavy, Part 2 (coming soon)
The Post God Object
Consider a blog. Our Post aggregate started small. A title, a body, an author, a status. Three months later it owns excerpt generation, render pipelines, featured-eligibility rules, publish validation, a state machine, moderation, SEO checks, and tag policies, all in a single god object. Every code review now ends with the same question: where does the new rule go, inside Post or outside it?
The two obvious answers are both wrong. If we keep stuffing logic into the aggregate, we make the god object worse. If we drain the logic into a PostService, we land in the anemic-domain-model trap. There is a third move, and it is the one that keeps the domain model intact.
Two Escape Hatches, Both Are Traps
Door one: keep the rules inside Post. Every new policy adds another method. Every new method touches private state the others touch. The class breaks SRP the moment featured-eligibility, render layering, and publish validation share a file. It breaks OCP the moment a new featured rule means editing the same canBeFeatured() we wrote during the last sprint.
Door two: drain the logic into one or more service classes. The methods leave the aggregate, the rules leave with them, and what we have left is data plus getters. That is The Anemic Domain Model Trap from two weeks ago. Last week’s “Where Does Application Logic Go?“ added the line that matters here: application services route, they do not own rules.
So neither door is a good answer because the question is wrong. The right question is: how do we move behavior out of Post while it stays domain behavior and not a procedure operating on the aggregate’s getters?
Own the Behavior, Don’t Hoard It
An aggregate doesn’t have to contain all its behavior. It has to own it.
Containing and owning are different things. Containing means every rule lives in a method of the class. Owning means the aggregate is still the consistency boundary, still the place a caller goes to ask “can this happen?”, but the rule itself lives in a smaller domain object that the aggregate composes and delegates to. The aggregate keeps the question. It hands off the answer.
An objection presents itself: isn’t a small object that takes the aggregate’s state in its constructor just a service in disguise? The mechanics are similar. The difference lies in who the caller is and the name of the extracted unit. A service reads the aggregate’s getters from outside and answers on the aggregate’s behalf. A composed Specification, a named Policy (a decision object that bundles a set of rules under a domain name), or a Strategy receives the values it needs, keeps the rule inside the domain layer, and lets the aggregate stay the caller.
The litmus test for whether our split stayed healthy is one question: did the extracted unit1 keep a domain name (a Featured policy, a PostState, an ExcerptStrategy), or did it become a procedure that takes a Post and reads its getters? The rest of this series pays off that test four times.
Four Ways to Split Without Going Anemic
One Post aggregate, four kinds of heaviness, four patterns. Each section takes the same Post from the prior section and lightens it by one move. The companion repo is laid out the same way: phases 00-start through 04-cor are cumulative snapshots, and the diff between phase N and phase N+1 is exactly one pattern arriving2.
This post (Part 1 of 2) covers the first two: the rules pattern (Specification + Policy) and the variants patterns (State + Strategy). Part 2 next week covers Decorator and Chain of Responsibility, and closes with a field guide naming the patterns we do not cover with code.
Too Many Rules: Specification and Policy
Symptom: Post Is Thick With Eligibility Checks
canBeFeatured() runs five rules in a row: title is not blank, author is verified, at least three tags, moderation cleared, status is PUBLISHED. Each one is an if-block inside the aggregate. To add a sixth rule, we edit canBeFeatured. If we add a second policy that reuses the same title-not-blank rule, we copy the if-block.
public Decision canBeFeatured() {
if (title == null || title.isBlank()) {
return Decision.no("title must not be blank");
}
if (!author.verified()) {
return Decision.no("author must be verified");
}
if (tags.size() < 3) {
return Decision.no("at least 3 tags required");
}
if (moderationStatus != ModerationStatus.CLEARED) {
return Decision.no("moderation status is not cleared: " + moderationStatus);
}
if (status != Status.PUBLISHED) {
return Decision.no("post must be published");
}
return Decision.ok();
}Pattern: Specification + Policy
A Specification is a composable boolean rule that lives in the domain. A Policy is a named decision object that composes those rules into a complete answer. Combined, the rule fragments become first-class domain objects and the decision keeps its ubiquitous-language name.
In phase 01-specification the aggregate keeps its public method, but the body is one line:
public Decision canBeFeatured() {
return new Featured(title, author, tags, moderationStatus, state.code()).check();
}The Featured policy is the named decision:
public final class Featured implements Spec {
private final String title;
private final Author author;
private final List<Tag> tags;
private final ModerationStatus moderationStatus;
private final Status status;
// ... constructor ...
public Decision check() {
return Spec.all(
new HasContent(title, "title"),
new IsVerified(author),
new AtLeastN(tags, 3, "tags"),
new HasClearedModeration(moderationStatus),
new IsPublished(status)
).check();
}
}
public final class HasContent implements Spec {
private final String value;
private final String fieldName;
// ... constructor ...
public Decision check() {
if (value == null || value.isBlank()) {
return Decision.no(fieldName + " must not be blank");
}
return Decision.ok();
}
}
// other Spec implementations omittedNote what Featured checks. Not only “the post is well-formed enough to be shown as featured.” It also checks that the post is PUBLISHED and that moderation is CLEARED. Featuring an unpublished draft is not a metadata question. It is a state question. A Policy is allowed to cross what the aggregate stores and how the aggregate is staged. That breadth is what makes it the named decision rather than a typed predicate.
Generic Primitives, Named Policy
The five Specs (HasContent, IsVerified, AtLeastN, HasClearedModeration, IsPublished) look deliberately generic compared to the policy that composes them. That is the point. HasContent is one rule with two consumers. It covers “title must not be blank” inside Featured today. It also covers “title must not be blank” inside the publish-time guard pipeline we will build with Chain of Responsibility in Part 2. The duplicated-rule problem from The Anemic Domain Model Trap is answered by extracting the rule, not the policy. The policy is named. The rule is reusable.
From a SOLID aspect, we respect SRP because one Spec is one rule with one reason to change. We also fulfill OCP, because a new featured rule means a new Spec class and one extra line in Featured.check(). The aggregate is untouched.
Specification has two honest forms in Java and the companion code shows both. For policy composition we use an internal Spec interface whose check() returns a Decision carrying a reason. That is value-keyed Specification. For query-side composition we use java.util.function.Predicate<Post> directly:
public static Predicate<Post> published() {
return p -> p.status() == Status.PUBLISHED;
}
public static Predicate<Post> featured() {
return p -> p.canBeFeatured().allowed();
}Predicate<Post> is type-keyed Specification. Both are honest instances of the pattern. Pick the one that fits the call site. A custom interface is not required when the language already gives us one.
Too Many Variations: State and Strategy
The symptom for State is that Post‘s lifecycle is a state machine pretending to be an if-ladder. transitionTo(Status target) has six legal transitions (DRAFT → IN_REVIEW, IN_REVIEW → DRAFT|SCHEDULED, SCHEDULED → DRAFT|PUBLISHED, PUBLISHED → ARCHIVED), each has its own conditional. canEdit() is another if-ladder that asks “are we in DRAFT or IN_REVIEW?”. If we add a new status3, every method that branches on Status has to change.
Strategy’s symptom is excerpt(ExcerptKind kind) having three branches inside Post: FirstParagraph, LeadIn, SmartTruncate. The aggregate has no business knowing how a SmartTruncate excerpt is computed. It only needs to know that callers sometimes want one.
Pattern: State and Strategy
We rehearsed the mechanics in Replacing Type Code with State and Strategy. Here we re-do them on a Post. The shape that matters across both patterns is delegation: the aggregate holds an interface reference, not an enum, and delegates the variant question to the implementation.
In phase 02-state-strategy the lifecycle moves to a PostState interface with five concrete classes (DraftState, InReviewState, ScheduledState, PublishedState, ArchivedState). The interface has no defaults; every state implements code(), canEdit(), and transitionTo(Status) explicitly. The aggregate stops branching:
public void transitionTo(Status target) {
state = state.transitionTo(target);
}
public boolean canEdit() {
return state.canEdit();
}Each concrete state declares its own answers. DraftState allows editing and the single transition to IN_REVIEW:
final class DraftState implements PostState {
public Status code() { return Status.DRAFT; }
public boolean canEdit() { return true; }
public PostState transitionTo(Status target) {
if (target == Status.IN_REVIEW) {
return PostStates.forStatus(Status.IN_REVIEW);
}
throw new IllegalStateException(
"cannot transition from DRAFT to " + target);
}
}ArchivedState is the mirror image, terminal. Every transitionTo throws. The illegal transitions become impossible to express, not just unguarded.
excerpt follows the same shape with an ExcerptStrategy interface4:
public String excerpt(ExcerptKind kind) {
return ExcerptStrategies.forKind(kind).extract(body, title);
}One detail worth a sentence. excerpt takes the ExcerptKind as an argument, not a field, because the excerpt shape is per-call. State is stored: the post is in DRAFT. Strategy is passed: the caller wants a SmartTruncate this time. Same delegation shape, different lifetime.
From SOLID’s point of view, we support LSP, because every PostState and every ExcerptStrategy is fully substitutable behind its interface. The aggregate never asks which one it has. We also honor DIP, because the aggregate depends on the abstraction, not a concrete state or strategy.
The Litmus Test
The test is one question: could the team’s product manager use this name in a sentence about the rule, without translating?
Run it on the two units we just built.
Featured is a Policy. Product manager sentence: “Featured posts need a verified author and at least three tags.” The name carries the rule. It passes.
DraftState is a State variant. Product manager sentence: “Posts in draft state can be edited. In-review posts can too.” The name carries the rule about who can edit. It passes.
Now the alternative: PostService.checkCanBePublished(post). Product manager sentence: there isn’t one. “Check can be published” is not a thing a product person says; it is the implementation talking to itself. The rule has left the domain layer and become a procedure on getters. It fails.
A domain split is healthy when the extracted unit keeps a ubiquitous-language name and owns its own rule. It has gone anemic when it became a procedure that reads the aggregate’s getters.
TLDR
Two shapes of heaviness, two patterns. Part 2 next week adds two more:
Too many eligibility rules. Reach for Specification + Policy. The aggregate keeps the question (
canBeFeatured()). A named policy owns the answer. Small Specs compose the rule fragments. The same Spec class can serve a publish-time guard later, so the rule is written once and named twice.Too many lifecycle variations or pluggable algorithms. Reach for State when the variant is stored on the aggregate, Strategy when the variant is passed per call. Variants are stateless implementations of one interface. The aggregate delegates.
Try this week
Pick one aggregate that has grown too large. Run this on it:
List every method in it that returns a
boolean, an enum, or throws on failure. Those are the policies.For each, ask whether the method body could be its own class with a domain name your team would recognize. That class is your
Featured, yourDraftState.If the method body branches by an enum, ask whether the variants share a single interface and the aggregate could delegate.
Move one policy out this week. Watch the aggregate shed one method without shedding one ounce of behavior.
Next week
Part 2 covers Decorator and Chain of Responsibility, the two patterns for layered transformations and ordered checks. It closes with a field guide that names the patterns I do not present with code (Command, Composite, Visitor, Domain Service). Let me know in the comments if you want me to cover any of those in detail.
Subscribe to keep these arriving on Mondays. Leave a comment with the policy you moved and the name you gave it. The named ones survive longest.
Media attributions:
Cover image by the author (generated with Gemini)
“Unit” is deliberately broad. It can be a class (a Featured policy, a DraftState state variant, a FirstParagraph excerpt strategy) or a method in the aggregate or a service (Post.canBeFeatured, Post.transitionTo, PostService.checkCanBePublished()). The litmus test works (and should be applied) on either level. The question is whether the extracted thing carries a ubiquitous-language name and owns the rule, not whether it crossed a class boundary.
Companion code is available at Code-That-Makes-Sense/heavy-aggregate-example-code. Each phase is a full codebase. Walk it phase by phase if you want to see the aggregate shrink in real time.
By month eighteen the lifecycle will also have DRAFT_OF_DRAFT, IN_REVIEW_BUT_NOT_THAT_REVIEW, and PUBLISHED_BUT_DO_NOT_TELL_LEGAL. Polymorphism arrives just in time.
ExcerptStrategy isn’t a pure domain name, I used it for teaching purposes. “Strategy” is recognizable to readers who already know the Gang of Four patterns catalog. We keep “Strategy” on the interface for that recognition.
A CMS team would more naturally reach for Excerpt, ExcerptStyle, or ExcerptShape. An even more domain-pure solution is dropping the parent abstraction’s name from the classifier and having LeadInExcerpt, FirstParagraphExcerpt, and SmartTruncateExcerpt implementing a small Excerpt interface.
The trade we make by keeping “Strategy” in the name is recognition versus the litmus test from the prior section. The name we chose is deliberately a pattern label rather than a domain noun. A real codebase is the place to drop the label and keep the noun.

