How would you refactor a long conditional that dispatches among several algorithms into the Strategy pattern, and where does the runtime selection of the strategy end up living?
answer
- replace conditional with polymorphism
- one interface from the widest branch
- branch locals → constructor params
- switch collapses into a registry/factory
- unknown key: fail loud or Null Object
basics
~20 sExtract each branch into its own object implementing one shared interface, replace the branching call site with a delegation, and move the choice into one place — a factory or a map from key to strategy — that the client uses to pick the right implementation.
solid answer
~60 sMechanically: (1) find the switch and make every branch call the *same* shape — same inputs, same return type, no side effects unique to one branch; (2) define the interface from that shape; (3) move each branch body into its own class, promoting branch-local variables to constructor parameters; (4) replace the switch body with `strategy.execute(input)`; (5) replace the switch itself with a selection point — usually a `Map<key, Strategy>` built at startup by DI, or a factory; (6) delete the enum-driven branching and default to an explicit failure or a Null Object for unknown keys. The selection cannot disappear: something must map external input (request field, config, tenant, feature flag) to an implementation. Good options: a registry map, self-selecting strategies with `supports(input)` plus a priority for tie-breaks, or DI qualifiers. Bad option: `instanceof` checks or a switch inside the Context — that recreates the smell. Key risks: strategies needing different inputs (fix with a parameter object, not by widening the interface with unused fields), and branches that weren't actually the same operation.
code
typescript · 18 linesinterface Billing { total(o: Order): number }
class FreeBilling implements Billing { total(o){ return o.subtotal } }
class ProBilling implements Billing { total(o){ return o.subtotal * 0.9 } }
class EnterpriseBilling implements Billing {
constructor(private tiers: TierLookup) {} // dependency only this variant needs
total(o){ return o.subtotal * (1 - this.tiers.for(o.customerId).discount) }
}
// the former switch, collapsed into one selection point
const registry: Record<Plan, Billing> = { FREE: new FreeBilling(), PRO: new ProBilling(),
ENTERPRISE: new EnterpriseBilling(tiers) };
function billingFor(p: Plan): Billing {
return registry[p] ?? (() => { throw new Error(`unknown plan ${p}`) })();
}
class Invoicer { constructor(private billing: Billing) {}
total(o: Order){ return this.billing.total(o) } } // no branching leftgo deeper
Describe the mechanics: one class per branch, a shared interface, the call site delegates, and a factory picks the class.
Add the selection mechanisms (registry map, factory, DI) and unknown-key handling, and note that branch-local variables become constructor parameters.
Discuss the parameter problem and parameter objects, thread-safe immutable strategies, lost switch exhaustiveness and how to recover it, and the testing split between strategies and selector.
Weigh it as an extension-point decision: who owns new variants, whether selection should be config- or flag-driven, how policies are observed and rolled out, and the cost of publishing an interface you cannot change later.
## Step-by-step refactor ("Replace Conditional with Polymorphism") Starting point: ``` class Invoicer { total(order, plan) { base = order.subtotal if (plan == FREE) { return base } else if (plan == PRO) { d = base * 0.10; log(...); return base - d } else if (plan == ENTERPRISE) { tier = lookupTier(order.customerId) return base - base * tier.discount + contractFee(order) } else throw Unknown(plan) } } ``` **Step 1 — normalize the branches.** Make each branch a single expression producing the same type from the same inputs. If one branch also writes an audit record and another doesn't, decide whether that side effect belongs to the algorithm (keep it inside the strategy) or to the Context (hoist it out before extracting). Mixed responsibilities are the most common reason a Strategy refactor stalls. **Step 2 — derive the interface from the widest branch.** Here every branch consumes an `Order` and returns `Money`, so `interface Billing { total(order: Order): Money }`. Resist adding parameters that only one branch needs; see the parameter problem below. **Step 3 — extract each branch into a class.** Anything the branch read from the enclosing scope becomes either a method parameter (if it varies per call) or a constructor dependency (if it is fixed per configuration). `lookupTier` becomes an injected collaborator of `EnterpriseBilling`, which is a *win*: the free and pro paths no longer drag that dependency around, so their tests need no stubs. **Step 4 — delegate.** The Context becomes `total(order) = billing.total(order)` and stops mentioning plans entirely. If any `instanceof`/`switch` on the strategy remains inside the Context, the refactor is incomplete. **Step 5 — build the selection point.** Choose one of: - **Registry map** — `Map<Plan, Billing>` populated at startup (hand-written or by a DI container collecting all implementations). Selection is `registry[plan] ?: throw UnknownPlan(plan)`. Simple, fast, easy to assert in a test that every enum value has an entry. - **Self-selecting chain** — each strategy exposes `supports(input): boolean` and the selector takes the first match, with an explicit `priority`/`order` so the result is deterministic. Good when the criterion is richer than an enum (amount thresholds, feature flags, country rules). Cost: selection logic scatters, and overlapping predicates create subtle precedence bugs — always define and test the tie-break. - **DI qualifier/profile** — the container injects one implementation per environment or tenant. Good for a choice fixed at deployment; poor when it varies per request. - **Factory method** — a single function encapsulating the mapping, possibly with defaulting and normalization. Effectively a hand-written registry, useful when construction requires per-call arguments. **Step 6 — handle the unknown key.** Two respectable answers: fail loudly (`UnknownPlan`), or install a **Null Object** (a strategy that does the neutral thing) when a missing key is legitimately "no behavior". Never fall through silently to the first branch. ## Where the conditional actually went It did not vanish — it *collapsed*. Before: potentially many switch sites across the codebase, each needing an edit for a new variant. After: one selection point, plus one new class per variant. That is the real payoff: the number of places that must change when the family grows goes from N to 1 (and to 0 if the container discovers implementations automatically). ## The parameter problem (most common failure) One variant needs data the others don't (`ENTERPRISE` needs a contract). Tempting fixes and their consequences: - *Widen the interface* (`total(order, contract, promoCode)`) — every implementation now ignores most arguments; callers must supply nulls; the contract lies. - *Pass a context/parameter object* (`total(BillingRequest)`) — usually right: one cohesive argument that can grow without breaking implementations, and each strategy takes what it needs. - *Let the strategy fetch its own data* via an injected collaborator — right when the extra data is derivable from what's already passed (as with `lookupTier`), wrong when it makes a pure calculation do I/O. - *Make the strategy stateful*, configured per call — a thread-safety trap; strategies should be immutable and shareable, with per-call data passed as arguments. ## Other traps - **Premature extraction.** Two stable branches of three lines each are clearer as an `if`. Extract when the family is growing, the branches are long, or external code needs to add a variant. - **Anemic strategies.** If each class is a one-line return, consider a data table (`Map<Plan, BigDecimal>`) — the varying thing is a *value*, not an *algorithm*. - **Shared code between variants.** Factor it into a collaborator both strategies call, or an abstract base — but a base class that calls abstract hooks is Template Method, so be deliberate about which pattern you are in. - **Losing exhaustiveness.** A `switch` over an enum can be compiler-checked exhaustive in many languages; a registry map is not. Compensate with a startup assertion or a test that every enum constant resolves. - **Testing.** Test each strategy directly (fast, no branching setup), test the *selector* separately (every key maps to the expected type, unknown key fails), and keep one integration test proving the Context wires them together.
- After the refactor, a compiler that used to check switch exhaustiveness no longer helps. How do you protect against a new enum value with no strategy?Add a startup or unit test that iterates every enum constant and asserts the registry resolves one, fail fast on an unknown key at runtime, or keep a small exhaustive factory function so the compiler still checks the mapping in one place.
- One variant needs an extra input the others ignore. What do you do?Introduce a parameter object carrying the request's data so the signature stays stable and implementations take what they need, or inject a collaborator into the variant that needs it. Do not widen the interface with parameters most implementations ignore.
- When is this refactor not worth doing?When the branches are few, short, and stable, when each 'algorithm' is really just a constant (use a lookup table of values), or when the branches don't share a single coherent operation — forcing them into one interface would produce a lying contract.
A restaurant that had one cook shouting a different recipe per order becomes a kitchen of specialists plus a single expediter at the pass. The decision still happens — once, at the pass — instead of inside every dish.
saying these in an interview costs you the question
- Believing Strategy eliminates the conditional entirely, rather than collapsing many branch sites into one selection point.
- Leaving `instanceof`/type switches inside the Context after extraction.
- Widening the interface with parameters only one implementation uses, forcing nulls at every call site.
- Making strategies mutable and configuring them per call, then sharing one instance across threads.
- Silently falling back to a default strategy on an unknown key, hiding data errors.
- Extracting three-line stable branches into three files and calling it an improvement.