Kill the Conditional Maze: From If-Statements to Rule Pipelines
Why long conditionals break both humans and agents, and how to refactor tangled if-chains into clear, composable rules that scale with change.
Long chains of conditionals are one of the most common sources of accidental complexity. They’re a familiar sight in codebases, and rarely do they stay stable. Over time, those if-blocks turn into a sequence of intertwined decisions where order matters, intent is implicit, and change becomes risky.
This pattern addresses that problem by turning branching logic into an explicit pipeline of rules. Instead of hiding decisions in control flow, we make them visible, ordered, and easy to change.
The previous pattern handled selection. This one handles workflows.
Consider the following code:
// NOTE: Simplified example to illustrate the core issue.
// Real-world versions are typically much larger and harder to reason about.
// Admin accounts are usually safe, unless someone tries to sneak weird separators into the raw name.
if (context.accountType().equals("admin") && !hasWeirdSeparators) {
if (looksLikeImpersonation(normalizedName)) {
logReview(100);
return new ReviewResult(
"manual",
"Admin-like username requires manual review");
}
auditRuleHit("ALLOW_SAFE_ADMIN_USERNAMES", context);
return new ReviewResult("allow", "Admin username looks safe");
}
// 1. Block quoted fragments that may hide suspicious content
if (originalName.matches(".*\"[^\"]*\".*")) {
logReview(101);
notifyReviewQueue(context.userId(), "quoted-fragments");
return new ReviewResult("manual", "Quoted fragments require manual review");
}
// 2. Block bracketed fragments that may hide tags or role names
if (originalName.matches(".*\\[[^\\]]*\\].*")) {
logReview(102);
String reason = originalName.contains("[admin]")
? "Bracketed role labels require manual review"
: "Bracketed fragments require manual review";
return new ReviewResult("manual", reason);
}
// ...think many more lines with conditionals and blocks...
if (normalizedName.contains("test") && normalizedName.length() < 8) {
logReview(107);
flagPattern(context.userId(), "short-test-username");
return new ReviewResult(
"manual",
"Short usernames containing 'test' require review");
}
return new ReviewResult("allow", "No suspicious username patterns detected");
So what’s the problem here?
The logic we are trying to express is a workflow. But the code does not say that. It hides the workflow inside a sequence of decisions, all compressed into a single method.
That style works right up until someone needs to change it. (And what good is code that an AI — or you — cannot safely change?) The consequence is that you cannot touch one rule without re-reading all the others, because the branching structure hides both order and intent.
The refactoring is simple: stop expressing policy as a branching maze and express it as an explicit decision pipeline.
After:
private static final List<ReviewRule> REVIEW_PIPELINE = List.of(
ALLOW_SAFE_ADMIN_USERNAMES,
REQUIRE_MANUAL_REVIEW_FOR_QUOTED_FRAGMENTS,
REQUIRE_MANUAL_REVIEW_FOR_BRACKETED_FRAGMENTS,
REQUIRE_MANUAL_REVIEW_FOR_REPEATED_PUNCTUATION_BEFORE_DIGITS,
REQUIRE_MANUAL_REVIEW_FOR_INVISIBLE_CHARACTERS,
REQUIRE_MANUAL_REVIEW_FOR_RESERVED_ROLE_NAMES,
REQUIRE_MANUAL_REVIEW_FOR_LEADING_OR_TRAILING_UNDERSCORE,
REQUIRE_MANUAL_REVIEW_FOR_SHORT_TEST_USERNAMES
);
public ReviewResult reviewUsername(SignupContext context) {
for (ReviewRule rule : REVIEW_PIPELINE) {
Optional<ReviewResult> reviewResult = rule.tryReview(context);
if (reviewResult.isPresent()) {
return reviewResult.get();
}
}
return allow("No suspicious username patterns detected");
}
What we have done here is adapt the classic Chain of Responsibility pattern to a refactoring problem. The original formulation comes from the Gang of Four book, where a request is passed along a chain of handlers until one of them decides to handle it (Gamma, Helm, Johnson, and Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software, Addison-Wesley, 1994). Here, we use the same core idea, but in a stripped-down and more explicit form: a linear sequence of small rules.
After this transformation, the logic and behaviour are driven by the table. Each rule in that table is simply a named method reference. Think of them as pointing to small rule methods. Here’s an example:
private static final ReviewRule REQUIRE_MANUAL_REVIEW_FOR_QUOTED_FRAGMENTS =
Reviewer::requireManualReviewForQuotedFragments;
private static Optional<ReviewResult> requireManualReviewForQuotedFragments(SignupContext context) {
if (context.originalName().matches(".*\"[^\"]*\".*")) {
logReview(101);
notifyReviewQueue(context.userId(), "quoted-fragments");
return Optional.of(manual("Quoted fragments require manual review"));
}
return Optional.empty(); // Optional.empty means: this rule does not apply, pass to next rule
}
Each rule becomes a small, named method that does one thing. It either returns a decision or passes control forward.
Also, the rules might not be just predicates. They also own their side effects. Logging, auditing, and notifications now live next to the decision that triggers them.
Note that we kept the original logic and structure (we only changed the return shape to Optional so the workflow can be driven by the pipeline). There is still cleanup we could do in those rule methods, but we do that stepwise: first make structure and intent explicit, then iterate once everything works. Too-large refactoring steps are a reliable way to sidetrack an agent.
Implementation Tip: Go Functional
If you prefer a more functional style, you can also express the orchestration as a pipeline operation:
public ReviewResult reviewUsername(SignupContext context) {
return REVIEW_PIPELINE.stream()
.map(rule -> rule.tryReview(context))
.flatMap(Optional::stream)
.findFirst()
.orElseGet(() -> allow("No suspicious username patterns detected"));
}
In C#, this maps naturally to LINQ; in Python, a generator-based first-match approach gives a similar shape.
I chose the explicit loop and if check in this chapter because it doesn’t require any detailed Java knowledge to follow, and because I prefer the pure simplicity of the more procedural form. Sometimes, a simple if is exactly what the doctor orders.
So the pipeline is not magic. It is just an ordered list of named decisions. The original logic is still there, but now encapsulated in stable and readable identities with their execution order controlled by the pipeline.
The Hard Part: Naming Rules
The challenging part in this refactoring is to identify the rules to extract and name. You might be fortunate to have comments explaining what the following code block does.
The original code has some of that: // 1. Block quoted fragments that may hide suspicious content.
But not everything is commented. Look at the final rule guarded by the if (normalizedName.contains("test") && normalizedName.length() < 8) { clause.
In the latter case, we need to go into detective mode and try to figure out the purpose and intent. (And the magic number 8 is not helping us here). Often, an LLM can help with the task -- language models are surprisingly good at naming things, a skill we humans struggle with.
Why this is better
In the conditional-heavy version, order is implicit in the branch clutter. In the refactored pipeline version, order becomes first-class data. You can point to the pipeline and answer immediately: “what runs first, what runs last, what short-circuits?”
That makes change less speculative. Add a new rule? Insert one pipeline entry and one method. Modify an existing rule? Open one dedicated method and stop there.
If that sounds familiar, it should. This is the same gain we saw in Express Selections as Tables. There, we refactored branching logic into a declarative lookup table. Here, we refactor decision logic into a declarative pipeline. In both cases, behaviour stops being buried inside control flow and starts being expressed as explicit structure.
That means extension becomes simpler and safer. They are now declarative changes. That matters to an AI agent too: the code now advertises where behaviour lives, in what order it runs, and how it can be extended without guesswork.
When to use this pattern
Use responsibility chains when:
one method contains many policy checks,
checks are mostly independent decisions,
rule order matters,
short-circuit behavior is desired.
Do not force this pattern everywhere. If conditions share heavy mutable state, or if you are selecting behavior families, strategy/polymorphism may be a better fit.
Design guardrails
To keep this refactoring honest:
Preserve API and behavior.
Keep the same decision order.
Keep each rule narrow and intention-revealing.
A good litmus test is this:
If you cannot describe what a rule does in one short sentence, the rule is probably doing too much.
Why this matters in AI-first development
Conditional-heavy code consumes tokens without increasing information density.
LLMs have a limited attention budget. Every extra token competes for that budget, and unstructured control flow forces the model to spend it on reconstruction instead of reasoning. Such code burns more tokens than a Meta employee looking to land on the internal leaderboard.
Responsibility chains reverse that tradeoff. They convert implicit flow into explicit decisions. That reduces ambiguity, narrows edit scope, and makes automated transformations safer. All of that improves machine-readability:
The orchestrator method is tiny, obvious, and stable.
Rule intent is encoded in method names, not inferred from nested conditions.
The agent can focus on one decision at a time instead of reconstructing control flow from a dense branch forest.
In other words: this is not just a stylistic opinion. It is about better geometry for change.

