System Design
1. System Prerequisites

Polymorphism

⏱ 0s active on this topic

In simple words

Polymorphism means 'many forms'. You write one line of code, such as payment.pay(order), and what actually happens depends on the kind of payment object that is really there — card, UPI or wallet. The code that calls pay() never changes, so you can add a new kind of payment later without touching it. That is what makes a system easy to extend.


Two kinds (only one matters for system design)

KindAlso calledDecided atMatters for SD?
Compile-timeMethod overloading — same name, different parameterscompile timebarely
RuntimeMethod overriding — subclass/impl redefines a methodrun timethis is the one

Overloading example (skim it, move on):

int    add(int a, int b)       { return a + b; }
double add(double a, double b) { return a + b; }   // compiler picks by argument types

Everything below is runtime polymorphism.


The build-up — why you need it

Without polymorphism

class OrderService {
    private CardPaymentService payment;   // welded to ONE concrete class

    void checkout(Order o) {
        payment.pay(o);
    }
}

Now the business asks for UPI. You edit OrderService. Then wallet — edit again. Then crypto — edit again. Every new payment method modifies a class that has nothing to do with payment mechanics. That's an Open/Closed violation and a coupling problem.

With polymorphism

interface PaymentService {
    void pay(Order order);
}

class CardPaymentService   implements PaymentService { public void pay(Order o) { /* card  */ } }
class UpiPaymentService    implements PaymentService { public void pay(Order o) { /* UPI   */ } }
class WalletPaymentService implements PaymentService { public void pay(Order o) { /* wallet*/ } }

class OrderService {
    private final PaymentService payment;              // the INTERFACE, not a class
    OrderService(PaymentService payment) { this.payment = payment; }

    void checkout(Order o) {
        payment.pay(o);                                // same line for every payment type
    }
}

Add crypto tomorrow = one new class. OrderService never changes. That's the whole point.


How it actually works

PaymentService p;

p = new CardPaymentService();
p.pay(order);        // runs CardPaymentService.pay()

p = new UpiPaymentService();
p.pay(order);        // runs UpiPaymentService.pay()

The variable's declared type is PaymentService. The actual object decides which pay() runs. The JVM resolves this at run time — "dynamic dispatch".


In Spring — you use it constantly

@Service
class OrderService {
    private final PaymentService payment;              // interface
    OrderService(PaymentService payment) { this.payment = payment; }
}

Spring injects whichever PaymentService implementation is configured — OrderService has no idea (and shouldn't). Same with UserRepository → a JPA or Mongo implementation you never named. Polymorphism + dependency injection is the backbone of how Spring apps stay changeable.


Polymorphism vs its neighbours

Focus
Abstractionhides how a thing works, behind an interface
Polymorphismchooses, at runtime, which hidden implementation runs
Inheritancea mechanism that can produce the subtypes polymorphism dispatches over (interfaces do too)

They stack: you abstract with an interface, provide many implementations (via implements/extends), and polymorphism picks the right one per object.


At system-design scale

The same "one interface, many implementations, chosen at runtime" shows up everywhere:

PaymentService     → Card / UPI / Wallet / Crypto        (checkout picks per user)
NotificationService→ Email / SMS / Push / WhatsApp       (per user preference)
FileStorage        → S3 (prod) / LocalDisk (tests) / GCS (per environment)
LoadBalancer       → RoundRobin / LeastConnections / Weighted (per config)

How This Connects

  • Abstraction / Encapsulation — polymorphism needs an abstraction (an interface or base type) to dispatch over.
  • Composition vs Inheritance — you get the many subtypes from implements (composition-friendly) or extends; polymorphism doesn't care which, it dispatches on the real object.
  • Dependency Injection — DI hands the caller some implementation of the interface; polymorphism makes the call route to it. Together they're why you can swap a DB or a payment provider by config.
  • SOLID — this is the mechanism behind Open/Closed (add a class, don't edit one) and Dependency Inversion (depend on the interface).
  • Design Patterns — Strategy, Factory, Observer, State are all polymorphism aimed at a specific problem.
  • Module 3 — HLD — "the service talks to a gateway interface and we swap providers" is polymorphism at architecture scale.

Trade-offs & common mistakes

  • Don't declare fields/parameters as a concrete class (CardPaymentService payment) — declare the interface (PaymentService payment). Concrete types kill the flexibility.
  • Don't keep a if (type.equals("CARD")) … else if ("UPI") … chain to pick behaviour — that's the exact thing polymorphism replaces (one class per type).
  • Note: it adds an interface and a class per variant. If there's genuinely only ever one implementation, an interface is just ceremony — add it when the second variant is real.

In the Interview

When to say it. Any time you have "different behaviour for different types" or "swap this out":

  • "payment method varies per user — I'd model each as a PaymentService implementation"
  • "the repository is an interface so the same service works with Postgres in prod and a fake in tests"

The ~20-second version:

"Runtime polymorphism is one interface with many implementations, where the actual object decides which method runs. For payments I define a PaymentService interface and a class per method — card, UPI, wallet. OrderService depends on the interface and calls pay(); adding crypto later is a new class with zero changes to OrderService. It's what makes Open/Closed possible, and it's how Spring works — I inject PaymentService and never know or care which concrete implementation I got."

Follow-ups to expect:

  1. "Overloading vs overriding?" — Overloading = same name, different parameters, resolved by the compiler (compile-time). Overriding = a subtype redefines a method, resolved by the real object (runtime). System design cares about overriding.
  2. "How does the JVM know which method to call?" — Dynamic dispatch: the object carries a reference to its class's method table; the call routes through that at run time.
  3. "When is it over-engineering?" — When there's only ever one implementation. Then the interface adds indirection with no payoff.

Mistake to avoid: replacing an if/else type-switch with a switch that returns different subclasses but still keeps behaviour in the caller. Push the behaviour into the classes; the caller should just call pay().


Quick Revision — 6 lines

  1. Polymorphism = one reference type, many implementations; the real object picks which method runs (runtime).
  2. Two kinds: overloading (compile-time, minor) and overriding (runtime — the important one).
  3. Replaces "edit OrderService for every new payment type" with "add one class, OrderService unchanged".
  4. Declare the interface (PaymentService payment), never the concrete class.
  5. Spring: inject an interface, get an implementation you never named — polymorphism + DI.
  6. It's the mechanism behind Open/Closed, Dependency Inversion, and the Strategy/Factory/Observer patterns.

Quick-revision quiz

Question 1 of 17
`PaymentService p = new UpiPaymentService(); p.pay(order);` — what decides which `pay()` runs, and when?