Nuno Silva

Mar 05, 2026 • 7 min read

The DRY Trap: Escaping the Infinite If/Else Nightmare with Behaviour Injection

Stop passing booleans. Start passing behaviour.

The DRY Trap: Escaping the Infinite If/Else Nightmare with Behaviour Injection

"Don't Repeat Yourself" (DRY) is the first commandment developers learn. See two blocks of identical code? Extract them into a shared helper method. It feels clean. It feels efficient. It feels like architecture.

But talk to anyone who has maintained a system for more than two years, and they'll tell you about the dark side of this rule: that beautifully simple abstraction eventually mutates into a fragile, 500-line labyrinth of if/else statements, boolean flags, and edge cases that nobody dares to touch.

Extracting methods is a core part of good software design. But knowing how to protect that extracted method from business variants is a skill that only comes with hard-won experience — usually after maintaining a codebase long enough to watch your own clean abstractions turn against you.


The Trap: "Boolean Blindness"

Code doesn't rot in a vacuum — it rots because the business changes.

Let's say we build an exportToPdf(data) service for the Billing team. A month later, HR needs PDFs too, so we share the service. Sensible call.

Then the business grows, and the architecture doesn't grow with it.

  • Billing needs their PDFs to include a custom financial watermark.

  • HR needs their PDFs to be password-encrypted for compliance.

  • The new Enterprise tier needs both a watermark and a cover page.

To handle this, most developers try to "save" the abstraction by patching the signature:

// Month 1
exportToPdf(data, boolean isBilling)

// Month 3
exportToPdf(data, boolean isBilling, boolean isHR)

// Month 6
exportToPdf(data, boolean isBilling, boolean isHR, boolean isEnterprise)

And the method body starts to look like this:

public byte[] exportToPdf(Document data, boolean isBilling, boolean isHR, boolean isEnterprise) {
 byte[] pdf = renderer.render(data);

 if (isBilling) {
 pdf = watermarkService.apply(pdf, "CONFIDENTIAL");
 }
 if (isHR) {
 pdf = encryptionService.encrypt(pdf, complianceKey);
 }
 if (isEnterprise) {
 pdf = watermarkService.apply(pdf, "CONFIDENTIAL");
 pdf = coverPageService.prepend(pdf, data.getClientName());
 }
 // What if isBilling AND isEnterprise are both true? Nobody knows.
 return pdf;
}

What started as a clean shared utility is now a loaded question: what combination of flags is even valid? Can isBilling and isHR both be true? What happens if they are? The method has no idea — and neither does the next developer who reads it.

This is a textbook example of Prudent & Inadvertent technical debt. Nobody did anything maliciously wrong. But the developer has inadvertently coupled completely unrelated business domains together inside a single function. The shared service has become a bottleneck — and the testing burden has quietly become unmanageable.

Here's the math nobody talks about: with 3 boolean flags, we don't have 3 test cases. We have 8 (2³). Every flag we add doubles the number of combinations a thorough test suite must cover. By month 6, we're not writing unit tests — we're writing a matrix of integration tests that each require mocking half the system, and half of those combinations probably don't even represent valid states.


The False Dichotomy

When faced with this mess, developers usually assume they only have two choices:

  1. Keep adding if statements and watch the cognitive load explode.

  2. Duplicate the code into a BillingPdfService, HRPdfService, and EnterprisePdfService.

Duplication feels safe — no unexpected interactions, no shared state to worry about. But it's a dead end. We now maintain three near-identical implementations, and the next time the core rendering logic needs to change, we'll make the fix in one place and silently miss it in the other two.

We shouldn't have to choose between a flag-riddled monster and brute-force duplication.

There is a third way.


The Third Way: Behaviour Parameterisation

The problem was never the shared core rendering logic. The problem is that the abstraction is trying to own domain-specific behaviour it has no business knowing about.

The fix is a shift in perspective: stop passing state, start passing behaviour.

Instead of passing booleans that tell the method what to do, pass a function that does it. The caller — who already knows their own domain — supplies the behaviour. The shared service just runs it.

// Before: the service decides what to do based on flags
exportToPdf(data, boolean isBilling, boolean isHR)

// After: the caller decides what to do by passing behaviour
exportToPdf(data, Function<byte[], byte[]> postProcessor)

Here's what the cleaned-up implementation looks like:

public byte[] exportToPdf(Document data, Function<byte[], byte[]> postProcessor) {
 byte[] pdf = renderer.render(data);
 return postProcessor.apply(pdf); // No ifs. No flags. No domain knowledge.
}

And here's how each team calls it:

// Billing: applies a watermark
pdfService.exportToPdf(data, pdf -> watermarkService.apply(pdf, "CONFIDENTIAL"));

// HR: encrypts for compliance
pdfService.exportToPdf(data, pdf -> encryptionService.encrypt(pdf, complianceKey));

// Enterprise: watermark + cover page, composed together
pdfService.exportToPdf(data, pdf -> {
 pdf = watermarkService.apply(pdf, "CONFIDENTIAL");
 return coverPageService.prepend(pdf, data.getClientName());
});

// A new team that needs no post-processing at all
pdfService.exportToPdf(data, Function.identity());

Notice what just happened. The Enterprise requirement — which previously would have forced a new boolean flag and another if block — was handled entirely by the caller, with zero changes to PdfService. The shared service didn't need to know about it. It just did its job and handed off.

And notice the Enterprise lambda specifically: it chains two independent behaviours together before returning. This is composability — the secret superpower of this pattern. The caller isn't limited to injecting a single behaviour; they can assemble an infinite pipeline of transformations, and the core service stays completely blind to all of it. The pattern doesn't just solve today's requirements. It scales to requirements you haven't invented yet.

One detail worth calling out explicitly: the HR lambda uses complianceKey — a variable that exists purely in the HR caller's scope. It's not in the method signature. It's not in the Document object. PdfService has absolutely no access to it, and it never needs to. This is closures working in our favour. The caller already has the context they need, so they bake it into the behaviour before handing it off. The shared service never has to know about compliance keys, security contexts, account tiers, or anything else that lives in a caller's domain. The caller owns the context; the service owns the execution.

Why this works: the method's responsibility is now honest

The old signature lied. exportToPdf(data, boolean isHR) claimed to be a PDF export function, but it was secretly also a conditional encryption engine. It had hidden responsibilities it could never fully enumerate.

When a signature lies, every developer working with it has no choice but to open the method body and reverse-engineer what it actually does. That's not a minor inconvenience — that's a tax on the team's cognitive bandwidth, paid again every time someone new touches the code. You can't trust the signature, so the signature stops being documentation and becomes a trap.

The new signature is honest. exportToPdf(data, Function<byte[], byte[]> postProcessor) says exactly what it does: I render a PDF, you decide what happens to it after. You don't need to read the implementation to understand the contract. The signature is the documentation.

A function parameter isn't just syntactic sugar — it's a contract. It tells every caller: you own this part, I own that part. No ambiguity, no implicit coupling, no flag combinations to reason about.

And critically: the testing matrix drops to near zero. You test PdfService exactly once, passing a trivial identity function. You test watermarkService in complete isolation, with no PDF renderer in sight. You test encryptionService the same way. Every piece is independently testable because every piece has a single, honest responsibility. The integration test burden that was doubling with every flag simply disappears.


The Bottom Line

This is the Open/Closed Principle in its purest form. The PdfService is closed for modification — you will never need to add another if statement to it — but open for extension, because any caller can inject any behaviour they need.

The pattern scales naturally, too. As the system grows, complexity accumulates in the callers (where it belongs, with the people who understand the domain) rather than in the shared service (where nobody owns it).

A clean abstraction doesn't try to know everything about every domain. It provides a rigid, predictable skeleton and delegates the specifics back to whoever called it.

When we stop using boolean flags to control flow and start passing behaviours instead, we stop fighting our codebase and start engineering it.

Join Nuno on Peerlist!

Join amazing folks like Nuno and thousands of other builders on Peerlist.

peerlist.io/

It’s available... this username is available! 😃

Claim your username before it's too late!

This username is already taken, you’re a little late.😐

0

3

0