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?
answer
- switch on a type code, duplicated across files
- shotgun surgery / 'default: throw' in prod
- Replace Conditional with Polymorphism + registry
- expression problem: variants vs operations
- rule of three, not speculative interfaces
basics
~20 sIt 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 sThis 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// 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 editedgo deeper
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.
Add the concrete refactor: extract the interface implied by branch bodies, one implementation per variant, one registry/factory for dispatch, and mention shotgun surgery.
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.
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.