Junior 12 min · March 05, 2026

Java Polymorphism Pitfall: Constructor Override Yields NPE

Production NPE in getAccountSummary() after adding CryptoWallet: constructor called overridden method.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Polymorphism lets one method name behave differently depending on the object's actual runtime type.
  • Compile-time (overloading) is resolved by argument types at compile time — API convenience.
  • Runtime (overriding) uses dynamic dispatch — the JVM picks the method at runtime.
  • Overloading is for ergonomics; overriding is for extensible design.
  • Missing @Override creates a silent new method — hours of debugging.
  • Static methods are hidden, not overridden — they don't participate in dynamic dispatch.
Plain-English First

Think about a TV remote. One 'volume up' button works whether you're watching Netflix, live TV, or a Blu-ray — you don't press a different button for each. The button looks the same; what happens underneath changes depending on what's playing. That's polymorphism: one interface, many behaviours. In Java, it means one method name can do different things depending on which object is actually being used at that moment.

Compile-Time Polymorphism — Method Overloading and Why It Exists

Compile-time polymorphism (also called static polymorphism) is resolved by the compiler before your program even runs. The compiler looks at the number and types of arguments you pass to a method and decides which version to call. This is method overloading.

Why does it exist? Because you often want the same logical operation — say, formatting a price — to accept different input types without forcing the caller to do awkward type conversions first. Overloading makes your API feel natural. Instead of formatPriceFromInt and formatPriceFromDouble, you just write formatPrice and the compiler routes the call correctly.

The key thing to internalise is that overloading is resolved at compile time based on the declared (reference) type, not the actual runtime type. That distinction becomes critical when you move to runtime polymorphism. Here the compiler picks the method — it's essentially a convenience feature for API ergonomics, not a design tool for extensibility. Use it when the same operation genuinely makes sense across multiple input types, not just to save yourself from writing slightly longer method names.

io/thecodeforge/InvoiceFormatter.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package io.thecodeforge;

public class InvoiceFormatter {

    // Overload 1: caller has a whole-number amount (e.g. loyalty points)
    public String formatPrice(int amountInCents) {
        double dollars = amountInCents / 100.0;
        // Format the integer input as a dollar string
        return String.format("$%.2f", dollars);
    }

    // Overload 2: caller already has a decimal amount
    public String formatPrice(double amount) {
        // Directly format the double — no conversion needed
        return String.format("$%.2f", amount);
    }

    // Overload 3: caller also wants a currency code (e.g. for international invoices)
    public String formatPrice(double amount, String currencyCode) {
        // The compiler picks THIS version when two arguments are passed
        return String.format("%s %.2f", currencyCode, amount);
    }

    public static void main(String[] args) {
        InvoiceFormatter formatter = new InvoiceFormatter();

        // Compiler resolves each call at compile time based on argument types
        System.out.println(formatter.formatPrice(1999));           // int version
        System.out.println(formatter.formatPrice(19.99));          // double version
        System.out.println(formatter.formatPrice(19.99, "EUR"));   // double + String version
    }
}
Watch Out: Autoboxing Ambiguity
If you overload a method with both an int and a long parameter, passing an int literal is fine — but passing a value that can be widened or autoboxed can cause 'ambiguous method call' compile errors. Always check your overloads don't create a situation where the compiler can't decide. When in doubt, fewer overloads with better-named methods win.
Production Insight
Overloading is resolved at compile time — no runtime cost.
But overloading with autoboxing can cause ambiguous calls that break compilation.
Rule: Keep overloads minimal; use distinct method names when parameter types overlap semantically.
Key Takeaway
Compile-time polymorphism is about API convenience.
It's not a tool for runtime extensibility.
Overloading is resolved on the reference type — never the object type.

Coercion Polymorphism — Implicit Type Conversion in Java

Coercion polymorphism, also known as implicit type conversion, is a form of compile-time polymorphism where Java automatically converts one type to another when needed. This happens primarily in two contexts: numeric widening (e.g., int to long to float to double) and string concatenation using +.

Java’s type system permits implicit widening conversions without any explicit cast. When you call a method that expects a double but pass an int, the compiler automatically widens the parameter. This is coercion — the method hasn't changed its signature; the argument is implicitly converted. This is not overloading; it's a separate mechanism that enables polymorphism by letting different argument types satisfy the same method signature.

The most common and powerful coercion in Java is string concatenation. When you write "The value is " + 42, Java automatically converts the integer 42 into a string. This is not operator overloading in the C++ sense; it's a built-in language feature where the + operator is overloaded by the compiler specifically for string concatenation. If at least one operand is a String, the other is coerced to a String via String.valueOf().

Coercion is a silent convenience, but it can backfire. Precision loss can occur when widening from int to float (e.g., large int values lose precision) and when mixing numeric types in expressions. Always be explicit in critical calculations rather than relying on coercion.

io/thecodeforge/CoercionExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package io.thecodeforge;

public class CoercionExample {

    // Demonstrates coercion numeric widening
    public static double computeTotal(double price, int quantity, double taxRate) {
        // The 'int' quantity is coerced to double
        return price * quantity * (1 + taxRate);
    }

    // Overloaded methods that rely on coercion
    public static String describe(Object obj) {
        return "Object: " + obj;
    }

    public static String describe(String str) {
        return "String: " + str;
    }

    public static void main(String[] args) {
        // Coercion: int literals are widened to double
        double total = computeTotal(19.99, 2, 0.08);
        System.out.println(total); // outputs 43.1784

        // Coercion: integer coerced to string via concatenation
        String message = "Order #" + 1001;
        System.out.println(message);

        // Coercion can cause ambiguity with overloading, but here it's fine
        System.out.println(describe(42));        // calls describe(Object) because int is boxed to Integer
        System.out.println(describe("hello"));   // calls describe(String)
    }
}
Coercion vs Overloading — Know the Difference
Coercion is automatic type conversion; overloading is multiple methods with the same name. They often interact: if a method is overloaded with int and long parameters, passing a short will prefer the int version (widening beats boxing). Understanding the Java Language Specification §5.3 method invocation conversion helps avoid surprises.
Production Insight
Coercion makes code terser but can hide precision loss. For example, int to float may lose least significant bits when the int is very large. Always use explicit casts or BigDecimal for monetary arithmetic. String concatenation coercion triggers StringBuilder allocation — in hot loops, use explicit StringBuilder to avoid unnecessary object creation.
Key Takeaway
Coercion polymorphism is compile-time type conversion that enables flexible API usage without overloading. It's convenient but can cause silent precision loss and performance overhead in loops.

Operator Overloading in Java — The Language's Choice and Workarounds

Java does not support user-defined operator overloading like C++ or Python. The only operator that is overloaded by the language itself is +: it performs numeric addition when both operands are numeric types and string concatenation when one operand is a String. All other operators (-, *, /, ==, etc.) have a fixed meaning for primitive types and cannot be redefined for reference types.

This design choice was intentional. The Java language designers felt that operator overloading reduces readability and makes code harder to maintain. Instead, Java encourages using methods with descriptive names. For example, BigDecimal.add() instead of BigDecimal + BigDecimal. This avoids surprises when the + operator is used on objects.

However, the lack of operator overloading can lead to verbose code when dealing with mathematical objects like matrices or complex numbers. Libraries like Apache Commons Math or JScience provide classes with add() and multiply() methods, but you cannot write matrixA + matrixB. The JVM also uses the + operator for the invokedynamic instruction behind the scenes for string concatenation (since Java 9 uses StringConcatFactory), but that's an implementation detail.

For domain-specific types, you can implement fluent APIs or use method chaining to mimic the expressiveness of operator overloading. The key takeaway: Java's intentional omission of operator overloading pushes you toward clearer, more explicit code. If you find yourself desperately wanting + for your custom class, consider whether a well-named method like combine() or addTo() would be even clearer.

Pro Tip: Avoid Overloading `+` by Combining Methods
Instead of wishing for operator overloading, design your APIs with method chaining: number.add(10).multiply(2). This is clear, testable, and doesn't surprise readers. If you must work with mathematical objects, use well-established libraries that already provide these methods.
Production Insight
The lack of operator overloading means Java code is more verbose but more predictable. For performance-critical numeric code, consider using primitive arrays and explicit loops rather than boxing-heavy object models. The var keyword (Java 10+) can reduce verbosity when chaining calls.
Key Takeaway
Java only overloads + for string concatenation. All other operators are fixed. This promotes clarity over brevity. Use named methods for custom types.

Polymorphism Types Comparison — Compile-Time vs Runtime at a Glance

Here is a quick visual comparison of the two main types of polymorphism in Java: compile-time (static) and runtime (dynamic). Understanding their differences is essential for writing flexible, correct Java code.

AspectCompile-Time (Overloading)Runtime (Overriding)
MechanismMultiple methods in the same class with the same name but different parametersSubclass provides its own implementation of a method declared in parent/interface
Resolved byCompiler based on argument types (declared types)JVM based on actual object type (dynamic dispatch)
When resolvedDuring compilationAt runtime, just before invocation
Inheritance required?NoYes (class inheritance or interface implementation)
Return type ruleCan be different (it's a different method)Must be covariant (same or subtype)
Exampleprint(int) and print(String)Animal.sound()Dog.sound() -> "bark", Cat.sound() -> "meow"
Common pitfallsAutoboxing ambiguity, widening confusionMissing @Override, constructor calls, static method hiding

This table should help you quickly decide which type of polymorphism to use and what to watch out for. Remember, overloading is a convenience for callers; overriding is a design tool for extensibility.

Memory Aid
"Overloading is for humans; overriding is for machines." The compiler helps with overloading; the JVM handles overriding.
Production Insight
Use compile-time polymorphism (overloading) for API convenience and runtime polymorphism for substitutability. Mixing them incorrectly is a common source of bugs — for example, expecting runtime dispatch on an overloaded method that isn't inherited.
Key Takeaway
Compile-time = overloading, resolved by compiler, no inheritance needed. Runtime = overriding, resolved by JVM, requires inheritance. Choose based on whether you need extensibility or just convenience.

Runtime Polymorphism — Method Overriding, the JVM, and the Magic of Dynamic Dispatch

Runtime polymorphism is where Java's real power lives. The JVM — not the compiler — decides which method to call based on the actual type of the object at runtime. This mechanism is called dynamic dispatch, and it's the engine behind almost every extensible framework ever written in Java.

You set it up with inheritance (or interface implementation) and method overriding: a subclass provides its own version of a method declared in a parent class or interface. The critical rule is that the reference type can be the parent, but the object itself is the child. When you call the overridden method, Java always runs the child's version.

This is the feature that makes it possible to write a method like processPayment(PaymentMethod method) once and have it correctly handle a CreditCard, a PayPal account, or a CryptoPay instance without any changes. You're programming to the PaymentMethod abstraction. Adding a new payment type tomorrow means writing a new class — you never touch the method that processes payments. That's the Open/Closed Principle in action, made possible entirely by runtime polymorphism.

io/thecodeforge/PaymentProcessor.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
package io.thecodeforge;

// Abstract base class — defines the contract every payment method must fulfil
abstract class PaymentMethod {
    protected String accountId;

    public PaymentMethod(String accountId) {
        this.accountId = accountId;
    }

    // Every subclass MUST provide its own version of this method
    public abstract String processPayment(double amount);

    // This method is shared — subclasses inherit it unchanged
    public String getAccountSummary() {
        return "Account: " + accountId;
    }
}

// Concrete subclass 1
class CreditCard extends PaymentMethod {
    private String lastFourDigits;

    public CreditCard(String accountId, String lastFourDigits) {
        super(accountId);
        this.lastFourDigits = lastFourDigits;
    }

    @Override
    public String processPayment(double amount) {
        // This version charges a card and adds a processing fee
        double fee = amount * 0.015;
        return String.format("Credit card ****%s charged $%.2f (fee: $%.2f)",
                lastFourDigits, amount, fee);
    }
}

// Concrete subclass 2
class PayPalAccount extends PaymentMethod {

    public PayPalAccount(String email) {
        super(email);
    }

    @Override
    public String processPayment(double amount) {
        // PayPal has a flat fee model — completely different logic, same method name
        return String.format("PayPal account %s debited $%.2f (flat fee: $0.30)",
                accountId, amount);
    }
}

// Concrete subclass 3 — added later, zero changes to PaymentProcessor needed
class CryptoPay extends PaymentMethod {

    public CryptoPay(String walletAddress) {
        super(walletAddress);
    }

    @Override
    public String processPayment(double amount) {
        // Crypto converts to BTC at a fake rate for illustration
        double btcAmount = amount / 45000.0;
        return String.format("Wallet %s sent %.6f BTC ($%.2f)",
                accountId, btcAmount, amount);
    }
}

public class PaymentProcessor {

    // This method was written ONCE. It works for every PaymentMethod — past and future.
    // The JVM uses dynamic dispatch to call the right processPayment() at runtime.
    public static void checkout(PaymentMethod method, double orderTotal) {
        System.out.println(method.processPayment(orderTotal)); // runtime decision
        System.out.println(method.getAccountSummary());        // shared inherited method
        System.out.println("---");
    }

    public static void main(String[] args) {
        // Reference type is PaymentMethod; actual object type varies — that's the point
        PaymentMethod card   = new CreditCard("ACC-001", "4242");
        PaymentMethod paypal = new PayPalAccount("user@example.com");
        PaymentMethod crypto = new CryptoPay("0xABCD1234");

        // Same method call, three completely different behaviours — runtime polymorphism
        checkout(card,   99.99);
        checkout(paypal, 99.99);
        checkout(crypto, 99.99);
    }
}
Interview Gold: Reference Type vs Object Type
When interviewers say 'what does dynamic dispatch actually mean?', the answer they want is: Java always looks at the object's actual runtime type to resolve overridden methods, not the declared type of the reference variable. The reference type controls what fields and methods are accessible; the object type controls which override runs. Draw this out on a whiteboard — it impresses every time.
Production Insight
Dynamic dispatch has a tiny vtable lookup cost — negligible in most apps.
But excessive deep inheritance hierarchies can impact JIT inlining and method call overhead.
Rule: Prefer shallow hierarchies with interfaces; deep trees defeat JIT's inline cache.
Key Takeaway
Runtime polymorphism is the foundation of the Open/Closed Principle.
The JVM dispatches to the actual object's method, not the reference type.
Design for polymorphism: code to interfaces, not implementations.

Interfaces vs Abstract Classes for Polymorphism — Choosing the Right Tool

Both interfaces and abstract classes let you write polymorphic code, but they serve different purposes and picking the wrong one creates awkward designs that are painful to refactor later.

Use an abstract class when your subclasses genuinely share implementation — common fields, shared helper methods, a partial template. The PaymentMethod class above is a reasonable abstract class because every payment method has an accountId and a shared getAccountSummary() method. The 'is-a' relationship is tight: a CreditCard really is a PaymentMethod.

Use an interface when you're defining a capability or role that unrelated classes might play. A Printable interface makes sense on a Document, an Invoice, and an Image even though those three share no common ancestor. Interfaces also let a class participate in multiple polymorphic hierarchies simultaneously (a class can implement many interfaces but only extend one class).

The modern Java best practice, since Java 8, is to favour interfaces with default methods for most polymorphic contracts. Reserve abstract classes for situations where shared mutable state or a constructor template is genuinely needed. When in doubt, start with an interface — it's far easier to widen an interface into an abstract class later than to break apart an inheritance hierarchy.

io/thecodeforge/ReportExporter.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
package io.thecodeforge;

// Interface defines a CAPABILITY — any class can implement this regardless of its lineage
interface Exportable {
    // Every implementor must know how to export itself
    byte[] exportData();

    // Default method: shared behaviour that implementors can optionally override
    default String getExportStatus() {
        return "Export ready: " + this.getClass().getSimpleName();
    }
}

// A completely unrelated second interface — Java allows implementing both
interface Auditable {
    String getAuditLog();
}

// SalesReport implements BOTH interfaces — impossible with single-inheritance abstract classes
class SalesReport implements Exportable, Auditable {
    private String reportName;
    private double totalRevenue;

    public SalesReport(String reportName, double totalRevenue) {
        this.reportName = reportName;
        this.totalRevenue = totalRevenue;
    }

    @Override
    public byte[] exportData() {
        // Simulate generating CSV bytes from the report data
        String csv = "Report,Revenue\n" + reportName + "," + totalRevenue;
        return csv.getBytes();
    }

    @Override
    public String getAuditLog() {
        return "SalesReport '" + reportName + "' exported at " + System.currentTimeMillis();
    }
}

class InventorySnapshot implements Exportable {
    private int itemCount;

    public InventorySnapshot(int itemCount) {
        this.itemCount = itemCount;
    }

    @Override
    public byte[] exportData() {
        // Simulate generating JSON bytes
        String json = "{\"itemCount\": " + itemCount + "}";
        return json.getBytes();
    }

    // Not overriding getExportStatus() — the default implementation is used instead
}

public class ReportExporter {

    // This method only cares that the object is Exportable — it doesn't know or care what type
    public static void runExport(Exportable exportable) {
        byte[] data = exportable.exportData();              // runtime polymorphism here
        System.out.println(exportable.getExportStatus());  // default or overridden version
        System.out.println("Bytes exported: " + data.length);
        System.out.println("---");
    }

    public static void main(String[] args) {
        SalesReport salesReport       = new SalesReport("Q4-2024", 128500.00);
        InventorySnapshot snapshot    = new InventorySnapshot(342);

        runExport(salesReport);   // Uses SalesReport's exportData()
        runExport(snapshot);      // Uses InventorySnapshot's exportData()

        // SalesReport also satisfies Auditable — dual polymorphic identity
        Auditable auditTarget = salesReport;
        System.out.println(auditTarget.getAuditLog());
    }
}
Pro Tip: Prefer Interfaces for Type Tokens
When you store objects in a List<Exportable> or pass them as method parameters, you get polymorphism without binding yourself to any class inheritance. This is why Java's own Collections API uses List, Map, and Set interfaces everywhere — the concrete type (ArrayList, HashMap) is an implementation detail that can swap out transparently.
Production Insight
Using abstract classes for cross-cutting capabilities creates tight coupling.
If you later need to add a common feature to two unrelated classes, an abstract class forces a restructure.
Rule: Start with interfaces; default methods can provide shared behaviour without inheritance.
Key Takeaway
Abstract classes for 'is-a' with shared state; interfaces for 'can-do' with flexible contracts.
Java 8+ default methods reduce the need for abstract classes.
When in doubt, pick an interface — you can always wrap it with an abstract class later.

Covariant Return Types and the @Override Annotation — The Details That Matter

Two practical details of method overriding trip up a lot of intermediate developers: covariant return types and the @Override annotation.

Covariant return types mean an overriding method can return a more specific (sub)type than the parent method declares. If the parent says PaymentMethod createPaymentMethod(), a subclass can override it to return CreditCard createPaymentMethod(). The caller holding a PaymentMethod reference still works fine; a caller who knows they're dealing with the subclass can use the result directly as a CreditCard without casting. This is clean, type-safe, and reduces ugly casts throughout your codebase.

@Override looks optional because Java won't error without it — but always use it. It tells the compiler 'I intend to override a parent method here'. If you spell the method name wrong, or the parent method's signature changes, the compiler catches it immediately with a clear error. Without @Override you silently create a brand-new method instead of overriding, and your polymorphic behaviour simply doesn't fire. That's one of the most maddeningly subtle bugs in Java development.

io/thecodeforge/VehicleFactory.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
package io.thecodeforge;

// Base factory class with a general return type
class VehicleFactory {

    // Returns the broad type Vehicle
    public Vehicle createVehicle(String model) {
        return new Vehicle(model, "generic");
    }
}

// Subclass uses a COVARIANT return type — returns ElectricCar instead of Vehicle
class ElectricCarFactory extends VehicleFactory {

    @Override // Compiler will error here if createVehicle doesn't exist in parent — safety net
    public ElectricCar createVehicle(String model) {
        // Covariant: ElectricCar IS-A Vehicle, so this is a valid override
        return new ElectricCar(model, "electric", 350);
    }
}

class Vehicle {
    protected String model;
    protected String fuelType;

    public Vehicle(String model, String fuelType) {
        this.model    = model;
        this.fuelType = fuelType;
    }

    public String describe() {
        return model + " (" + fuelType + ")";
    }
}

class ElectricCar extends Vehicle {
    private int rangeKm;

    public ElectricCar(String model, String fuelType, int rangeKm) {
        super(model, fuelType);
        this.rangeKm = rangeKm;
    }

    @Override
    public String describe() {
        // Overridden to include range — runtime polymorphism fires here
        return super.describe() + ", range: " + rangeKm + "km";
    }
}

public class VehicleFactoryDemo {
    public static void main(String[] args) {
        VehicleFactory genericFactory  = new VehicleFactory();
        ElectricCarFactory evFactory   = new ElectricCarFactory();

        // Covariant return: no cast needed when using the concrete factory type
        ElectricCar tesla = evFactory.createVehicle("Model S");
        System.out.println(tesla.describe());        // ElectricCar's describe() runs
        System.out.println("Range: " + tesla.rangeKm + "km");  // can access rangeKm directly

        // Polymorphism via parent reference: describe() still calls ElectricCar's version
        VehicleFactory upcastFactory = evFactory;    // reference is VehicleFactory type
        Vehicle vehicle = upcastFactory.createVehicle("Model 3"); // returns ElectricCar object
        System.out.println(vehicle.describe());      // runtime dispatch — ElectricCar.describe()
    }
}
Watch Out: Missing @Override Is a Silent Bug Factory
If you override toString() but accidentally write tostring() (lowercase s), Java creates a new method and your object will print its memory address instead of your custom output. The @Override annotation would have caught this at compile time with 'method does not override or implement a method from a supertype'. Always use it. No exceptions.
Production Insight
Missing @Override is one of the most common silent bugs in production code.
It only takes one character typo to lose polymorphic behaviour — no compile error, no warning without lint.
Rule: Configure your IDE to flag missing @Override as an error; enable -Xlint:all in your build.
Key Takeaway
Covariant return types make API consumption cleaner — no casting needed.
@Override is a compile-time safety net: without it, a typo creates a new method silently.
Always use @Override. Configure your IDE to enforce it.

The Liskov Substitution Principle — Why Polymorphism Requires Contracts

Polymorphism isn't free. The Liskov Substitution Principle (LSP) is the rule that makes it work safely: if you have a parent reference pointing to a child object, the child must not break the expectations that the parent's contract defines. That means: override methods must accept all arguments the parent accepts (and may accept even narrower ones), and must not throw new checked exceptions the parent didn't declare. In practice, violating LSP leads to code that randomly throws ClassCastException or unexpected UnsupportedOperationException.

A classic violation: a Square subclass of Rectangle where setting width also modifies height. Code that sets rectangle width and expects only width to change will break when passed a Square. The Square violates the parent's contract. To fix, don't model Square as a subclass of Rectangle — use composition or a separate hierarchy.

In your own code, always ask: 'Does this subclass honour the parent's contract? Would code written against the parent work with this subclass without knowing about it?' If the answer is no, you've broken LSP and your polymorphic dispatch will cause subtle bugs.

io/thecodeforge/LSPExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package io.thecodeforge;

// Violation: Square extends Rectangle, but setWidth also changes height
class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int w) { this.width = w; }
    public void setHeight(int h) { this.height = h; }
    public int getArea() { return width * height; }
}

class Square extends Rectangle {
    @Override
    public void setWidth(int w) {
        super.setWidth(w);
        super.setHeight(w);  // Violates LSP — caller expects only width to change
    }

    @Override
    public void setHeight(int h) {
        super.setHeight(h);
        super.setWidth(h);   // Same violation
    }
}

public class LSPExample {
    // This method works fine with Rectangle but breaks with Square
    public static void resizeAndPrint(Rectangle r) {
        int originalHeight = r.getHeight();  // Let's assume getter exists
        r.setWidth(10);
        // If r is a Square, setWidth also changes height — unexpected!
        System.out.println("Area: " + r.getArea() + " Expected: " + (10 * originalHeight));
    }

    public static void main(String[] args) {
        Rectangle rect = new Rectangle();
        rect.setWidth(5); rect.setHeight(8);
        resizeAndPrint(rect); // works: area = 10*8 = 80

        Rectangle sq = new Square();
        sq.setWidth(5); // height also becomes 5 (violation)
        resizeAndPrint(sq); // might produce wrong area
    }
}
Mental Model: Contracts, Not Implementation
  • The parent class defines a contract (preconditions, postconditions, invariants).
  • The subclass must satisfy all contract conditions — it can weaken preconditions but not strengthen them.
  • If a subclass throws new exceptions or changes return types beyond covariance, it violates LSP.
  • Example: Java's Collections.unmodifiableList violates LSP because it throws UnsupportedOperationException for mutating methods.
  • In production, LSP violations manifest as mysterious cast failures or behavior changes in polymorphic code.
Production Insight
LSP violations are a leading cause of 'works on my machine' bugs in polymorphic code.
They arise when teams add subclasses without rethinking the parent contract.
Rule: Write unit tests for polymorphic components using the parent type and verify invariants.
Key Takeaway
Polymorphism without LSP is fragile.
Ensure every subclass respects the parent's contract — or don't inherit.
Test with parent references, not subclass instances.

instanceof Pattern Matching (Java 16+) — Safer Polymorphism with Type Checks

Introduced as a preview in Java 14 and standardised in Java 16, pattern matching for instanceof eliminates the tedious cast-and-check pattern that used to plague polymorphic code. Instead of writing:

``java if (obj instanceof String) { String s = (String) obj; // use s } ``

``java if (obj instanceof String s) { // use s directly } ``

The variable s is declared and scoped only inside the if block, and it is automatically cast. This improves readability and eliminates the risk of forgetting to cast or casting to the wrong type.

Pattern matching is especially useful when you need polymorphic behaviour that cannot be expressed purely through overriding — for example, when handling objects from an external library whose classes you cannot modify. It also works with sealed classes and records, giving you exhaustive pattern matching capabilities resembling pattern matching in functional languages.

Best practice: Use pattern matching instanceof as a last resort when polymorphism via method overriding is not feasible. If you control the class hierarchy, prefer adding a method to the base type. If you don't, pattern matching is a clean, safe fallback.

io/thecodeforge/PatternMatchingExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package io.thecodeforge;

// Base class for geometric shapes — we cannot modify this (e.g., from a third-party library)
class Shape {
    double area() { return 0; }
}

class Circle extends Shape {
    double radius;
    Circle(double radius) { this.radius = radius; }
    @Override double area() { return Math.PI * radius * radius; }
}

class Rectangle extends Shape {
    double width, height;
    Rectangle(double width, double height) { this.width = width; this.height = height; }
    @Override double area() { return width * height; }
}

// A service that needs to handle shapes differently — simulating polymorphic dispatch via pattern matching
class ShapeRenderer {
    public void render(Shape shape) {
        // Java 16+ pattern matching eliminates the need for explicit cast
        if (shape instanceof Circle c) {
            System.out.println("Rendering circle with radius " + c.radius);
        } else if (shape instanceof Rectangle r) {
            System.out.println("Rendering rectangle " + r.width + "x" + r.height);
        } else {
            System.out.println("Unknown shape");
        }
    }
}

public class PatternMatchingExample {
    public static void main(String[] args) {
        ShapeRenderer renderer = new ShapeRenderer();
        renderer.render(new Circle(5.0));
        renderer.render(new Rectangle(3.0, 4.0));
    }
}
When to Use Pattern Matching vs Overriding
If you own the interface/abstract class and can add a method (e.g., render()), do that — it's more object-oriented. Use instanceof pattern matching only when you cannot modify the base type, or when the logic depends on multiple unrelated types (e.g., handling both String and Integer in a generic parser).
Production Insight
Pattern matching reduces boilerplate and eliminates ClassCastExceptions from manual casts. However, overusing instanceof cascades can signal a missed abstraction. In production code, prefer a polymorphic method call; reserve pattern matching for cases where you're integrating with legacy or third-party code.
Key Takeaway
Java 16 pattern matching instanceof makes type checks safer and more readable. Use it when overriding is not possible or practical, but prefer adding methods to the base type when you control the hierarchy.

Polymorphism in Practice: Frameworks, Collections, and Real-World Patterns

You've already seen polymorphism in action whether you realised it or not. The Java Collections Framework is built on it: List interface with ArrayList, LinkedList, Vector — all behaving differently behind the same interface. Spring's @Transactional interception uses dynamic proxies (a form of polymorphism via interface implementation). Even the simple act of calling toString() on any object is polymorphism — the JVM dispatches to the actual class's override.

In design patterns, polymorphism is central: Strategy, Observer, Factory, Template Method all rely on runtime dispatch. The Strategy pattern, for instance, lets you swap an algorithm at runtime by passing different implementations of a common interface — exactly the pattern used in java.util.Comparator.

Knowing these patterns helps you recognise when to use polymorphism over conditional logic. If you find yourself writing if (type instanceof CreditCard) ... else if (type instanceof PayPal) ... you've missed the point. Replace that with a polymorphic call to a method on the common interface. That's the transformation that reduces code duplication and keeps your system open for extension.

io/thecodeforge/StrategyPattern.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
package io.thecodeforge;

// Strategy interface — defines the polymorphic contract
interface ShippingCostStrategy {
    double calculateShipping(double weight, String destination);
}

// Concrete strategies
class StandardShipping implements ShippingCostStrategy {
    @Override
    public double calculateShipping(double weight, String destination) {
        return weight * 1.5 + 5.0;
    }
}

class ExpressShipping implements ShippingCostStrategy {
    @Override
    public double calculateShipping(double weight, String destination) {
        return weight * 3.0 + 10.0;
    }
}

class InternationalShipping implements ShippingCostStrategy {
    @Override
    public double calculateShipping(double weight, String destination) {
        return weight * 4.0 + 20.0;
    }
}

// Context class that uses polymorphism
class ShippingCalculator {
    private ShippingCostStrategy strategy;

    public ShippingCalculator(ShippingCostStrategy strategy) {
        this.strategy = strategy;
    }

    public double calculate(double weight, String destination) {
        return strategy.calculateShipping(weight, destination);
    }
}

public class StrategyPattern {
    public static void main(String[] args) {
        ShippingCalculator calc = new ShippingCalculator(new StandardShipping());
        System.out.println("Standard: $" + calc.calculate(10, "US"));

        calc = new ShippingCalculator(new ExpressShipping());
        System.out.println("Express: $" + calc.calculate(10, "US"));

        calc = new ShippingCalculator(new InternationalShipping());
        System.out.println("International: $" + calc.calculate(10, "UK"));
    }
}
Pro Tip: Replace Type Checks with Polymorphism
Every time you write if (obj instanceof SomeType) ask yourself: could I move this behaviour into a polymorphic method? If the logic depends on the type of object, pushing that logic into the class itself (via an overridden method) is cleaner. The instanceof operator should be used sparingly — usually only in equals() implementations and rarely elsewhere.
Production Insight
Using instanceof checks instead of polymorphism is a code smell that increases technical debt.
Every new type added requires an extra branch in every if-else chain.
Rule: If you have three or more instanceof checks for the same abstraction, refactor to polymorphism.
Key Takeaway
Polymorphism powers the Strategy pattern and the Collections Framework.
Replace type checks with polymorphic method calls.
Your code becomes open for extension, closed for modification.

Advantages and Disadvantages of Polymorphism in Java

Polymorphism is one of the cornerstones of object-oriented programming, but it comes with both strengths and trade-offs. Understanding these helps you decide when to use it and when to avoid over-engineering.

AdvantagesDisadvantages
Code reusability: One method works for any subclass; no need to duplicate logic.Performance overhead: Dynamic dispatch adds a small vtable lookup (~2-10 ns). May inhibit JIT inlining.
Extensibility (Open/Closed): Add new subclasses without modifying existing code.Debugging complexity: Stack traces can be harder to read when runtime type isn't obvious.
Maintainability: Centralised behaviour in superclass/interface reduces duplication.Design fragility: Violating Liskov Substitution Principle causes subtle bugs.
Flexibility: Strategy pattern, dependency injection, and frameworks rely on it.Learning curve: Beginners often confuse overloading, overriding, hiding, and coercion.
Testability: Substitute mock implementations via polymorphism.Too much abstraction: Overuse can lead to tiny classes that obscure the actual flow.

In practice, the performance overhead of dynamic dispatch is negligible compared to database calls or network I/O. The real cost is in readability when hierarchies become deep. Aim for shallow, well-documented hierarchies with clear contracts.

Don't Over-Abstract
If you only have one implementation of an interface and no plans for a second, the interface is unnecessary. YAGNI (You Aren't Gonna Need It). Add the abstraction when you actually have a second implementation.
Production Insight
The disadvantage most teams hit is not performance but maintainability: a deep hierarchy of abstract classes and interfaces can be hard to navigate. Use tools like IntelliJ's 'Hierarchy' view and keep diagrams in your documentation. Also, be aware that JIT can inline small polymorphic calls after profiling, reducing overhead to zero.
Key Takeaway
Polymorphism's main advantages are reusability and extensibility. Its main disadvantages are complexity and potential performance overhead. Use it when you need to support multiple behaviours under a common interface, but avoid creating excessive abstraction layers.

Practice Problems — Polymorphism in Action

The best way to internalise polymorphism is to design and implement a polymorphic system. Below are five practice problems ranging from classic to real-world inspired. Each tests your ability to choose between compile-time and runtime polymorphism, apply LSP, and refactor conditional logic into polymorphic dispatch.

### 1. Design a Shape Renderer

Create a class hierarchy for geometric shapes: Shape, Circle, Rectangle, Triangle. Each shape must implement methods area() and draw() (simulate drawing with a console string). Then write a ShapeRenderer class that takes an array of Shape objects and renders all of them without ever checking their concrete type. This tests runtime polymorphism via overriding.

Stretch goal: Add a new shape (e.g., Hexagon) later without modifying ShapeRenderer.

### 2. Build a Payment Gateway with Multiple Providers

Design a polymorphic payment system. Define an interface PaymentGateway with methods charge(double amount) and refund(String transactionId). Create implementations: StripeGateway, PayPalGateway, SquareGateway. Each must simulate processing with different fee structures. Write a PaymentService that accepts any PaymentGateway and executes payment flows. This tests interface-based polymorphism and the Strategy pattern.

Stretch goal: Add a TransactionLogger that wraps a PaymentGateway and logs every call — a decorator pattern using polymorphism.

### 3. Overloading Utility Methods for Different Data Sources

Implement a DataParser class with overloaded methods parse(String csvData), parse(byte[] jsonBytes), and parse(InputStream xmlStream). Each returns a List<Record> (define a simple Record class). The compiler should select the correct overload based on input type. This tests compile-time polymorphism and API ergonomics.

Stretch goal: Add parse(Path file) that auto-detects format from extension.

### 4. Refactor a Conditional Chain to Polymorphism

You are given legacy code that uses instanceof chains to handle different notification types (Email, SMS, Push). Refactor it into a polymorphic design with a Notification interface and an override send() method. Write unit tests to verify the refactored code behaves identically.

Stretch goal: Use the Java 16+ pattern matching instanceof as an intermediate step before full polymorphic refactoring.

### 5. Implement a Document Converter with Covariant Returns

Create a base class Document with fields title and content. Subclasses PDFDocument, WordDocument. Create a DocumentFactory with a method createDocument(String title, String content) that returns Document. Override it in a PDFDocumentFactory with covariant return type PDFDocument. The factories should also have a convert(Document doc) method that returns the converted document — use covariant returns for type safety.

Stretch goal: Add a DocumentConversionPipeline that chains multiple polymorphic converters.

How to Approach Each Problem
Start by sketching the class/interface diagram. Identify what varies (behaviour) and what stays the same (contract). Then implement the base abstraction first, then the concrete classes. Finally, write code that uses the abstraction polymorphically and verify you can add a new subtype without changing existing code.
Production Insight
These problems mirror real-world scenarios. For example, the payment gateway pattern is used in every e-commerce company that supports multiple providers. The document converter reflects how content management systems handle different file types. Practicing these will prepare you for system design interviews and production code.
Key Takeaway
Polymorphism is best learned by designing extensible systems. Practice with shape renderers and payment gateways to build muscle memory for when to use overriding, overloading, and interfaces.
● Production incidentPOST-MORTEMseverity: high

Constructor Call to Overridden Method Leads to NullPointerException in Production

Symptom
After a new payment method (CryptoWallet) was added, the application crashed on startup with NullPointerException in PaymentMethod.getAccountSummary(). The stack trace pointed to the abstract class constructor.
Assumption
The team assumed that because the overridden getAccountSummary() in CryptoWallet used a field (walletAddress), it would be initialized by the time the parent constructor called it.
Root cause
The parent class constructor invoked an instance method (getAccountSummary()) that was overridden in the subclass. At that point in the construction chain, the subclass's fields had not yet been assigned — they were still null. The JVM correctly dispatched to the subclass's override, but the subclass's constructor body hadn't run yet.
Fix
Move the call to getAccountSummary() out of the parent constructor. Instead, call it explicitly after the object is fully constructed, or make the method final in the parent so it cannot be overridden. Alternatively, use a factory method that separates construction from initialization.
Key lesson
  • Never call overridable methods from a constructor. The subclass fields are not yet initialized.
  • If you must call a method in a constructor, make it private or final.
  • Use a template method pattern with a separate init() hook that subclasses override, but ensure the parent constructor doesn't call it — call it from the factory or after construction.
Production debug guideSymptom → Action guide for when polymorphic dispatch doesn't behave as expected4 entries
Symptom · 01
Overridden method is not called — parent's version runs instead
Fix
Check @Override annotation on the subclass method. If missing, verify method signature matches exactly (including parameter types and return type). Add @Override and recompile — the compiler will flag any mismatches.
Symptom · 02
NullPointerException inside an overridden method during object construction
Fix
Look for calls to overridable methods in parent class constructors. Move those calls out of the constructor, or make the method final. Use logging to confirm the call stack order.
Symptom · 03
Static method behaves differently than expected when called on subclass reference
Fix
Remember: static methods are hidden, not overridden. If you need polymorphic behavior, the method must be an instance method. If you must use static, ensure you call it on the class it belongs to, or use a pattern like a static factory that returns an instance.
Symptom · 04
Compile-time error: ambiguous method call when overloading
Fix
Check for autoboxing or widening ambiguity. Use explicit casts or rename overloaded methods to disambiguate. Prefer method names that describe the parameter type (e.g., formatPriceAsInt vs formatPriceAsDouble) over overloading.
★ Quick Debug: Polymorphism IssuesTwo-command debugging for the most common polymorphism failures.
Override not executing
Immediate action
Check for @Override and verify method signature matches exactly.
Commands
javac -Xlint:all YourClass.java (enables lint warnings for missing @Override)
java -verbose:class YourApp (see which method is actually loaded)
Fix now
Add @Override on the suspected override method. If the code doesn't compile, fix the signature.
Null from constructor-called override+
Immediate action
Trace the construction stack: look for any instance method call in the parent class constructor.
Commands
jstack <pid> | grep -A 10 'constructor call' (if you can reproduce, capture stack)
Add a breakpoint in the parent constructor and step through with JDB or IDE.
Fix now
Remove the call from constructor, or make the method final. If you must keep the call, initialize subclass fields in a separate init() method called after construction.
Static method not polymorphic+
Immediate action
Identify if the method is static. Confirm the reference type used to call it.
Commands
javap -c -p YourClass.class (check method signatures for static flag)
Add a non-static version of the same method and use that.
Fix now
Change the static method to an instance method. If that's not possible, ensure the call is made on the correct class (not a variable).
Compile-Time vs Runtime Polymorphism
AspectCompile-Time (Overloading)Runtime (Overriding)
Resolution timeCompile time — compiler decidesRuntime — JVM decides via dynamic dispatch
MechanismMethod overloading (same name, different params)Method overriding (@Override in subclass)
Inheritance required?No — works within a single classYes — requires parent/child or interface relationship
Which type matters?Declared (reference) type of argumentsActual (runtime) type of the object
Primary purposeAPI ergonomics and convenienceExtensibility and the Open/Closed Principle
Binding typeStatic bindingDynamic binding
Can change return type?Yes (it's a different method)Yes, but only covariantly (subtype of parent return)
Risk of silent bugs?Ambiguous overload (compile error)Missing @Override silently creates new method

Key takeaways

1
Compile-time polymorphism (overloading) is resolved by the compiler based on argument types
it's an ergonomics tool, not a design tool for extensibility.
2
Runtime polymorphism (overriding) is resolved by the JVM based on the object's actual type at runtime
this is the mechanism that makes the Open/Closed Principle achievable.
3
Always use @Override on intended overrides
without it, a typo silently creates a new method and your polymorphic dispatch simply doesn't happen, with no compile error to warn you.
4
Static methods are hidden, not overridden
they're resolved at compile time on the reference type, so they never participate in dynamic dispatch. Polymorphism only applies to instance methods.
5
The Liskov Substitution Principle ensures polymorphic code works safely
a subclass must honour the parent's contract to avoid subtle bugs.
6
Replace instanceof checks with polymorphic method calls
it keeps your code open for extension and closed for modification.

Common mistakes to avoid

5 patterns
×

Overloading when you mean overriding

Symptom
A developer adds the same method name in a subclass but with a slightly different parameter type by accident. No @Override is used, so Java creates a new overloaded method instead of overriding. The polymorphic dispatch never fires — the parent's version always runs, and hours are lost debugging.
Fix
Always annotate intended overrides with @Override. The compiler will immediately flag anything that isn't a genuine override.
×

Calling overridden methods from a constructor

Symptom
If a parent class constructor calls a method that a subclass overrides, the overridden version runs before the subclass constructor has finished initialising its fields, leaving them as null or 0. This causes NullPointerExceptions or wrong values that are extremely hard to trace.
Fix
Never call overridable methods from constructors. Make the method final in the parent, or use a factory method pattern to separate construction from initialisation.
×

Confusing method hiding with method overriding for static methods

Symptom
Developers assume that a static method in a subclass with the same signature overrides the parent's static method polymorphically. It doesn't. Static methods are hidden, not overridden — the call is resolved at compile time based on the reference type, not the object type. You'll get the parent's static method even when holding an object of the child type.
Fix
Never rely on polymorphic dispatch for static methods. If you need polymorphic behaviour, the method must be an instance method.
×

Using instanceof instead of polymorphism

Symptom
Code riddled with if (obj instanceof CreditCard) ... else if (obj instanceof PayPal) .... Adding a new payment type requires modifying every such chain — violates Open/Closed Principle and leads to fragile, hard-to-maintain code.
Fix
Refactor to a polymorphic approach: define the behaviour in a common interface or abstract class and let each subclass implement it. Remove the type checks.
×

Violating Liskov Substitution Principle

Symptom
A subclass overrides methods in a way that breaks the parent's contract (e.g., Square extends Rectangle, setting width also changes height). Code that works with Rectangle silently produces wrong results when passed a Square.
Fix
Ensure every subclass honours the parent's contract. Use composition over inheritance if the relationship doesn't truly satisfy an 'is-a' relationship with substitutability.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Can you explain the difference between method overloading and method ove...
Q02SENIOR
What happens when you call an overridden method from a superclass constr...
Q03JUNIOR
If you have `Animal a = new Dog();` and both `Animal` and `Dog` have a m...
Q01 of 03SENIOR

Can you explain the difference between method overloading and method overriding, and describe a scenario where you'd use each one?

ANSWER
Method overloading (compile-time polymorphism) occurs when multiple methods in the same class share the same name but differ in number or type of parameters. The compiler resolves which to call. Method overriding (runtime polymorphism) occurs when a subclass provides its own implementation of a method declared in a parent class or interface. The JVM resolves which to call based on the actual object type at runtime. Use overloading for API convenience (e.g., formatPrice(int) and formatPrice(double)). Use overriding when you need different behaviours for different concrete types under a common abstraction (e.g., PaymentMethod.processPayment() with CreditCard, PayPal, Crypto subclasses).
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
What is the difference between polymorphism and inheritance in Java?
02
Can polymorphism work without inheritance in Java?
03
Why can't we achieve runtime polymorphism with private or static methods?
04
What is the Liskov Substitution Principle and how does it relate to polymorphism?
🔥

That's OOP Concepts. Mark it forged?

12 min read · try the examples if you haven't

Previous
Inheritance in Java
4 / 16 · OOP Concepts
Next
Encapsulation in Java