skip to content

You find a billing module where four different functions each contain a `switch` on a `customerType` code (RETAIL, WHOLESALE, GOVERNMENT). Adding a new customer type means editing all four. How do you restructure this to satisfy the Open/Closed Principle, and what exactly becomes "closed"?

level: middleimportance: must knowfreq 68%

answer

  1. Replace Conditional with Polymorphism
  2. One interface, one class per switch arm
  3. Exactly one lookup left: the registry/factory
  4. Measure success: next type = one new file
  5. Interface itself is still open to churn (new operation → edit all)

basics

~20 s

Create one interface with the four behaviors, and one implementation per customer type holding that type's four branches. The billing code looks up the right implementation and calls it, so a new type is a new class, not four edits.

solid answer

~50 s

Replace the type code plus scattered conditionals with polymorphism. Define an interface — say `CustomerPolicy` with `discount()`, `taxRate()`, `invoiceTerms()`, `creditLimit()` — and create one implementation per customer type, moving each `switch` arm into the matching implementation. The billing functions stop switching and instead resolve a `CustomerPolicy` once (from a registry/map/DI container keyed by the stored type code) and delegate. What becomes **closed** is the billing orchestration code: adding GOVERNMENT_SUBSIDIZED is a new file plus one registry entry, with zero edits to the four functions and zero risk of forgetting one. This is the classic *Replace Conditional with Polymorphism* refactoring, and it also fixes the cohesion problem — everything about wholesale customers now lives in one place instead of being smeared across four files. Costs: one indirection, one lookup point that still needs a "unknown type" fallback, and a design decision if two types share most behavior (compose or use a shared base, not a deep hierarchy).

code

typescript · 20 lines
typescript
interface CustomerPolicy {
  readonly code: string
  discount(order: Order): Money
  taxRate(): number
  invoiceTermsDays(): number
  creditLimit(): Money
}

class PolicyRegistry {                       // the ONE remaining lookup
  private readonly byCode = new Map<string, CustomerPolicy>()
  constructor(all: CustomerPolicy[]) { all.forEach(p => this.byCode.set(p.code, p)) }
  for(code: string): CustomerPolicy {
    const p = this.byCode.get(code)
    if (!p) throw new Error(`unknown customer type: ${code}`)
    return p
  }
}

// Billing is now closed: it never mentions RETAIL / WHOLESALE / GOVERNMENT.
function buildInvoice(order: Order, policy: CustomerPolicy): Invoice { /* ... */ }

go deeper

for a junior

Describe extracting an interface with one implementation per type and calling through it instead of switching.

for a middle

Walk the full refactor: inventory branches, name the abstraction by responsibility, move code, single registry, unknown-key handling. State what is closed and what still isn't.

for a senior

Add the trade-offs: expression problem, when data/config beats classes, composition over inheritance for shared behavior, resolve-once-and-pass-down, and stateless shareable policies.

for a principal

Frame it as a change-cost decision — which axis actually grows, what the deployment story is (new class = deploy vs config row = no deploy), and whether the variant set belongs in code at all versus a rules/pricing service owned by the business.

## Vocabulary first - **Type code**: a primitive value (string/enum/int) stored on an entity that encodes "which kind of thing this is" — here `customerType`. - **Conditional on type code**: `if/switch` that branches on that value to pick behavior. - **Shotgun surgery**: a code smell where one logical change forces edits in many separate places. Four switches on the same code is textbook shotgun surgery. - **Polymorphism**: one call site dispatching to different implementations chosen at runtime by the object's actual type. - **Replace Conditional with Polymorphism**: the named refactoring (Fowler) that converts the former into the latter. ## The mechanical refactor, step by step 1. **Inventory the branches.** Enumerate every `switch (customerType)` in the codebase. Grep for the enum/constant, not just the word `switch` — branches hide in ternaries, map lookups, and template conditionals too. Suppose you find four: discount %, tax rate, invoice payment terms, credit limit. 2. **Name the abstraction after the responsibility, not the type.** `CustomerPolicy` / `PricingPolicy` — not `CustomerTypeHandler`. Give it exactly the operations the branches supply. 3. **Create one implementation per arm.** `RetailPolicy`, `WholesalePolicy`, `GovernmentPolicy`, each containing that type's four behaviors. Move code, do not rewrite it — keep the refactor behavior-preserving and let existing tests stay green. 4. **Introduce one resolution point.** Somewhere the stored `customerType` string must become an object. Options: a `Map<String, CustomerPolicy>` registry populated at startup; a DI container injecting all implementations and indexing them by a `supports()`/`key()` method; a factory function. There is exactly **one** remaining conditional, and it is a lookup, not business logic. 5. **Delete the old switches**; the four functions now call `policy.discount()` etc. 6. **Handle the unknown key.** Decide explicitly: throw (fail fast, good when data is trusted), or fall back to a `DefaultPolicy` / null-object (good when new codes may arrive from upstream before the code ships). 7. **Carry the policy, not the code, through the call stack.** Resolve once at the boundary and pass the `CustomerPolicy` object down, rather than re-resolving from the string in four places. ## What is now open and what is now closed - **Closed**: the billing orchestration functions, and every future call site that consumes `CustomerPolicy`. A new customer type never edits them. - **Open**: the set of `CustomerPolicy` implementations. Adding one is additive. - **Still not closed**: the *interface itself*. If the business adds a fifth behavior ("late-fee schedule"), every implementation must change. That is the expression-problem trade-off you accepted: cheap to add types, expensive to add operations. If new operations are the more frequent change, this refactor is the wrong bet and an exhaustive, compiler-checked `switch` in one place might serve better. ## Design decisions inside the refactor - **Shared behavior between types.** If WHOLESALE and GOVERNMENT differ only in tax, resist a deep inheritance chain. Prefer composition: give each policy small collaborators (`TaxRule`, `TermsRule`) and assemble them, or provide a base with sane defaults and override the one method. Deep hierarchies re-create the coupling you just removed and can violate the Liskov Substitution Principle. - **Where does the object come from?** If the entity is loaded from a database as data, it usually should not itself contain the policy — a persistence-layer mapper or the DI registry attaches it. Keeping the policy separate from the persisted entity avoids dragging the ORM into your domain rules. - **Data instead of classes.** If the branches differ only in *values* (discount 0%, 5%, 12%), the honest OCP move may be a configuration table, not four classes. Then adding a type is a data change with no deployment at all. Classes earn their keep when the branches differ in *logic*. - **Statefulness.** Policies should be stateless and safe to share as singletons; if a policy needs per-customer data, pass it as a method parameter rather than storing it, or you break sharing and thread-safety. ## How to know the refactor paid off The honest test: implement the *next* customer type and count files edited. OCP delivered if the diff is "one new file + one registry line + its tests." If you still had to edit three existing files, you missed branches — go find them. ## When not to do this With exactly one switch, in one place, on a set that has not changed in three years, the abstraction is pure cost. The trigger for this refactor is the *third* occurrence of the same switch, or the second time a new type caused a production bug because someone missed a branch. Doing it preemptively for a set that never grows is speculative generality.

  • You still have one `switch`-like lookup in the registry. Have you really achieved OCP?
    Yes, in the way that matters. OCP does not require zero conditionals — it requires that adding a variant not force edits to existing *behavioral* code. Concentrating the type-to-object mapping in exactly one small, mechanical place (or eliminating it via DI auto-discovery) means new variants are additive and mistakes are impossible to scatter.
  • What if two of the customer types share 90% of their behavior?
    Prefer composition over an inheritance chain: build each policy from small reusable rule objects, or use a base class with defaults and override the single differing method. A deep hierarchy reintroduces coupling and risks Liskov violations where a subtype silently changes contract expectations.
  • When would you deliberately keep the `switch` instead?
    When the variant set is genuinely fixed and exhaustiveness matters (the compiler can then force you to handle a new case everywhere), when there is only one switch site, or when the branches differ only by values — in which case a config table beats both a switch and a class hierarchy.

Four different clerks each keep a personal cheat sheet mapping customer type to a rule. Add a customer type and you must update all four sheets — miss one and that clerk gets it wrong silently. The fix is to give every customer a folder that already contains its own rules; the clerks just read the folder they were handed.

context