Simplifying Conditional Expressions — Senior Level¶
Exhaustiveness checking, type-driven design, the expression problem, and how conditional structure reflects (or hides) architecture.
Table of Contents¶
- Conditional structure as a design indicator
- Exhaustiveness as a design tool
- The expression problem at scale
- Visitor pattern and double dispatch
- State machines vs. conditionals
- Decision tables and rule engines
- Refactoring under uncertainty
- Tooling: SonarQube, ESLint, type-driven flags
- Anti-patterns at scale
- Review questions
Conditional structure as a design indicator¶
A senior reading code looks at the shape of conditionals as a diagnostic:
| Symptom | Likely diagnosis |
|---|---|
| Deep nesting (4+ levels) | Method has too many responsibilities |
| Big switch on type code | Missing polymorphism |
Many sequential if x == null | Missing Null Object or Optional |
| Booleans as parameters | Missing Replace Parameter with Explicit Methods |
| Same condition in 5 places | Missing query method or strategy |
| Long if-else if chain over numeric ranges | Missing decision table |
| Nested switch statements | Missing strategy / state machine |
These are not surface-level concerns. Each pattern signals a deeper design issue.
Senior heuristic: the structure of conditionals reveals what the code thinks the world looks like. Mismatch between conditional structure and domain structure is a refactoring opportunity.
Exhaustiveness as a design tool¶
When you Replace Conditional with Polymorphism using a sealed type or exhaustive switch, the compiler enforces that every variant is handled:
sealed interface Order permits Draft, Submitted, Shipped, Cancelled {}
String describe(Order o) {
return switch (o) {
case Draft d -> "Draft #" + d.id();
case Submitted s -> "Submitted #" + s.id();
case Shipped sh -> "Shipped #" + sh.id();
case Cancelled c -> "Cancelled #" + c.id();
}; // compile error if a case is missing
}
Adding a new variant Returned becomes a compile-time event, not a runtime surprise.
Why this matters¶
In a non-exhaustive design (switch on int or strings), a new variant silently slips into a default branch — often handled wrong. Exhaustiveness shifts the cost from production bugs to compiler errors.
Languages with exhaustive switch¶
- Rust (
matchis exhaustive on enums). - Scala (sealed traits).
- Kotlin (
whenon sealed classes). - TypeScript (discriminated unions with
never). - Java 21+ (sealed + pattern matching).
Languages without (default behavior)¶
- Python (
matchis not exhaustive by default; use type-checking). - Go (no sum types; tag fields + switch).
- C (no compile-time checking).
The expression problem at scale¶
The classic trade-off:
| Approach | Easy to add | Hard to add |
|---|---|---|
| Polymorphism (subclasses + virtual methods) | A new type (one new class) | A new operation (modify every subclass) |
| Pattern matching / switch | A new operation (write a new function) | A new type (modify every function) |
In a closed domain (the set of types is stable; new operations come up): use pattern matching.
In an open domain (new types arrive often; operations are stable): use polymorphism.
In real codebases, you have both pressures. The senior move:
- Identify the closed parts (e.g., financial instruments — fixed set: stock, bond, option).
- Identify the open parts (e.g., events in an event-sourced system — new event types added monthly).
- Use the right tool for each.
Don't apply Replace Conditional with Polymorphism universally. Sometimes the conditional is right.
Visitor pattern and double dispatch¶
When you need to add new operations to a closed type hierarchy without modifying it, the Visitor pattern is the classical answer:
interface Shape { <R> R accept(ShapeVisitor<R> v); }
class Circle implements Shape {
public <R> R accept(ShapeVisitor<R> v) { return v.visit(this); }
}
class Square implements Shape {
public <R> R accept(ShapeVisitor<R> v) { return v.visit(this); }
}
interface ShapeVisitor<R> {
R visit(Circle c);
R visit(Square s);
}
// New operation = new Visitor implementation:
class AreaVisitor implements ShapeVisitor<Double> {
public Double visit(Circle c) { return Math.PI * c.r() * c.r(); }
public Double visit(Square s) { return s.side() * s.side(); }
}
When Visitor wins¶
- The type hierarchy is closed (you control it).
- New operations come up frequently.
- The operation logic is non-trivial.
When sealed + pattern matching wins¶
- The language supports it (Java 21+, Scala, Kotlin, Rust).
- Operations are simple expressions (no need for class-level structure).
- Less ceremony than Visitor.
In modern Java, sealed + switch has largely replaced Visitor for new code. Visitor remains useful for: - Complex compiler/AST traversal where each visit may carry state. - Cross-cutting operations that benefit from a Visitor parameter.
State machines vs. conditionals¶
Order: DRAFT → SUBMITTED → SHIPPED → DELIVERED. With cancellation possible at most stages.
Bad: imperative conditionals¶
public void cancel() {
if (status == DRAFT) { status = CANCELLED; return; }
if (status == SUBMITTED) { refund(); status = CANCELLED; return; }
if (status == SHIPPED) { throw new IllegalStateException(); }
if (status == DELIVERED) { throw new IllegalStateException(); }
}
Better: State pattern¶
Each state knows what cancel() means.
interface OrderStatus { void cancel(Order o); }
class DraftStatus implements OrderStatus { ... }
class SubmittedStatus implements OrderStatus { ... }
// etc.
Best for complex flows: explicit state machine¶
StateMachine<OrderStatus, Event> sm = StateMachine.builder()
.from(DRAFT).on(SUBMIT).to(SUBMITTED)
.from(SUBMITTED).on(SHIP).to(SHIPPED)
.from(SHIPPED).on(DELIVER).to(DELIVERED)
.from(DRAFT).on(CANCEL).to(CANCELLED)
.from(SUBMITTED).on(CANCEL).to(CANCELLED, REFUND_ACTION)
.build();
Spring StateMachine, Java's Squirrel-Foundation, or even AWS Step Functions externalize the flow entirely.
Senior decision¶
| Need | Tool |
|---|---|
| 2–3 states, simple | switch / polymorphism |
| 4–10 states, complex transitions | State pattern |
| Many states, audit/persistence/replay | State machine engine |
| Distributed, async, failure handling | Workflow engine (Temporal, Cadence) |
Decision tables and rule engines¶
Some "if-else if" chains aren't really code — they're rules.
double tier(double spend, int years, String country) {
if (years > 10 && spend > 5000) return GOLD;
if (country.equals("US") && spend > 1000) return SILVER;
if (years > 5) return SILVER;
if (spend > 100) return BRONZE;
return NONE;
}
When this grows to 50 rules involving 10 variables, it's a decision table trapped in code.
Move to data¶
tiers:
- name: GOLD
when: years > 10 AND spend > 5000
- name: SILVER
when: country == "US" AND spend > 1000
- name: SILVER
when: years > 5
- name: BRONZE
when: spend > 100
Engines: Drools, Camunda DMN, Easy Rules, OpenL Tablets.
When to externalize¶
- Rules change often without code releases.
- Domain experts (non-developers) maintain the rules.
- Auditing the rule set is required (compliance, finance).
When NOT¶
- Rules are stable for years.
- The "rule" is so tied to performance / data shape that an engine adds latency.
- The team doesn't want a new technology.
Refactoring under uncertainty¶
Sometimes you find a 200-line conditional and don't know all the cases. Senior approach:
- Characterize first. Write tests that capture current behavior on representative inputs.
- Map by case. Read the conditional and write a table:
- Identify dead branches. Some are unreachable. Note carefully — they may be defensive against rare inputs.
- Apply Decompose Conditional. Each branch becomes a named method.
- Apply Replace Conditional with Polymorphism (or guard clauses) once structure is clear.
- Re-run characterization tests.
This is slow. It's also reliable. Don't shortcut on conditionals you don't fully understand.
Tooling: SonarQube, ESLint, type-driven flags¶
| Tool | Helps with |
|---|---|
| SonarQube | Cyclomatic complexity, "switch missing default", duplicate branches |
| ESLint (with plugins) | no-fallthrough, default-case, exhaustive-deps for hooks |
| TypeScript strict | --strictNullChecks, exhaustiveness via never |
| Cognitive Complexity (G. Ann Campbell) | Better than cyclomatic for nested ifs |
| ArchUnit | Forbid switch over types in non-factory code |
Cyclomatic vs. cognitive complexity¶
Cyclomatic counts branches. A 10-case switch is "complexity 10."
Cognitive complexity weights nesting more heavily — a 10-case flat switch is "cognitive 10," but two nested switches of 5 each is "cognitive 25" (or worse). Cognitive complexity better matches what readers experience.
Set CI gates on cognitive complexity to catch nested-conditional regressions early.
Anti-patterns at scale¶
1. Mass refactoring to polymorphism prematurely¶
Two cases is not polymorphism. Hold off until you have 3+ AND each case has non-trivial behavior.
2. Universal Null Object¶
Replacing every nullable with a Null Object means callers can never tell if data was found. The Null Object is invisible — bugs hide in default behavior.
3. Over-asserted code¶
Assertions in every method, often duplicating type system. After type checks, more assertions add noise without value.
4. The default: throw new IllegalStateException("can't happen") epidemic¶
If you trust the type system, the default isn't reachable. If you don't, your model is wrong. Either fix the model or use exhaustive checking. The throw is symptomatic.
5. Guard clauses for everything¶
A method with 12 guard clauses and 1 line of "real" work is inverted — the special cases dominate. Often the method is too generic; consider Replace Parameter with Explicit Methods.
Review questions¶
- How does conditional structure reveal architectural mismatches?
- What does exhaustiveness checking buy you over a default branch?
- What is the expression problem? How do polymorphism and pattern matching trade off?
- When is the Visitor pattern still preferable to sealed types + pattern matching?
- When does a state machine engine win over the State pattern?
- When should rules become a decision table outside code?
- What's the difference between cyclomatic and cognitive complexity?
- When is "Universal Null Object" a problem?
- Why is
default: throw new IllegalStateException(...)a smell? - How do you refactor a 200-line conditional you don't fully understand?