Java OOP Interview Questions — The $23K @Override Bug
A missing @Override caused a $23K refund bug in Java: non-refundable payments silently did nothing.
20+ years shipping production code across the stack, with years spent interviewing engineers. Drawn from code that ran under real load.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- Encapsulation protects invariants, not just data hiding
- Abstraction defines contracts (what), polymorphism executes them (how)
- Abstract classes for shared state; interfaces for cross-hierarchy capabilities
- LSP is your sanity check for inheritance — if you can't substitute, use composition
- Overloading = compile-time, overriding = runtime — interviewers love this distinction
This article dissects Java OOP interview questions through the lens of a real production bug that cost $23,000 — a missed @Override annotation that silently broke polymorphic behavior. It’s not a list of trivia; it’s a deep dive into what interviewers actually probe: whether you understand OOP as a tool for managing complexity, not just a set of textbook definitions.
The four pillars (encapsulation, inheritance, polymorphism, abstraction) are explained with concrete Java examples, showing why each exists — for example, why encapsulation isn’t just private fields but a contract for state management, and why polymorphism without Liskov substitution leads to runtime surprises. The article targets mid-to-senior developers who’ve used OOP for years but may still confuse abstraction with polymorphism or choose inheritance over composition out of habit.
It contrasts abstract classes (shared state, partial implementation) with interfaces (behavioral contracts, multiple inheritance) using real-world scenarios like payment processors and logging frameworks. The refactoring section walks through replacing a brittle class hierarchy with composition, using a notification system as the case study — showing how to avoid the fragile base class problem that plagues many enterprise Java codebases.
If you’re preparing for a senior Java role or debugging a system where OOP principles have gone sideways, this article gives you the why behind the what.
Think of a TV remote. You press 'Volume Up' and the TV gets louder — you don't care how the TV processes that signal internally. OOP works the same way: you interact with objects through simple buttons (methods), while the complex wiring stays hidden inside. Polymorphism means that same 'Volume Up' button works on a Samsung AND a Sony. Abstraction is why you never need to open the TV to change the channel. That's Java OOP in one analogy.
If you're interviewing for a Java role — junior, mid, or senior — OOP questions are guaranteed to show up. Not because interviewers love theory, but because the way you answer reveals how you actually design software. A candidate who can recite four pillars from memory is forgettable. A candidate who explains WHY encapsulation prevents bugs in a multi-team codebase gets the offer.
The problem is that most resources teach OOP as a list of definitions. That leaves you able to parrot answers but unable to handle the natural follow-up: 'Can you give me a real-world example?' or 'How does that differ from an abstract class?' Those follow-ups are where interviews are actually won or lost.
After working through this article, you'll be able to explain every major OOP concept with a concrete analogy, write runnable code that demonstrates each idea, spot the three classic mistakes candidates make, and handle the tricky follow-up questions interviewers use to separate the memorisers from the thinkers.
What Java OOP Interview Questions Actually Test
Java OOP interview questions assess whether you understand the four pillars—encapsulation, inheritance, polymorphism, and abstraction—not as textbook definitions, but as design tools that prevent production bugs. The core mechanic is that each pillar enforces a contract between components: encapsulation hides state behind methods, inheritance shares behavior across a hierarchy, polymorphism lets one interface serve multiple implementations, and abstraction separates what a system does from how it does it.
In practice, these principles interact in ways that matter at scale. For example, polymorphism via method overriding is O(1) at dispatch but can introduce subtle failures when a subclass changes behavior that a parent class's internal methods rely on. The infamous $23K bug occurred when a developer overrode a method without understanding that the parent class called it internally during initialization, causing a NullPointerException in production. This is why interviewers probe beyond syntax—they want to see if you grasp the runtime implications of each OOP choice.
Use OOP principles when you need to manage complexity across a team or over time. Encapsulation reduces ripple effects from refactoring; inheritance should be used only for true "is-a" relationships, not code reuse (prefer composition). Polymorphism enables pluggable architectures like strategy or observer patterns. In real systems, failing to apply these correctly leads to fragile code that breaks when extended, costing hours of debugging and, in extreme cases, revenue loss.
The Four Pillars — What They Are and Why Each One Exists
Every Java OOP interview starts here. The four pillars are Encapsulation, Abstraction, Inheritance, and Polymorphism. But interviewers don't want a dictionary. They want to know you understand the problem each pillar solves.
Encapsulation solves the 'who changed my data?' problem. By bundling data with the methods that operate on it and hiding the internals, you prevent other parts of the system from putting an object into an invalid state. Think of a bank account — you never want external code to set the balance directly to a negative number.
Abstraction solves the 'I don't need to know how' problem. You expose only what's necessary and hide everything else. This is why you can call without understanding TimSort.list.sort()
Inheritance solves the 'don't repeat yourself' problem. Common behaviour lives in a parent class; child classes inherit it and specialise where needed.
Polymorphism solves the 'treat different things uniformly' problem. One interface, many implementations. This is what makes your code extensible without modification — the Open/Closed Principle in action.
package io.thecodeforge.oop; /** * Production-grade Encapsulation Example. * We protect the 'balance' invariant from external corruption. */ public class BankAccount { private double balance; private final String accountId; public BankAccount(String accountId, double initialDeposit) { this.accountId = accountId; validateAndSetBalance(initialDeposit); } public void deposit(double amount) { if (amount <= 0) { throw new IllegalArgumentException("Deposit must be positive"); } this.balance += amount; } public void withdraw(double amount) { if (amount > balance) { throw new IllegalStateException("Insufficient funds for account: " + accountId); } this.balance -= amount; } public double getBalance() { return balance; } private void validateAndSetBalance(double amount) { if (amount < 0) throw new IllegalArgumentException("Initial balance cannot be negative"); this.balance = amount; } }
Polymorphism vs Abstraction — The Question That Trips Everyone Up
These two are the most commonly confused pillars, and interviewers exploit that confusion heavily. Here's the clean separation: Abstraction is about DESIGN — hiding complexity behind a simple interface. Polymorphism is about BEHAVIOUR — the same call producing different results depending on the actual object at runtime.
Abstraction is implemented in Java via abstract classes and interfaces. You define WHAT something must do without specifying HOW. Polymorphism is what happens at runtime when Java resolves which overridden method to actually call.
A classic follow-up: 'What's the difference between method overloading and method overriding?' Overloading is compile-time polymorphism — same method name, different parameters, resolved by the compiler. Overriding is runtime polymorphism — same signature in parent and child, resolved by the JVM based on the actual object type.
package io.thecodeforge.oop; import java.util.List; // ABSTRACTION: The 'What' interface Notifier { void send(String message); } // POLYMORPHISM: The 'How' class EmailNotifier implements Notifier { @Override public void send(String msg) { System.out.println("Emailing: " + msg); } } class SlackNotifier implements Notifier { @Override public void send(String msg) { System.out.println("Slacking: " + msg); } } public class NotificationService { public void broadcast(List<Notifier> recipients, String message) { // Polymorphic call: The service doesn't care about concrete types recipients.forEach(n -> n.send(message)); } }
Abstract Classes vs Interfaces — When to Use Which
This is arguably the most asked Java OOP question at mid-level interviews. Both enforce a contract. Both support polymorphism. But they're not interchangeable, and using the wrong one reveals a gap in design thinking.
Use an abstract class when you have a true 'is-a' relationship AND shared state or behaviour to inherit. Example: a Vehicle abstract class that stores fuelLevel and has a concrete method that all vehicles share. Child classes extend this and implement their own refuel() method.accelerate()
Use an interface when you're defining a capability that could apply to completely unrelated classes. Serializable, Comparable, and Runnable are capabilities, not identities. A Dog and a BankTransaction can both be Serializable — that doesn't mean they share a parent.
Since Java 8, interfaces can have default and static methods, which blurs the line slightly. The practical rule: if you need instance state (fields) in the shared contract, you need an abstract class. Interfaces can't hold instance state.
package io.thecodeforge.oop; // Abstract Class: Shared state (identity) abstract class BaseVehicle { protected int fuelLevel; public void refuel(int amount) { this.fuelLevel += amount; } public abstract void drive(); } // Interface: Shared capability interface GPS { String getCoordinates(); } class SmartCar extends BaseVehicle implements GPS { @Override public void drive() { fuelLevel -= 5; } @Override public String getCoordinates() { return "51.5074 N, 0.1278 W"; } }
Inheritance Pitfalls and the Liskov Substitution Principle
Inheritance looks clean on paper but is the most misused OOP feature in real codebases. The classic mistake: using inheritance for code reuse when there's no genuine 'is-a' relationship. Stack extending Vector in Java's own standard library is the canonical example of this done badly — a Stack is NOT a Vector, but Java's designers used inheritance for convenience, which meant Stack accidentally exposed methods like add(int index, Object element) that make no logical sense for a stack.
The Liskov Substitution Principle (LSP) is the interview gold standard here. It says: if you replace a parent with any of its subtypes, the program should still behave correctly. A Square extending Rectangle violates LSP — if you set width on a Square it must also change the height, which breaks any code that expects to set width and height independently.
When LSP is in danger, favour composition over inheritance. Instead of Square extending Rectangle, give Square a Dimensions object internally.
package io.thecodeforge.oop; /** * Demonstrating LSP: Any subtype of Payment must be substitutable * without breaking the 'process' logic. */ public abstract class Payment { public abstract void process(double amount); } class CreditCardPayment extends Payment { @Override public void process(double amount) { System.out.println("Charging credit card: $" + amount); } } class RefundablePayment extends Payment { @Override public void process(double amount) { System.out.println("Processing refundable payment: $" + amount); } public void refund(double amount) { System.out.println("Refunding: $" + amount); } }
Composition Over Inheritance – A Real-World Refactoring
Many developers default to inheritance when they need to share code. But composition — assembling behaviour from smaller, focused classes — is often a better choice. The rule of thumb: 'Favor composition over inheritance.'
Consider a Bird class that needs to fly. If you create a FlyingBird subclass, you'll soon have NonFlyingBird, SwimmingBird, etc. Adding a new capability (like Sing) explodes the class hierarchy. Instead, compose the bird with a FlyBehavior interface and delegate.
Interviewers love this topic because it tests your ability to design flexible systems. When they ask 'How would you model a bird?' they're not looking for inheritance tree depth; they want to see if you reach for interfaces and delegation.
Here's a clean composition example: an OrderProcessor composed with a DiscountCalculator instead of extending a BaseOrder. This lets you swap discount strategies at runtime without changing the processor.
package io.thecodeforge.oop; // Discount as a separate responsibility interface DiscountCalculator { double applyDiscount(double total); } class NoDiscount implements DiscountCalculator { @Override public double applyDiscount(double total) { return total; } } class SeasonalDiscount implements DiscountCalculator { @Override public double applyDiscount(double total) { return total * 0.9; } } public class OrderProcessor { private final DiscountCalculator discount; public OrderProcessor(DiscountCalculator discount) { this.discount = discount; } public double process(double total) { // ... validation, tax calculation etc. return discount.applyDiscount(total); } }
- Inheritance models identity; composition models capability.
- Composition keeps classes small and focused (Single Responsibility).
- You can swap behaviors at runtime (Strategy pattern).
- Composition doesn't lock you into a rigid hierarchy.
- Interfaces make composition natural and testable.
Object Creation in Java — The Full Menu
Every Java developer knows 'new'. But if you're debugging a memory leak or designing a library that needs to control instantiation, you need the full picture. Here's what you can actually do: the new keyword (for normal objects), Class.forName().newInstance() (reflection, still used in legacy frameworks), Constructor.newInstance() (reflection with parameter access, preferred for modern reflective code), (shallow copy — no constructor called, watch for shared mutable state), and deserialization (reads the object from a byte stream, also skips the constructor). Why does this matter? Because two of these mechanisms bypass constructor validation entirely. If your constructor sets invariants (e.g., 'age must be positive'), a cloned or deserialized object can violate them silently. The rule: never rely on a constructor alone for security. Validate invariants in setters or use a factory method that controls the entire creation path.clone()
// io.thecodeforge public class User { private final String name; private final int age; public User(String name, int age) { if (age < 0) throw new IllegalArgumentException("Age cannot be negative"); this.name = name; this.age = age; } // Clone bypasses constructor! @Override public User clone() { return (User) super.clone(); // age could be -1 } } // Usage in production: User original = new User("Alice", 30); User clone = original.clone(); // no validation here
readObject() or override clone() to re-validate. I've seen this create 'impossible' null pointer bugs in payment systems.private validate() method called from constructor, setters, clone(), and readObject(). One validation point, zero surprises.Access Modifiers — The Gatekeepers You Can't Ignore
Access modifiers are not a syntax quiz. They are your contract with the rest of the codebase. private means 'implementation detail — change at will'. public means 'other teams depend on this; break it and you get paged at 3 AM'. Here's the real-world breakdown: public — visible everywhere. Use only for API endpoints or stable library interfaces. protected — visible to subclasses and same package. This is the most abused modifier. Why? Because people use 'protected' for fields, thinking it's safe. It's not. It couples you to every subclass ever written. default (no modifier) — visible to package. Good for internal helpers you don't want to leak. private — only the class. Getters and setters should never be the default. Ask: 'does this state need to be exposed?' If not, keep it private. A senior move: use private for everything, then relax to default or protected only when you have a concrete use case. Never start with public.
// io.thecodeforge public class PaymentService { private double rate; // OK — internal state protected int timeout; // BAD — now every subclass inherits this field public String name; // WORST — anyone can mutate your identity public PaymentService(double rate, int timeout, String name) { this.rate = rate; this.timeout = timeout; this.name = name; } } // After refactoring — defensive: public class BetterPaymentService { private final double rate; private final int timeout; private String name; public BetterPaymentService(double rate, int timeout, String name) { this.rate = rate; this.timeout = timeout; this.name = name; } protected int getTimeout() { return timeout; } // controlled access }
protected timeout field to 0, causing a denial-of-service. Getters and setters are not just Java ceremony — they are firewalls against accidental misuse.private final for fields. Expose behavior via methods, never raw fields. Relax visibility only when you have a provable need.The Payment Processing Bug That Cost $23K
RefundablePayment subclass wouldn't break the existing process method because the parent Payment class seemed generic enough.RefundablePayment class extended Payment and overrode process() — but the overridden version in the base class was called instead for non-refundable payments due to a missing @Override annotation and different method signature. More subtly, the code that called process() didn't know about refund(), and RefundablePayment was not truly substitutable for Payment without breaking the caller's expectation.Refundable interface for refund capability. RefundablePayment implemented both Payment and Refundable, and the refund logic was moved out of the inheritance chain. The process method was marked final in the base class to prevent accidental override shadowing.- Favor composition over inheritance when adding orthogonal behavior like refunds.
- Always annotate overrides with
@Overrideto catch signature mismatches at compile time. - If you can't guarantee Liskov Substitutability, break the inheritance and use interfaces.
System.out.println(getClass().getName()) to see the actual runtime type. Verify @Override annotations.private, it's not overridable. Mark it protected or public. Also check the constructor invocation order: if a parent constructor calls an overridden method, the child's fields may not yet be initialized.private and accessed only via getters/setters. If a setter is missing validation, add it. Consider making the class final to prevent unintended subclass access.instanceof checks to branch behavior, it's a sign of violated LSP. Refactor by introducing a separate interface or abstract method.javap -p <classname> to list methods in the compiled classmvn dependency:tree to see conflicting versionsAdd a breakpoint in the parent constructor and child constructorAdd `System.out.println(getClass().getSimpleName() + " " + field)`javap -c -p <classname> to see method signaturesAdd `@Override` to ensure compile-time check| Aspect | Abstract Class | Interface |
|---|---|---|
| Can hold instance state (fields) | Yes — instance fields allowed | No — only static final constants |
| Constructor | Yes — can define constructors | No — interfaces have no constructors |
| Inheritance limit | Single parent class only | A class can implement unlimited interfaces |
| Method types allowed | Abstract + concrete + static | Abstract + default + static (Java 8+) |
| Access modifiers on methods | Any modifier (private, protected, public) | Public by default (private since Java 9) |
| Best used when | Shared state + is-a relationship exists | Shared capability across unrelated classes |
| Real Java example | AbstractList in java.util | Comparable, Runnable, Serializable |
| File | Command / Code | Purpose |
|---|---|---|
| io | /** | The Four Pillars |
| io | interface Notifier { | Polymorphism vs Abstraction |
| io | abstract class BaseVehicle { | Abstract Classes vs Interfaces |
| io | /** | Inheritance Pitfalls and the Liskov Substitution Principle |
| io | interface DiscountCalculator { | Composition Over Inheritance – A Real-World Refactoring |
| ObjectCreationExamples.java | public class User { | Object Creation in Java |
| AccessModifierRefactoring.java | public class PaymentService { | Access Modifiers |
Key takeaways
Common mistakes to avoid
3 patternsConfusing overloading with overriding
Thinking private fields are 'inherited'
Using an interface purely because 'it allows multiple inheritance'
Interview Questions on This Topic
Explain the 'Diamond Problem' in Java. Why does it not occur with interfaces even with default methods, but would occur with multiple class inheritance?
Why is it said that 'Composition is better than Inheritance'? Provide a scenario where favoring inheritance would lead to a rigid architecture.
Vehicle and Engine. With inheritance, you'd create GasCar extends Car extends Vehicle, ElectricCar extends Vehicle, etc. Adding a hybrid would break the hierarchy. With composition, Car has an Engine interface — you can swap gas, electric, or hybrid engines without touching the Car class. Composition also avoids breaking encapsulation because subclasses can depend on parent internals.If a parent class constructor calls an overridden method, what is the risk? Walk through the object initialization order and explain the potential for NullPointerException.
null or 0 at that point, leading to NullPointerException or incorrect behavior. Example: parent's constructor calls overrideMe(), child overrides overrideMe() and uses this.name which is still null. The fix: never call overridable methods from constructors. Mark called methods as private or final to prevent overriding.Frequently Asked Questions
Shadowing occurs when a variable in a subclass has the same name as a variable in the parent class; this is resolved based on the reference type. Overriding applies to methods and is resolved based on the actual object type at runtime. Seniors should note that shadowing variables is generally considered bad practice as it violates encapsulation and clarity.
Primarily to avoid the 'Diamond Problem,' where a subclass might inherit conflicting implementations of the same method from two different parent classes. Java allows multiple inheritance of type via interfaces, resolving method conflicts through specific rules for default methods introduced in Java 8.
Overloading is compile-time polymorphism: multiple methods in the same class share a name but differ in parameter type or count, and the compiler decides which to call. Overriding is runtime polymorphism: a subclass provides its own implementation of a parent method with the identical signature, and the JVM decides which to call based on the actual object type — not the reference type.
A final method cannot be overridden by subclasses — useful for protecting critical logic that should not be changed. A final class cannot be subclassed at all — used for immutable classes like String or to prevent inheritance-based design issues.
20+ years shipping production code across the stack, with years spent interviewing engineers. Drawn from code that ran under real load.
That's Java Interview. Mark it forged?
5 min read · try the examples if you haven't