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.
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.
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.
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 payoff examiners test for.
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.