This post is part of a series:
When the Aggregate Gets Heavy, Part 2 (this post)
The Story So Far
Last week (When the Aggregate Gets Heavy) the question was where a new domain rule should go: inside the Post (god object) aggregate, or drained into a stateless PostService and made anemic. The answer was a third move. An aggregate has to own its behavior; it does not have to contain it. The aggregate stays the place a caller goes to ask “can this happen?”, and the rule itself lives in a smaller domain object that the aggregate composes and delegates to.
Part 1 covered the rules pattern (Specification + Policy) and the variants patterns (State + Strategy). Both ways made the aggregate lighter without turning the extracted unit into a procedure on getters. Both extracted units kept domain-language names: Featured, DraftState, LeadIn.
This part covers the other two patterns from the running list: Decorator for optional and incremental layers stacked on a core value, Chain of Responsibility for ordered checks where any link can short-circuit. The thesis and the litmus stay the same.
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.
The companion repo is the same one from Part 11: four cumulative phase snapshots, each a full codebase. Phase 03-decorator introduces the render chain we will meet in section one. Phase 04-cor introduces the publish guard chain in section two.
Too Many Layers: Decorator
Symptom: Post.render() Runs A Pipeline of Transformations on the body
MarkdownToHtml, ExpandEmbeds, and SanitizeXss always run. SyntaxHighlight runs when the post contains code. InjectToC runs when the consumer asks for a table of contents. Inside the aggregate, that becomes an if-ladder that grows every time a new layer enters the mix:
public String render(RenderProfile profile) {
String s = body;
s = "[" + s + " + MarkdownToHtml]";
if (profile.highlightSyntax()) {
s = "[" + s + " + SyntaxHighlight]";
}
s = "[" + s + " + ExpandEmbeds]";
if (profile.includeToC()) {
s = "[" + s + " + InjectToC]";
}
s = "[" + s + " + SanitizeXss]";
return s;
}The bracketed strings are not real rendering. They’re placeholders instead of the actual transformations so the example stays about the shape of the pipeline, not the mechanics of turning Markdown into HTML.
Pattern: Decorator
Where Part 1’s patterns drained eligibility rules out of the aggregate, Decorator answers a different kind of heaviness: stacked cross-cutting layers. Each layer becomes its own class implementing a small interface, wrapping an inner layer, and transforming its output. Once a layer is written it never changes again. New behavior arrives as a new layer class, and the only conditional left just picks which layers to assemble. That is the Open/Closed Principle at work: the aggregate stops growing an if-ladder, and each capability lives behind a name.
In phase 03-decorator each layer is a few lines:
final class MarkdownToHtml implements RenderLayer {
private final RenderLayer inner;
MarkdownToHtml(RenderLayer inner) { this.inner = inner; }
public String apply(String input) {
return "[" + inner.apply(input) + " + MarkdownToHtml]";
}
}The chain assembles outside Post, in a builder:
public static RenderLayer chainFor(RenderProfile profile) {
RenderLayer chain = new PassThrough();
chain = new MarkdownToHtml(chain);
if (profile.highlightSyntax()) {
chain = new SyntaxHighlight(chain);
}
chain = new ExpandEmbeds(chain);
if (profile.includeToC()) {
chain = new InjectToC(chain);
}
chain = new SanitizeXss(chain);
return chain;
}The aggregate hands the body to the chain and gets out of the way:
public String render(RenderProfile profile) {
return RenderChain.chainFor(profile).apply(body);
}Adding a new layer (a Watermark for paywalled posts or a LazyImageLoader for mobile consumers) means adding a class and a line to the builder. Post doesn’t change.
We can push the idea even further. Hold the decorators in a list and let each one decide whether it should apply to a given post, and the builder’s conditionals dissolve into the layers themselves. Assembly becomes pure data, OCP gets even stronger, and we land pretty close to the Chain of Responsibility shape we cover next.
A note on framing. Decorator is the optional-layer tool, not the anti-anemia hero. Specification and Policy from Part 1 carry the anti-anemia work. Decorator carries the cross-cutting composition work. The two patterns often sit in the same codebase but answer different questions.
Decorating the Infrastructure
The same idea pushes into infrastructure. A repository can be wrapped by caching, logging, metrics, and event-publishing decorators2. The boundary worth honoring: do not let infrastructure decorators take over persistence concerns that the aggregate or repository already owns. That blurs the boundary between domain and adapter, and that boundary is what hexagonal architecture is about in the first place.
From a SOLID perspective, OCP is the driver here. New behavior arrives as new classes while existing classes stay untouched (except the builder, but that change is localized).
A Sequence of Checks: Chain of Responsibility
Symptom: Post.validateForPublish() Runs A Sequence of Guards Before a Post Can Go Live
Title present, body long enough, no broken references, SEO description present, moderation cleared. Inside the aggregate, that becomes a long early-return ladder where every guard returns the first failure it finds:
public Decision validateForPublish() {
if (title == null || title.isBlank()) {
return Decision.no("title must not be blank");
}
if (body == null || body.length() < MIN_BODY_LENGTH) {
return Decision.no("body must be at least "
+ MIN_BODY_LENGTH + " characters");
}
for (Reference reference : references) {
if (!reference.valid()) {
return Decision.no("reference is broken: " + reference.url());
}
}
// seo, moderation ...
return Decision.ok();
}Pattern: Chain of Responsibility (CoR)
Each check becomes its own handler. The chain runs them in order and stops at the first failure. Inserting a new check adds a class and a single line where the chain is assembled.
In phase 04-cor each guard is small and focused:
final class TitlePresent implements PublishGuard {
public Decision check(PublishCheck context) {
return new HasContent(context.title(), "title").check();
}
}Notice that TitlePresent delegates to HasContent, the same Spec we met in Part 1’s Specification section. The blank-title rule lives in one class. Two policies consume it: the Featured policy inside the aggregate, and the publish-time guard inside the validation pipeline. That is the answer to the duplicated-rule question from The Anemic Domain Model Trap. The policy gets named twice because two policies have two jobs. The underlying rule is written once.
Run the litmus on the guard. TitlePresent keeps a ubiquitous-language name and owns its rule, one that a product manager could say out loud: a post needs a title before it publishes. It passes.
The chain assembles outside Post, as data:
public final class PublishGuards {
private static final List<PublishGuard> CHAIN = List.of(
new TitlePresent(),
new BodyMinLength(50),
new NoBrokenReferences(),
new SeoPresent(),
new ModerationCleared()
);
public static Decision checkAll(PublishCheck context) {
for (PublishGuard guard : CHAIN) {
Decision d = guard.check(context);
if (!d.allowed()) {
return d;
}
}
return Decision.ok();
}
}The aggregate packages a PublishCheck context and delegates:
public Decision validateForPublish() {
return PublishGuards.checkAll(new PublishCheck(
title, body, references, seoDescription, moderationStatus));
}A note on the shape. The CoR in the companion code is list-based: every guard does the same thing (check a value, return a Decision), and a single runner shorts on the first failure3. Classical Chain of Responsibility is a linked list of handlers where each handler has a next reference and decides whether to forward, transform, or short-circuit. That linked shape pays off when handlers differ in what they do. Think middleware that routes some requests, transforms others, and short-circuits a third group on its own conditions. When the guards are homogeneous validators, a list is the honest shape.
From SOLID, CoR respects SRP because each guard has one reason to change. Also OCP because inserting a guard adds a class and one line in CHAIN while the other guards stay untouched.
The Rest, in One Table
We could use four more patterns as a diet for an aggregate. None of them gets code or detailed explanations in this series. If an aggregate carries one of these shapes, we can reach for them.
Domain Service is the anemia onramp. It is the only pattern in this table that easily turns into a procedure with getters. Reach for it when the logic is genuinely cross-aggregate (the recommender takes two aggregates by construction), not because moving rules out of an aggregate feels neater. If the logic could live on one of the aggregates, it should. This series has already walked that line twice: The Anemic Domain Model Trap on where a rule belongs, and Where Does Application Logic Go? on the split between a domain service that decides and an application service that orchestrates.
Visitor carries its own cost. Double dispatch in many languages means every type in the hierarchy has an accept method, and adding a new type is an edit, not an extension. Reach for it last.
Which one next? Leave a comment if you want me to cover any of the patterns above.
What to Do This Week
TLDR
Four shapes of heaviness, four patterns (across the two posts):
Too many eligibility rules. Reach for Specification + Policy (Part 1).
Too many lifecycle variations or pluggable algorithms. Refactor to State (stored variant) or Strategy (per-call variant). (Part 1.)
Too many optional or layered modifiers stacked. Use Decorator. The layers are composed by construction. Adding a layer is one class and one builder line.
Too many ordered checks or transformations where any link can short-circuit. Reach for Chain of Responsibility. Guards are small, the runner is small, the order is data.
Try this week
Replace one chain of operations with Chain of Responsibility. Pick a method on one of your aggregates that runs a sequence of independent checks (a validate, a canDo, a prepareFor).
List every check inside it. Each check is its own potential guard.
Ask whether the order matters and whether a first failure should stop the rest. If yes, Chain of Responsibility can help.
Move one guard out this week. Keep the aggregate method’s signature, move the body into a
XPresentorXClearedclass, and let the aggregate delegate.Watch the method shrink without losing any behavior.
Then come back to the comment thread and tell me which pattern from the field-guide table you want next.
Media attributions:
Cover image by the author (generated with Gemini)
Companion code at Code-That-Makes-Sense/heavy-aggregate-example-code. Walk it phase by phase if you want to see the aggregate shrink in real time.
For a detailed example, see The Decorator Pattern “where Business Logic meets Clean Code…”. One caveat worth keeping: decorating findById and save blurs the aggregate/repository boundary, so the infrastructure extension is advanced territory, not the default move.
List-based Chain of Responsibility is sometimes called Pipeline or Filter Chain in practice. The pattern’s name tracks the behavior (handlers in order, first failure wins), not the data structure. A close cousin, Pipes-and-Filters, runs the same skeleton without the short-circuit rule: same delegation shape, different stopping condition. A markdown-sanitize, XSS-sanitize, HTML-minify pipeline is the canonical example.

