Polymorphism
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)
| Kind | Also called | Decided at | Matters for SD? |
|---|---|---|---|
| Compile-time | Method overloading — same name, different parameters | compile time | barely |
| Runtime | Method overriding — subclass/impl redefines a method | run time | this 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 | |
|---|---|
| Abstraction | hides how a thing works, behind an interface |
| Polymorphism | chooses, at runtime, which hidden implementation runs |
| Inheritance | a 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) orextends; 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
PaymentServiceimplementation" - "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
PaymentServiceinterface and a class per method — card, UPI, wallet.OrderServicedepends on the interface and callspay(); adding crypto later is a new class with zero changes toOrderService. It's what makes Open/Closed possible, and it's how Spring works — I injectPaymentServiceand never know or care which concrete implementation I got."
Follow-ups to expect:
- "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.
- "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.
- "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
- Polymorphism = one reference type, many implementations; the real object picks which method runs (runtime).
- Two kinds: overloading (compile-time, minor) and overriding (runtime — the important one).
- Replaces "edit
OrderServicefor every new payment type" with "add one class,OrderServiceunchanged". - Declare the interface (
PaymentService payment), never the concrete class. - Spring: inject an interface, get an implementation you never named — polymorphism + DI.
- It's the mechanism behind Open/Closed, Dependency Inversion, and the Strategy/Factory/Observer patterns.
Optional. The note above covers what matters for design work — runtime polymorphism (overriding). This is the full mechanism behind method overloading, which interviews still probe with "what does this print?".
What counts as an overload
Overloading = several methods in one class with the same name but different parameter lists — differing in the number, type, or order of parameters.
These do not distinguish an overload; a method that differs only in one of them is a compile error:
- return type (
int f(int)vsdouble f(int)) - parameter names (
f(int a)vsf(int b)) - access modifier (
public f(int)vsprivate f(int)) throwsclausefinalon a parameter
Constructors overload by the same rules. Generics don't help: f(List<String>) and f(List<Integer>) both erase to f(List) → compile error.
How the compiler resolves the call — three phases
The target is chosen at compile time, trying these in order and stopping at the first that matches:
- Exact match, then primitive widening —
byte → short → int → long → float → double, andchar → int. No boxing, no varargs considered yet. The smallest necessary widening wins. - Autoboxing / unboxing — only if phase 1 found nothing.
int → Integer,Integer → int. - Varargs — last resort. A fixed-arity method always beats a
T...method.
Priority: widening > boxing > varargs. So given f(long) and f(Integer), the call f(5) picks f(long) — widening an int is preferred over boxing it (a backward-compatibility rule from before Java 5).
void f(int x) { print("int"); }
void f(Integer x) { print("Integer"); }
void f(long x) { print("long"); }
void f(Object x) { print("Object"); }
void f(int... x) { print("varargs"); }
f(1); // "int" — exact match
// drop f(int) -> "long" (widening beats boxing to Integer)
// drop f(int), f(long) -> "Integer" (boxing beats upcast to Object / varargs)
// keep only f(int...) -> "varargs"
int alone with only f(short) present is a compile error — int → short is narrowing, which the compiler never does implicitly.
Reference types, null, and ambiguity
For object parameters the most specific applicable type wins: with f(Object) and f(String), f("hi") calls f(String).
f(null) also calls f(String) there — String is more specific than Object. But if the candidates are unrelated — f(String) vs f(StringBuilder) — then f(null) is a compile error (ambiguous, no "more specific" one). Two candidates that each need exactly one widening (f(long, int) vs f(int, long), called with two ints) are ambiguous too.
Resolution uses the compile-time type, not the runtime object
The compiler picks from each argument's declared type. This is the single most common gotcha, and the exact opposite of overriding:
void f(Object o) { print("Object"); }
void f(String s) { print("String"); }
Object x = "hello";
f(x); // "Object" — chosen from x's DECLARED type
f((String) x); // "String" — the cast changes the compile-time type
Overriding dispatches on the real object at run time; overloading is fixed at compile time.
Overloading vs overriding
| Overloading | Overriding | |
|---|---|---|
| Where | one class (or inherited into one) | a subtype redefines a supertype method |
| Signature | same name, different parameters | same name and parameters |
| Return type | free (parameters already differ) | same, or a covariant subtype |
| Bound at | compile time (static) | run time (dynamic dispatch) |
| Decided from | declared type of the arguments | runtime type of the object |
static methods | can be overloaded | cannot be overridden (they are hidden) |
private / final | can be overloaded | cannot be overridden |
| Access modifier | any | cannot be narrower than the parent's |