A codebase has a `PaymentRecord` with a `method` string field ("CARD", "BANK_TRANSFER", "WALLET"), and the same `switch (method)` appears in fee calculation, settlement-delay estimation, and receipt rendering. Walk through applying the GRASP Polymorphism principle here, and state what you gain and what you give up.
answer
- same discriminator, 3+ switches → refactor
- move each case body into its own class
- one registry at the parsing boundary
- contract test across all implementations
- new type cheap / new operation costly
basics
~20 sCreate a PaymentMethod abstraction with fee(), settlementDelay(), and renderReceipt(). Add one class per method implementing all three. Map the stored string to a class once, in a factory. Callers just call the operations — the three switches disappear.
solid answer
~50 sThree switches over the same discriminator is the canonical trigger for GRASP Polymorphism. Steps: (1) collect the operations that branch on `method` — fee, settlement delay, receipt — and declare them on one abstraction, `PaymentMethod`; (2) create `CardPayment`, `BankTransferPayment`, `WalletPayment`, moving each switch *branch* into the matching class along with the fields only that branch used; (3) replace the persisted string with a single factory/registry lookup at the boundary where records are loaded or parsed; (4) delete the switches; callers now depend only on the interface. Gains: adding "CRYPTO" touches one new class instead of three edits scattered across modules; each variant's data can be private; every operation is guaranteed implemented. Losses: three files instead of three cases, comparison across variants now requires jumping between classes, and adding a *fourth* operation later means editing every class. Keep the boundary mapping in exactly one place, and don't do this at all if only one operation ever branches.
code
text · 17 lines// BEFORE (repeated in 3 modules)
switch (record.method) {
case "CARD": return amount * 0.029 + 0.30
case "BANK_TRANSFER": return Money.of(0.25)
case "WALLET": return amount * 0.015
default: throw new IllegalStateException(record.method)
}
// AFTER
interface PaymentMethod { fee(amount); settlementDelay(); renderReceipt(ctx); code() }
// single boundary mapping (registry, not switch — plugins can register)
registry = { "CARD": CardPayment::from, "BANK_TRANSFER": BankTransfer::from, "WALLET": Wallet::from }
method = registry[record.method] ?: throw UnknownPaymentMethod(record.method)
// call sites
method.fee(amount) // no branchinggo deeper
Describe the target shape: one interface, one class per method, factory maps the string. Name one gain (adding a method touches one file).
Give the ordered refactoring steps, say where the surviving factory lives and why, and name at least one real cost (lost side-by-side comparability or the expense of adding a new operation later).
Add contract tests across implementations, the expression-problem trade-off, serialization/lifecycle concerns, and the enum-with-behavior middle ground for a closed variant set.
Frame it as an extension-point decision: is the variant set open (third parties register) or closed (compile-time exhaustive)? Who owns each variant? Would a data-driven fee schedule beat code entirely?
## 1. Why this specific situation is the trigger GRASP Polymorphism is worth applying when **the same type discriminator drives more than one decision**. One switch in one place is cheap and readable. Three switches over the same `method` field is *shotgun surgery* waiting to happen: adding a payment method means finding all three, and a reviewer cannot tell whether you found them all. Duplicated branch structure over a shared type code is the single strongest signal for this refactoring. ## 2. The mechanical refactoring, step by step **Step 0 — Inventory the branches.** Grep for the discriminator (`method`, `kind`, `type`) and list every switch/if-chain. Note which fields each branch reads. Typically you find fields that are only meaningful for one variant (`cardLast4`, `ibanCountry`, `walletProvider`) — a *mutually exclusive fields* smell that confirms multiple types are hiding in one class. **Step 1 — Introduce the abstraction.** Declare the operation set on a common type: ``` interface PaymentMethod { fee(amount): Money settlementDelay(): Duration renderReceipt(ctx): Text } ``` Choose the operations deliberately: this interface is now a contract you'll pay to change. If a branch needs caller-specific context, pass it in as a parameter (`ctx`) rather than letting the variant reach back into the caller. **Step 2 — One class per variant ("replace conditional with polymorphism").** For each `case`, create a class and move that case's body in verbatim, then push the variant-only fields into it: ``` class CardPayment implements PaymentMethod { private last4, network fee(a) = a * 0.029 + 0.30 settlementDelay() = Duration.days(2) renderReceipt(ctx) = "Card ****" + last4 } class BankTransferPayment implements PaymentMethod { ... } class WalletPayment implements PaymentMethod { ... } ``` Do this one operation at a time, keeping tests green, rather than all three at once. **Step 3 — Create the variants once, at the boundary.** The persisted/received value is still a string. Convert it exactly once: ``` registry = { "CARD": CardPayment.from, "BANK_TRANSFER": ..., "WALLET": ... } function paymentMethodFrom(record) = (registry[record.method] ?: throw UnknownPaymentMethod(record.method))(record) ``` This surviving lookup is *not* a failure of the principle — it is the required translation from the untyped outside world (database column, JSON field, message payload) to the typed inside world. Keep it in one file so "where do I register a new method?" has one answer. A registry (map) is preferable to a hard-coded switch here because it allows registration from a module or plugin. **Step 4 — Delete the switches** and change call sites to `method.fee(amount)`. Any remaining `instanceof`/`is` check in a caller means the abstraction is missing an operation; add it rather than casting. **Step 5 — Test at the seam.** Write one contract test that runs the same assertions against every implementation ("fee is never negative", "settlementDelay is positive") so new variants are automatically held to the invariants. Then variant-specific tests for formulas. ## 3. What you gain, precisely - **Localized change.** New method = one new class + one registry line. Reviewable in isolation, and a plugin/module can supply it. - **Guaranteed completeness.** Missing an operation is a compile error, not a runtime `default: throw`. - **Encapsulation restored.** `cardLast4` need not be public on a shared record; it lives inside `CardPayment`. - **Cohesion.** All card knowledge in one place — you can review "is our card handling correct?" by reading one file. - **Testability.** Each variant is unit-testable without constructing the switch's surrounding context. ## 4. What you give up, precisely - **Comparability.** With a switch you could read all three fee formulas together and spot that one was 2.9% and another 0.29%. Spread across files, that cross-variant review gets harder. Mitigate with a table-driven test that asserts all fees side by side. - **Navigation cost.** "Go to definition" on `fee()` lands on the interface; readers need tooling or discipline to see implementations. - **Rigidity of the operation set.** Adding a fourth operation (`refundWindow()`) later means touching every class — and if variants live in other teams' modules or plugins, you cannot do it unilaterally without a default implementation or a versioned interface. This is the *expression problem*: class hierarchies make new **types** cheap and new **operations** expensive; switches do the reverse. - **Object lifecycle.** You now need instances where you previously had a string. If the record is loaded 10,000 times per request, allocate stateless singletons per variant rather than one object per record. - **Serialization round-trip.** Writing back to the database still needs a code (`method.code()`), so the discriminator does not vanish from the schema — it just stops being *interpreted* in multiple places. ## 5. When to stop short - **Only one operation branches** → keep the switch; a one-branch hierarchy is ceremony. - **The variants share almost all behavior and differ by a constant** → prefer a data table or a parameterized value object (`FeeSchedule(rate, fixed, delay)`), which is even more extensible than subclasses because new entries need no code at all. - **You don't own the type** (it comes from a third-party library or an external schema you cannot subclass) → an external dispatch table keyed by the code, or a Visitor, is the pragmatic option. ## 6. The intermediate option people forget Between "switch everywhere" and "full class hierarchy" sits **enum-with-behavior** (supported in Java, Kotlin, Rust-style enums with impls, Python enums with methods): a closed set of constants that each carry their own implementations. You get polymorphic dispatch and exhaustiveness, keep all variants visible in one file (preserving comparability), and lose only open extension by third parties — often exactly the right trade for a fixed business set like payment methods.
- You removed three switches but added a registry lookup. How is that better?Count the edit sites for a new payment method: before, three scattered switches in different modules with no compiler help; after, one new class plus one registry entry, and the compiler forces the new class to implement every operation. The number of places that *interpret* the discriminator dropped from three to zero.
- Six months later product asks for a `refundWindow()` on every payment method. What does that cost, and could you have avoided it?It costs an edit to every implementation — the expression problem's expensive direction. Mitigations: give the interface a sensible default implementation so only variants that differ must change; or, if the variant set is genuinely closed, use an enum-with-behavior or a sealed hierarchy with exhaustive matching so you get compile-time coverage without owning every file.
- When would you not do this refactoring at all?When only one operation branches on the type; when the variants differ only in constants (use a data table / parameterized value object instead); or when the types come from outside your control and cannot be subclassed.
Three separate checklists in three departments, each saying "if it's a card do X, if it's a wire do Y," versus giving each payment type its own trained specialist. The refactoring fires the checklists and hires specialists — onboarding a new payment type becomes hiring one person, not editing three documents in three buildings.
saying these in an interview costs you the question
- Creating the class hierarchy but leaving `instanceof`/`is` checks and downcasts in callers — that is the original switch with extra steps.
- Scattering the string→class mapping across several factories, so adding a variant still requires a codebase-wide hunt.
- Putting caller-specific concerns (HTTP formatting, SQL) inside the variant classes, coupling the domain to a delivery mechanism.
- Claiming the refactoring removes all conditionals, including the boundary parse.
- Refactoring for a single branch site — three files where four lines would do.
- Assuming subclasses are the only option and never considering a data table or enum-with-behavior for near-identical variants.