- Chapter
- Chapter 1 — Strategy
- Prerequisites
- Topics 1.1–1.2; Volume II Topic 3.x (Open/Closed)
- You will be able to
- Recognise when conditional logic has become an explosion, refactor it safely into strategies, extend behaviour without modifying existing code, and decide when a switch expression over a sealed type is the better modern alternative
- Covers
- if/else explosion; Extension
On this page
- 1. Concept / Definition
- 2. Why It Exists
- 3. Core Concepts
- 4. How It Works Internally — The Refactoring Path
- 4A. Deep Dive — The Modern Alternative: switch Over Sealed Types
- 5. APIs / Features / Syntax
- 6. Practical Examples
- 7. Common Problems & Mistakes
- 8. Performance & Production Considerations
- 9. Architecture & Design Considerations
- 10. Interview Questions & Answers
1Concept / Definition
Conditional logic (if/else, switch) and the Strategy pattern are two ways to express variation. Conditionals put all variants in one place and choose among them by testing; Strategy puts each variant in its own object and chooses by selecting it once.

| Aspect | Conditional logic | Strategy |
|---|---|---|
| Where variants live | One method (often several) | One class/lambda each |
| Adding a variant | Modify existing method(s) | Add a new strategy |
| Choosing the variant | At every decision point | Once, in a selector |
| Readability at 2–3 variants | Excellent | Slightly heavier |
| Readability at 10+ variants | Poor | Stays constant per strategy |
2Why It Exists
Most code starts with an if. That is correct: introducing a pattern for two stable cases is over-engineering. But certain variation axes keep growing — countries, payment methods, customer segments, file formats — and each addition edits the same methods. Merge conflicts, regression risk and cognitive load rise with every branch. Recognising the moment to switch from conditionals to strategies, and doing the refactoring safely, is a core design skill.
3Core Concepts
3.1 if/else explosion
An if/else explosion has recognisable symptoms:
| Symptom | Example |
|---|---|
| The same condition in several methods | switch (country) in shippingCost(), eta(), labelFormat(), customsForm() |
| Nested conditions across two axes | if (country == IN) { if (express) ... } |
| Branches growing every release | Each new market adds branches in 6 files |
| Branches owned by different teams | Tax team and logistics team edit the same method |
| Tests per method explode | Every branch combination needs coverage |
| Flags accumulating | boolean newPricing, useV2Rounding, isPilotTenant |
// A typical explosion (simplified)
public Money shippingCost(Order o) {
if (o.country() == Country.IN) {
if (o.express()) return Money.inr(149);
return o.total().isAtLeast(Money.inr(499)) ? Money.ZERO : Money.inr(49);
} else if (o.country() == Country.DE) {
if (o.bulky()) return Money.eur("29.00");
return o.total().isAtLeast(Money.eur("50")) ? Money.ZERO : Money.eur("4.90");
} else if (o.country() == Country.US) {
return o.express() ? Money.usd("19.99") : Money.usd("6.99");
}
throw new UnsupportedOperationException("country " + o.country());
}
// ...and a parallel switch in eta(Order), carrier(Order), label(Order)
The cyclomatic complexity of the method grows linearly with each country; with strategies, complexity per unit stays constant and the selection is a single map lookup.
3.2 Extension
The Open/Closed Principle asks that adding behaviour should not require modifying tested code. With Strategy, extension means adding a class and registering it:
public interface CountryShipping {
Country country();
Money cost(Order o);
Duration eta(Order o);
Carrier carrier(Order o);
}
@Component final class IndiaShipping implements CountryShipping {
public Country country() { return Country.IN; }
public Money cost(Order o) {
if (o.express()) return Money.inr(149);
return o.total().isAtLeast(Money.inr(499)) ? Money.ZERO : Money.inr(49);
}
public Duration eta(Order o) {
return o.express() ? Duration.ofDays(1) : Duration.ofDays(4);
}
public Carrier carrier(Order o) { return Carrier.DELHIVERY; }
}
@Component
final class ShippingRules {
private final Map<Country, CountryShipping> byCountry;
ShippingRules(List<CountryShipping> all) {
byCountry = all.stream().collect(
Collectors.toUnmodifiableMap(CountryShipping::country, s -> s));
}
CountryShipping forCountry(Country c) {
var s = byCountry.get(c);
if (s == null) throw new UnsupportedMarket(c);
return s;
}
}Adding Japan is now a new JapanShipping class — no existing class changes, no existing tests need rerunning for correctness, and ownership can be delegated to the team launching the market.
| Change | Conditional design | Strategy design |
|---|---|---|
| Add Japan | Edit 4 methods, add 4+ branches | Add 1 class |
| Change India express price | Edit a branch among others | Edit IndiaShipping only |
| Test India rules | Through a big method | IndiaShipping unit tests |
| Who reviews | Everyone touching shipping | Market team |
4How It Works Internally — The Refactoring Path

1. Pin behaviour with characterisation tests covering each branch (golden inputs → outputs).
2. Find the axis the conditional switches on (country, type, segment).
3. Extract the interface with one method per decision that switches on that axis.
4. Move one branch at a time into a strategy class; route that key through the new path while others still use the old code.
5. Build the registry keyed by the axis; add a default or explicit failure for unknown keys.
6. Delete the conditional when all branches have moved; tests stay green throughout.
This incremental approach — Replace Conditional with Polymorphism — keeps the system releasable at every step.
4ADeep Dive — The Modern Alternative: switch Over Sealed Types
Java 21+ pattern matching changes the trade-off. When the variants are a closed set of data types and the operations are owned by one team, an exhaustive switch can be clearer than strategies:
public sealed interface PaymentMethod permits Card, Upi, BankTransfer {}
public record Card(String token, String network) implements PaymentMethod {}
public record Upi(String vpa) implements PaymentMethod {}
public record BankTransfer(String iban) implements PaymentMethod {}
static Money fee(PaymentMethod m, Money amount) {
return switch (m) { // exhaustive: no default needed
case Card c when c.network().equals("AMEX") -> amount.percent("2.9");
case Card c -> amount.percent("1.8");
case Upi u -> Money.ZERO;
case BankTransfer b -> Money.eur("0.35");
};
}
| Criterion | Sealed type + switch | Strategy |
|---|---|---|
| Variant set | Closed, rarely changes | Open, grows over time |
| Who adds variants | Same team as the operations | Other teams / plug-ins / config |
| Compiler support | Exhaustiveness check on every switch | None needed — polymorphism |
| Adding an operation | One new function | New method in every strategy |
| Adding a variant | Compiler flags every switch to update | One new class |
| Runtime selection by config | Awkward | Natural |
This is the classic expression problem: switches make new operations cheap; polymorphism makes new variants cheap. Choose based on which will change more.
5APIs / Features / Syntax
| Feature | Role |
|---|---|
Switch expressions (->, yield) | Concise, expression-valued conditionals |
Pattern matching with guards (when) | Conditions on data shape |
| Sealed interfaces | Exhaustiveness checking |
Map<K, Strategy> / EnumMap | Strategy registries |
Spring List<T> injection | Automatic registration |
| ArchUnit / PMD cyclomatic-complexity rules | Detect explosions early |
EnumMap is an efficient registry when the key is an enum:
Map<Country, CountryShipping> byCountry = new EnumMap<>(Country.class);6Practical Examples
6.1 Characterisation test before refactoring
@ParameterizedTest
@CsvSource({ "IN,false,300,49.00", "IN,false,600,0.00", "IN,true,600,149.00",
"DE,false,40,4.90", "DE,true,40,4.90", "US,true,10,19.99" })
void shippingCostUnchanged(Country c, boolean express, int total, String expected) {
assertThat(shipping.cost(order(c, express, total))).isEqualTo(money(c, expected));
}The same test runs before, during and after the refactoring.
6.2 Removing a boolean flag
calculate(order, useNewPricing) becomes calculate(order, pricingStrategy); the flag's two branches become two strategies selected by a feature-flag lookup in one place — and the old one is deleted after rollout.
6.3 When the conditional stayed
A switch over three HTTP status families (2xx/4xx/5xx) in one error mapper was left as is: stable, local and readable. Introducing strategies would have added files without reducing risk.
6.4 Worked refactoring — a payment-method fee calculator
Starting point. A FeeService with four methods (fee, settlementDays, refundable, displayName) each containing a switch over a String method code ("CARD", "UPI", "NETBANKING", "WALLET"). Two new methods (BNPL, EMI) are planned.
| Step | Change | Tests |
|---|---|---|
| 1 | Parameterised characterisation tests for all four methods × four codes | 16 cases green |
| 2 | Introduce PaymentMethodRules interface with the four operations | unchanged |
| 3 | Move CARD branches into CardRules; FeeService delegates for CARD only | green |
| 4 | Repeat for UPI, NETBANKING, WALLET | green after each |
| 5 | Registry Map<String, PaymentMethodRules> built from beans; unknown code → UnsupportedMethod | +1 negative test |
| 6 | Delete the four switches | green |
| 7 | Add BnplRules, EmiRules as new classes | new tests only |
public interface PaymentMethodRules {
String code();
Money fee(Money amount);
int settlementDays();
boolean refundable();
String displayName(Locale locale);
}
@Component
final class UpiRules implements PaymentMethodRules {
public String code() { return "UPI"; }
public Money fee(Money amount) { return Money.ZERO; }
public int settlementDays() { return 1; }
public boolean refundable() { return true; }
public String displayName(Locale l) { return "UPI"; }
}
@Service
final class FeeService {
private final Map<String, PaymentMethodRules> rules;
FeeService(List<PaymentMethodRules> all) {
rules = all.stream().collect(
Collectors.toUnmodifiableMap(PaymentMethodRules::code, r -> r));
}
Money fee(String code, Money amount) { return rulesFor(code).fee(amount); }
private PaymentMethodRules rulesFor(String code) {
var r = rules.get(code);
if (r == null) throw new UnsupportedMethod(code);
return r;
}
}| Metric | Before | After |
|---|---|---|
| Largest method complexity | 14 | 3 |
| Files touched to add BNPL (estimate) | 1 file, 4 methods, 4 switches | 1 new class |
| Merge conflicts per quarter (team estimate) | frequent | rare |
7Common Problems & Mistakes
- Premature Strategy for two stable branches.
- Big-bang refactoring without characterisation tests.
- Moving the switch into a factory that is then duplicated elsewhere.
- Strategies that still switch on the same axis internally.
- Missing default for unknown keys →
NullPointerExceptionat runtime. - Ignoring the expression problem — using strategies where operations, not variants, keep growing.
8Performance & Production Considerations
| Aspect | Conditional | Strategy |
|---|---|---|
| Execution speed | Branch prediction; very fast | Map lookup + virtual call; very fast |
| Deployment risk of a change | Whole method redeployed/retested | Only the affected strategy |
| Observability | Branch taken is implicit | Strategy name loggable/metric-taggable |
| Config-driven changes | Code change | Registry/config change possible |
Performance rarely decides between them; maintainability and change ownership do.
9Architecture & Design Considerations
- Track cyclomatic complexity and change frequency together: complex and frequently changed methods are prime Strategy candidates.
- Align strategy boundaries with team ownership (market, product line, tenant).
- Prefer sealed types + switch for closed domain variants; strategies for open, configurable or externally extended variants.
- Record the choice in an ADR — it predicts where future changes will land.
10Interview Questions & Answers
What is an if/else explosion?
An if/else explosion is conditional logic that keeps growing as new variants of the same concept are added, typically countries, payment methods, customer segments or file formats. The giveaway is not a single long if chain but repetition and growth: the same switch on country appears in shippingCost, eta, carrier and label methods, conditions nest across two axes such as country and express delivery, and every release adds branches in several files, with feature flags accumulating on top. The effects are rising cyclomatic complexity, merge conflicts between teams editing the same methods, an exploding number of test combinations and a high risk that a new branch breaks an existing one.
How does Strategy support extension without modification?
Strategy turns each variant into its own class or lambda behind a common interface, and a selector chooses the right one once, typically from a map keyed by the variation axis. Adding a variant then means writing a new class and registering it; existing strategies, the selector and their tests stay untouched, which is the Open/Closed Principle in practice. In Spring the registration is automatic: inject List<CountryShipping>, build an unmodifiable map by country, and a new @Component JapanShipping is picked up on the next start. Ownership becomes clearer too, because the team launching a market owns its strategy class and tests without editing anyone else's branches.
When should you keep a conditional instead of introducing Strategy?
Keep the conditional when the variation is small, stable and local. Two or three branches that rarely change, live in one method and are owned by one team are clearer as an if or a switch expression than as an interface plus several classes and a registry. Examples are mapping HTTP status families in an error handler or choosing between a couple of rounding modes. Introducing Strategy there adds files and indirection without reducing risk, which is premature abstraction. Switch to Strategy when the same condition starts appearing in several methods, branches grow every release, variants need runtime selection by configuration, or different teams need to own different variants.
What are the steps to refactor a conditional into strategies?
First, pin current behaviour with characterisation tests covering every branch, often a parameterised test with golden inputs and outputs. Second, identify the axis the conditionals switch on, such as country or payment code. Third, extract an interface with one method per decision that switches on that axis. Fourth, move one branch at a time into a strategy class and route only that key through the new path while the others still use the old code, running the tests after each move. Fifth, build a registry keyed by the axis, with an explicit failure for unknown keys. Finally, delete the old conditional once every branch has moved. The system stays releasable throughout.
How do sealed types and pattern-matching switch change the decision?
They give conditionals compile-time safety, so the choice is no longer simply polymorphism good, switch bad. With a sealed interface whose permitted subtypes are records, a pattern-matching switch is exhaustive: no default branch is needed, and adding a new subtype makes the compiler flag every switch that must handle it. Guards with when handle finer conditions such as the card network. This is ideal when the variant set is closed, data-like and owned by the same team as the operations, and new operations arrive more often than new variants. Strategy remains better when variants are open, selected by configuration at runtime, contributed by other teams or plug-ins, or carry their own dependencies.
What is the expression problem in this context?
The expression problem says no single structure makes it cheap to add both new variants and new operations. With switches over a sealed type, operations are functions outside the types, so adding an operation such as refundPolicy is one new function, but adding a variant forces an edit to every switch. With Strategy or polymorphism, each variant is a class implementing all operations, so adding a variant is one new class, but adding an operation means changing the interface and every implementation. The practical rule is to look at change history and predict which axis will grow: choose Strategy when variants multiply, and sealed types with switches when operations do.
How would you detect candidates for this refactoring in a large codebase?
I combine complexity with change frequency, because a complex switch that never changes is not worth touching. SonarQube or PMD give cyclomatic and cognitive complexity per method, and Git history shows how often each file changes; the intersection, complex methods modified every few weeks, is the hotspot list. Then I look for repeated switches on the same axis using IntelliJ structural search or simple searches for switches on country or method codes, and for change coupling, where several files with parallel switches are always committed together. Merge-conflict frequency and accumulating boolean flags are further signals. The result is a short list ranked by expected payoff, not a big-bang refactoring plan.
How does replacing conditionals with strategies affect team organisation?
It moves the code structure closer to team ownership, which is Conway's law working in your favour. While country or tenant rules live in shared switch statements, every team edits the same methods, so changes need cross-team review, merge conflicts are common and releases need coordination. Once each market, product line or tenant has its own strategy class, or even its own module contributing strategies through a shared interface, the owning team can change, test and deploy its rules without touching anyone else's. The shared interface becomes a contract that needs governance and careful evolution. The architectural gain is lower coordination cost and faster routine changes, more than reduced code complexity.