Polymorphism: one interface, many behaviors
Polymorphism lets you write code against an abstraction and swap behaviors at runtime. It is the mechanism behind the Open/Closed Principle and the Strategy pattern.
Mental Model
Polymorphism is one message with several valid implementations. The caller holds a promise such as Payment, sends process(request), and does not need to know whether the object is a card, wallet, or crypto implementation. The runtime chooses the behavior; the contract keeps the caller stable.
The Problem
Without polymorphism, conditional logic sprawls across your codebase. Every caller that needs to handle multiple payment types reaches for instanceof and grows a new branch every time a new type is added.
// Adding Bitcoin support means editing this method (and every method like it).
// This violates the Open/Closed Principle.
public class PaymentProcessor {
public void process(Object payment, PaymentRequest request) {
if (payment instanceof CreditCardPayment cc) {
cc.chargeCreditCard(request.amount(), cc.getCardToken());
} else if (payment instanceof PayPalPayment pp) {
pp.initiatePayPalCharge(request.amount(), pp.getEmail());
} else if (payment instanceof CryptoPayment crypto) {
crypto.broadcastTransaction(crypto.getWalletAddress(), request.amount());
}
// BitcoinLightning next sprint? Edit this class again.
}
}
The pattern that fixes this is simple: define a shared interface, push the conditional into the type system, and let the JVM dispatch the right behavior at runtime.
Core Concept
Polymorphism ("many forms") lets a single method name produce different behavior depending on the runtime type of the object. Java has two kinds.
Compile-time polymorphism (method overloading): the compiler resolves which method to call based on the static parameter types.
Runtime polymorphism (method overriding): the JVM resolves which method to call at runtime based on the actual object, not the declared reference type. This is the important one for design.
PaymentProcessor depends on Payment, not on any concrete class. Adding BitcoinLightningPayment requires zero changes to PaymentProcessor. That is the Open/Closed Principle expressed through polymorphism.
Five-Minute Explanation
Walk through polymorphism in this order:
- Find the variation. Ask which part of the operation changes when the input kind changesβin this case, how a payment is processed or refunded.
- Define one honest contract. Put the common inputs, outputs, and failure semantics on
Payment; do not hide type-specific methods behind a broadObjectparameter. - Move behavior to implementations. Each payment type owns its provider-specific details and honors the same result contract.
- Call through the abstraction.
PaymentProcessorinvokespayment.process(request)once. Runtime dispatch selects the concrete override. - Check the boundary. Use overloading for compile-time convenience, overriding for runtime variation, and a sealed hierarchy when exhaustive handling is more important than third-party extension.
Polymorphism pays off only when the implementations are genuinely substitutable. A class that throws for a required operation or changes the meaning of the return value is not a safe subtype.
Concrete Example: Swapping Payment Providers
The same processor call can use two different implementations without changing the processor:
Payment payment = new PayPalPayment(/* account details */);
PaymentResult paypalResult = payment.process(request);
payment = new CreditCardPayment(/* token and network */);
PaymentResult cardResult = payment.process(request);
The variable is declared as Payment, but the object carries the behavior. The processor depends on the stable method contract; provider-specific authentication, network calls, and refund details stay inside each implementation.
Implementation
// The polymorphic contract. Every payment method must implement this.
// PaymentProcessor depends on this interface, never on a concrete class.
// Adding a new payment type = adding a new class, not modifying any existing code.
public interface Payment {
/**
* Process the payment. Returns a result encapsulating success/failure
* rather than throwing exceptions for expected business outcomes.
*/
PaymentResult process(PaymentRequest request);
/**
* Initiate a refund for a previously processed payment.
* refundId is the transaction ID returned from process().
*/
RefundResult refund(String refundId);
}Compile-time polymorphism (method overloading)
The compiler picks the correct overload at compile time based on the static type of the argument. At runtime, there is no JVM decision to make.
// Compiler resolves format(int), format(double), or format(String) at compile time.
public class Formatter {
public String format(int value) { return String.valueOf(value); }
public String format(double value) { return String.format("%.2f", value); }
public String format(String value) { return "\"" + value + "\""; }
}
Formatter f = new Formatter();
f.format(42); // resolves to format(int) -- compile-time decision
f.format(3.14); // resolves to format(double)
f.format("hello"); // resolves to format(String)
Sealed interfaces (Java 17): exhaustive polymorphism
Sealed interfaces constrain which classes can implement an interface. Combined with switch expressions, they force you to handle every variant at compile time.
// Sealed: only these three types can implement Payment in this module.
public sealed interface Payment permits CreditCardPayment, PayPalPayment, CryptoPayment {
PaymentResult process(PaymentRequest request);
}
// The compiler enforces exhaustiveness -- no default branch needed:
String describe(Payment p) {
return switch (p) {
case CreditCardPayment cc -> "Card: " + cc.cardToken();
case PayPalPayment pp -> "PayPal: " + pp.email();
case CryptoPayment c -> "Wallet: " + c.walletAddress();
// Compiler error if you add a fourth permitted type and miss it here.
};
}
This is the opposite of the instanceof chain: the compiler catches missing cases, not a test that happened to run.
How It Works
PaymentProcessorcallspayment.process(request)on itsPaymentreference.- The JVM looks up the actual runtime class in the vtable (a per-class method pointer table).
- For
CreditCardPayment, the vtable entry points toCreditCardPayment.process(). ForPayPalPayment, it points toPayPalPayment.process(). - The same bytecode instruction in
PaymentProcessorproduces different behavior based entirely on which concrete type was injected.
Design Trade-offs and Common Pitfalls
| Pitfall | What goes wrong | Fix |
|---|---|---|
instanceof chains | Every new type requires editing the conditional. Violates OCP and grows unbounded. | Introduce an interface, push the conditional into the type hierarchy. |
| Confusing overloading with overriding | Overloading is compile-time. Overriding is runtime. Declaring a variable as Payment and expecting CreditCard-specific overloads to fire will not work. | Overloading: same name, different parameter types. Overriding: same signature, subtype replaces behavior. |
| Widening breaks overload resolution | Calling format(1L) where only format(int) exists causes widening to Object, not an error. The wrong overload fires silently. | Add the long overload, or use Number as the parameter type and dispatch inside. |
| Liskov violations in overrides | Override throws new unchecked exceptions or tightens preconditions. Callers that expect the interface contract break at runtime. | Subtype must honor the interface contract: only relax preconditions, only tighten postconditions. |
| Missing covariant return types | Candidate doesn't know subclasses can override with a more specific return type. | Covariant returns are valid in Java: a subclass can override Payment createPayment() with CreditCardPayment createPayment(). |
Comparison: Overloading vs Overriding vs Generics
| Dimension | Overloading | Overriding | Generics |
|---|---|---|---|
| Resolution time | Compile-time | Runtime (JVM vtable) | Compile-time (erased at runtime) |
| Use case | Same operation, different types | Substitutable behaviors | Type-safe collections and algorithms |
| OCP impact | No | Yes: add types without modifying callers | Yes: List<? extends T> accepts any subtype |
| Java feature | Same class or hierarchy | Subtype of interface/class | Type parameters with extends |
Real-World Examples
Spring's ApplicationContext is the classic example of runtime polymorphism. You annotate a field with @Autowired NotificationService notificationService, and Spring injects the concrete implementation based on your configuration. The caller never knows whether it received an EmailNotificationService or a SlackNotificationService.
java.util.List exposes one polymorphic interface (add, get, remove) implemented by ArrayList (O(1) random access), LinkedList (O(1) head/tail insert), and CopyOnWriteArrayList (thread-safe reads). Callers swap implementations by changing one constructor call, touching nothing else.
java.util.Comparator is parametric polymorphism in the standard library. Collections.sort(list, comparator) works on any List<T> with any Comparator<T>. Adding a new sort criterion means writing a new Comparator implementation. The sort algorithm never changes.
Interview Tips
Mistake 1: "Overloading and overriding are both polymorphism, so they work the same."
They don't. Overloading is resolved at compile time using the static declared type. If you pass a PayPalPayment variable declared as Payment, the compiler picks the overload for Payment, not PayPalPayment. Overriding is resolved at runtime. Mixing these up in an interview is a red flag.
Mistake 2: "I use instanceof to check the payment type and call the right method."
This is the pre-polymorphism approach. It violates OCP: every new payment type requires editing the conditional. The correct answer is to define a Payment interface with a process() method, implement it per type, and call payment.process(). The JVM dispatches for you.
Mistake 3: Not cold-answering "How do you add Bitcoin support without changing any existing code?"
The answer: implement the Payment interface in a new BitcoinPayment class, register it in the DI container, and you are done. PaymentProcessor never changes. This is the core extension question that demonstrates the payoff.
Mistake 4: Can't explain covariant return types.
A subclass can override a method and return a more specific type. A PaymentFactory.create() returning Payment can be overridden in CreditCardFactory to return CreditCardPayment. Valid Java, useful in fluent builder hierarchies.
Mistake 5: Has not heard of sealed interfaces (Java 17). Sealed interfaces give you exhaustive polymorphism: the compiler enforces that every subtype is handled in a switch expression. Use them for closed type hierarchies (ASTs, event types, command objects) where you want compile-time safety over openness.
Test Your Understanding
Quick Recap
- Runtime polymorphism lets the JVM dispatch the right method at runtime via vtable lookup. The caller never knows the concrete type.
- Compile-time polymorphism (overloading) is resolved by the compiler based on the static argument type, not the runtime type.
- The core payoff: polymorphism turns
if (type == X) doX() else if (type == Y) doY()into a single method call, satisfying OCP and eliminating the growing branch. - Sealed interfaces (Java 17) give you exhaustive polymorphism: the compiler enforces that every subtype is handled in switch expressions.
- Liskov Substitution Principle is the constraint: every polymorphic subtype must honor the interface contract, or callers break at runtime.
- Parametric polymorphism (generics with
List<? extends Payment>) adds compile-time type safety for collections and algorithms. - In an interview: "How do you add a new payment type without changing existing code?" -- implement the interface, register it, done.
Related Articles
Inheritance models IS-A relationships and enables polymorphism, but it creates tight coupling. Use composition when in doubt.
Abstraction separates what an object can do from how it does it. Callers depend on contracts, not implementations, which makes systems extensible and independently testable.
Inheritance models IS-A but couples tightly. Composition models HAS-A and stays flexible. Learn when each is right and how to migrate from one to the other.