skip to content

Every time a new payment method is added, developers edit the same switch statement in three different files. Which SOLID principle is being violated, and what refactoring restores compliance?

level: middleimportance: must knowfreq 74%

answer

  1. switch on a type code, duplicated across files
  2. shotgun surgery / 'default: throw' in prod
  3. Replace Conditional with Polymorphism + registry
  4. expression problem: variants vs operations
  5. rule of three, not speculative interfaces

basics

~20 s

It violates the Open/Closed Principle: adding a variant forces editing existing code. Replace the repeated conditionals with polymorphism - one interface per payment method with fee, validate and charge behaviour - so a new method means adding a class, not editing three.

solid answer

~50 s

This is an Open/Closed Principle (OCP) violation: software should be open for extension but closed for modification. The tell is a type-discriminator conditional (switch on a payment-kind enum) duplicated across files - Fowler's 'shotgun surgery' smell. Each new variant means finding and editing every copy, and a missed copy is a production bug. Refactor with Replace Conditional with Polymorphism: define a PaymentMethod abstraction with the operations the switches performed (validate, fee, charge), give each variant its own implementation, and resolve the implementation once - via a registry, factory, or DI container keyed by the discriminator. Exactly one place still knows the mapping. The trade-off: OCP requires guessing the axis of variation. If the code instead grows new *operations* over a fixed set of types, polymorphism makes it worse - a single exhaustive match over a sealed hierarchy is better. Apply after the second or third variant, not speculatively.

code

pseudocode · 19 lines
pseudocode
// Before: same switch repeated in fee/validate/charge sites
fun fee(kind: Kind, amt: Money) = when (kind) {
  CARD  -> amt * 0.029
  SEPA  -> Money(0.35)
  CRYPTO-> amt * 0.01
}

// After: one abstraction, one dispatch point
interface PaymentMethod {
  val key: String
  fun fee(amt: Money): Money
  fun charge(amt: Money): Result
}

class PaymentRegistry(methods: List<PaymentMethod>) {
  private val byKey = methods.associateBy { it.key }
  fun of(key: String) = byKey[key] ?: error("unknown method $key")
}
// adding a method = adding a class; no existing file is edited

go deeper

for a junior

Name the principle, identify the duplicated switch as the smell, and describe one interface per payment method so new methods are added, not edited in.

for a middle

Add the concrete refactor: extract the interface implied by branch bodies, one implementation per variant, one registry/factory for dispatch, and mention shotgun surgery.

for a senior

Discuss which axis you are closing against, the expression problem, the rule of three, exhaustive sealed matches as the alternative, and data-driven variation instead of classes.

for a principal

Frame OCP as a bet on the direction of change: cite change-history evidence for choosing the axis, weigh plugin/runtime extension against compile-time exhaustiveness, and note the organisational payoff (variants added by other teams without touching core code).

## The principle **OCP (Open/Closed Principle)** - 'software entities should be open for extension, but closed for modification'. Bertrand Meyer's original version achieved this by inheriting from a closed base class. Robert C. Martin's *polymorphic OCP* - the version meant in interviews - achieves it by having callers depend on an **abstraction** (interface) whose implementations can be added without editing the caller. 'Closed for modification' means: to add behaviour, you add a file; you do not reopen and edit working, tested code. ## Recognising the violation The canonical smell is a **type-code conditional**: a `switch`/`if-else` chain on an enum, string, or class-of-object, where each branch is variant-specific behaviour. Warning intensity rises with duplication: - **One switch, in one place** (typically a factory) - usually fine, often unavoidable. - **The same switch duplicated in several files** - a genuine OCP violation, and the direct cause of *shotgun surgery* (one logical change scattered across many files). - **`instanceof` / type-check chains** before behaviour - the same smell wearing a different hat, and often also an LSP symptom. - **A 'default: throw' branch that fires in production** - proof that a variant was added without all sites being updated. Other evidence: pull requests that add a feature always touch the same set of unrelated files; a checklist in the wiki titled 'when adding a new X, remember to update...'. ## Refactoring strategies 1. **Replace Conditional with Polymorphism.** Extract the interface implied by the branch bodies (`validate`, `fee`, `charge`), create one implementation per variant, move each branch body into its implementation. 2. **Collapse dispatch to one site.** Use a **registry/map** from discriminator to implementation, a factory, or the DI container's ability to inject a collection of implementations. One remaining lookup replaces N switches. 3. **Strategy pattern** when the variation is an algorithm chosen at runtime. 4. **Table-driven dispatch** when the variation is data (rates, limits), not behaviour - a lookup table is simpler and better than a class per row. 5. **Plugin / service-loader** when variants must be added without rebuilding the core - the strongest form of 'closed'. A useful sequencing: first *unify* the duplicated switches into one, then replace that one with polymorphism. Unifying alone already removes most of the risk. ## Trade-offs, limits, and when the switch is right - **OCP is directional.** You must pick the axis you are closing against. This is the **expression problem**: class-based polymorphism makes adding *variants* cheap and adding *operations* expensive (every implementation must change); a switch makes adding *operations* cheap and adding *variants* expensive. If your system grows new operations over a stable set of types, keep the switch - ideally an exhaustive match over a **sealed/closed hierarchy** so the compiler reports every site when a type is added. - **No design is closed against every change.** Martin's own advice: be closed against the changes you have actually experienced. - **Speculative OCP is over-engineering.** An interface with one implementation, added 'because someone might', costs navigation and indirection with no payoff. The **rule of three** is the common heuristic: duplicate once, extract on the third variant. - **Wrong seam is expensive.** As Sandi Metz puts it, the wrong abstraction is costlier than duplication, because callers grow flags and special cases to work around it. - **Data-driven variation beats classes.** Ten payment methods differing only by a percentage do not need ten classes. - **Configuration/runtime cost.** Registries and plugin loading move errors from compile time to run time; keep the mapping validated by a test that asserts every discriminator resolves. ## Interplay with the other principles OCP is enabled by **DIP** (callers depend on the abstraction, not the variants) and depends on **LSP** (if a variant is not truly substitutable, callers reintroduce type checks and the design reopens). **ISP** keeps the extracted abstraction small enough that new variants can implement it honestly.

  • Is a single switch statement inside a factory an Open/Closed Principle violation?
    Practically, no. Something must map an external discriminator (an enum, a database column, a JSON field) to an implementation, and concentrating that in one factory or registry is the accepted cost. The violation is the *duplication* of that switch across call sites. If the mapping itself must change without a rebuild, replace the factory with a plugin/service-loader mechanism.
  • When does replacing conditionals with polymorphism make the design worse?
    When new *operations* rather than new *types* are what keeps arriving (the expression problem), because every new operation forces editing every implementation. Also when the variants differ only by data - ten classes that each return a different rate should be a lookup table - and when you have only one variant so far, where the interface is speculative generality.
  • How can the compiler help you avoid a missed branch when you deliberately keep a switch?
    Model the variants as a sealed/closed hierarchy or enum and use an exhaustive match with no default branch. Adding a variant then produces compile errors at every match site - the change is still scattered, but it can no longer be silently forgotten, which is the real danger of the duplicated-switch smell.

A power socket is closed for modification and open for extension: any new appliance plugs in without rewiring the house. A house where each appliance is hard-wired needs an electrician (an edit) for every new device.

saying these in an interview costs you the question

  • Claiming OCP means 'never edit existing code' - bug fixes and refactoring obviously modify code.
  • Treating every switch statement as a violation, including the single dispatch point that must exist somewhere.
  • Adding an interface with exactly one implementation and calling it OCP compliance.
  • Ignoring the expression problem and reflexively converting conditionals into class hierarchies.
  • Believing inheritance is the only way to satisfy OCP - composition, higher-order functions and data tables also work.

context