OOP Pillars Deep Dive: Encapsulation, Inheritance, Polymorphism, and Abstraction
A staff-engineer guide to the four pillars of object-oriented design — beyond textbook definitions, into the trade-offs that decide whether a class hierarchy survives contact with real requirements.
OOP Pillars Deep Dive
Every LLD interview and every production class hierarchy rests on four ideas: encapsulation, inheritance, polymorphism, and abstraction. Most engineers can recite the textbook definitions. Far fewer can say when a pillar is the wrong tool — when inheritance will collapse under a small requirement change, when "abstraction" is really just indirection, or when encapsulation needs to protect an invariant rather than just hide a field. This guide goes past the definitions into the judgment calls.
1. The Four Pillars, and How They Relate
The pillars aren't independent — they compose. Abstraction defines what an object exposes; encapsulation enforces that boundary; polymorphism lets many shapes honor the same abstraction; inheritance is one (often overused) mechanism for reusing implementation across those shapes.
Notice inheritance is drawn with a dotted line. It is the pillar most LLD interviews penalize for overuse. Composition — building behavior by holding references to other objects — satisfies polymorphism and reuse without inheritance's baggage. We come back to this in Section 3.
2. Encapsulation: Information Hiding, Not Just Field Hiding
The textbook definition — "bundling data and methods, hiding fields behind getters/setters" — misses the point. Encapsulation's job is to protect invariants: facts about an object that must always hold true. A class with private fields and public getters/setters for every field has hidden nothing; it has just added ceremony around direct field access.
Data hiding vs information hiding
| Data hiding | Information hiding | |
|---|---|---|
| What's hidden | The fields themselves | The decision of how the object is represented internally |
| Typical shape | private fields + generated getters/setters | Behavior-oriented methods; representation can change freely |
| Invariant safety | None — callers can still set fields into any combination | Enforced — the class controls every state transition |
| Example | setBalance(double b) | withdraw(Money amount) which internally checks funds and updates balance |
// Data hiding: fields are private, but the invariant (balance >= 0)
// is not protected — any caller can violate it.
public class BadAccount {
private double balance;
public double getBalance() { return balance; }
public void setBalance(double balance) { this.balance = balance; } // no guard!
}
BadAccount acct = new BadAccount();
acct.setBalance(-500); // invariant violated, and the class allowed it// Information hiding: representation is private, invariant is
// enforced by the only two operations that can change balance.
public final class Account {
private long balanceCents; // representation detail — could become BigDecimal tomorrow
public Account(long openingBalanceCents) {
if (openingBalanceCents < 0) {
throw new IllegalArgumentException("Opening balance cannot be negative");
}
this.balanceCents = openingBalanceCents;
}
public void withdraw(long amountCents) {
if (amountCents > balanceCents) {
throw new InsufficientFundsException(amountCents, balanceCents);
}
balanceCents -= amountCents;
}
public void deposit(long amountCents) {
if (amountCents <= 0) {
throw new IllegalArgumentException("Deposit must be positive");
}
balanceCents += amountCents;
}
public long balanceCents() { return balanceCents; }
}A useful test for "did I actually encapsulate this, or just hide fields?": ask whether you could change the internal representation (e.g. double → long cents, or a single field → a computed value) without changing the public API or breaking any caller. If yes, you have real information hiding.
Immutability as an encapsulation strategy
The strongest form of invariant protection is making the invariant impossible to violate after construction — immutability. An immutable object's invariants are checked once, in the constructor, and never again.
public final class Money {
private final long amountCents;
private final String currency;
public Money(long amountCents, String currency) {
if (amountCents < 0) throw new IllegalArgumentException("Amount cannot be negative");
this.amountCents = amountCents;
this.currency = Objects.requireNonNull(currency);
}
public Money add(Money other) {
requireSameCurrency(other);
return new Money(this.amountCents + other.amountCents, currency); // returns new instance
}
private void requireSameCurrency(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("Currency mismatch: " + currency + " vs " + other.currency);
}
}
}Immutable objects are automatically thread-safe with no locking. In concurrent backend code, prefer immutable value objects (Money, UserId, DateRange) over mutable ones whenever the object represents a value rather than an identity with a lifecycle.
3. Inheritance: Powerful, and the Most Misused Pillar
Inheritance models an "is-a" relationship and lets a subclass reuse a superclass's implementation. The trouble is that "is-a" in English is a looser relationship than "is-a" in a type system needs to be — and reusing implementation via inheritance couples the subclass to the superclass's internals in ways that are easy to violate later.
The fragile base class problem
This is not hypothetical — it's the reason the JDK's java.util.Properties extending Hashtable, or early java.util.Stack extending Vector, are considered design mistakes: a change to the superclass's internal implementation silently breaks subclasses that never touched that code.
Rule of thumb: never extend a concrete class you don't control (a library class) unless it was explicitly designed for extension (documented extension points, protected hooks, no internal self-calls that bypass overrides). If in doubt, wrap it instead of extending it — see Adapter/Decorator in Phase 3.
Composition over inheritance
Composition means an object holds a reference to another object and delegates to it, instead of inheriting its implementation. The same behavior reuse is achieved, but coupling is far looser — the composed object is swappable, mockable, and doesn't leak its internal method-calls-method structure.
// Inheritance approach: adding a hybrid vehicle forces awkward choices
class Vehicle { void move() { /* ... */ } }
class Car extends Vehicle { }
class ElectricCar extends Car { } // is an ElectricCar-a-Car-a-Vehicle chain right?
// what about an electric truck? diamond problem looms.
// Composition approach: behavior is a plugged-in collaborator
interface Engine { int horsepower(); void start(); }
class ElectricEngine implements Engine {
public int horsepower() { return 300; }
public void start() { /* silent start */ }
}
class CombustionEngine implements Engine {
public int horsepower() { return 250; }
public void start() { /* ignition sequence */ }
}
class Car {
private final Engine engine; // composed, not inherited
public Car(Engine engine) { this.engine = engine; }
public void drive() {
engine.start();
// Car doesn't need to know HOW the engine starts
}
}
Car electricCar = new Car(new ElectricEngine());
Car dieselCar = new Car(new CombustionEngine());Ask this question before reaching for extends: "Do I need this subclass to be substitutable wherever the superclass is expected?" (Liskov Substitution, covered in the SOLID guide). If the real need is just "reuse this logic," composition — or a static helper, or an injected interface — is almost always the safer choice.
When inheritance IS the right call
Inheritance earns its place when:
- You're modeling a genuine, stable taxonomy (
Shape→Circle,Rectangle— geometry doesn't grow new dimensions of variation often). - You control both the base and derived classes, so a base-class change can be verified against every subclass at compile time.
- You want to use the Template Method pattern deliberately — the base class defines an algorithm's skeleton and expects subclasses to fill in specific steps (see Phase 4).
4. Polymorphism: Three Different Ideas Sharing One Name
"Polymorphism" in casual conversation usually means one thing (subtype polymorphism via interface/extends), but the term covers three genuinely different mechanisms.
| Kind | Mechanism | Resolved when | Java example |
|---|---|---|---|
| Subtype | Dynamic dispatch via vtable | Runtime | Shape s = new Circle(); s.area(); calls Circle.area() |
| Parametric | Type erasure + compile-time checks | Compile time (erased at runtime) | List<String>, Box<T> |
| Ad-hoc | Method overload resolution | Compile time, by static argument types | void log(String s) vs void log(int i) |
// Subtype polymorphism — the interview-relevant one
interface Shape { double area(); }
class Circle implements Shape {
double radius;
public double area() { return Math.PI * radius * radius; }
}
class Rectangle implements Shape {
double w, h;
public double area() { return w * h; }
}
// Same call site, different behavior resolved at RUNTIME based on actual object type
List<Shape> shapes = List.of(new Circle(), new Rectangle());
double total = shapes.stream().mapToDouble(Shape::area).sum();Overloading pitfall: because ad-hoc polymorphism resolves at compile time using the static type, a common bug is calling an overloaded method with a variable declared as a supertype — the supertype overload runs, not the one matching the runtime type. This is different from overriding (subtype polymorphism), which always dispatches on the runtime type. Confusing the two is a classic interview trick question.
void handle(Object o) { System.out.println("Object version"); }
void handle(String s) { System.out.println("String version"); }
Object o = "hello";
handle(o); // prints "Object version" — resolved by the STATIC type of o, not "hello"'s actual type5. Abstraction: Hiding Complexity, Exposing Intent
Abstraction is the discipline of designing an interface around what a client needs to do, not around how the implementation happens to work today. A well-abstracted PaymentGateway interface doesn't leak whether it's calling Stripe or a bank's SOAP API — it exposes charge(Money, PaymentMethod) and nothing about HTTP, retries, or serialization.
A good litmus test for abstraction quality: can you swap the implementation for a test double without changing a single line of client code? If a client has to know it's talking to Stripe specifically (checking for a StripeException, reading a Stripe-specific field), the abstraction has leaked.
Abstraction vs indirection
Not every interface is a good abstraction. An interface with one implementation, added "for future flexibility" with no current need, is indirection dressed up as abstraction — it adds a layer of navigation without hiding any real complexity or variability.
| Question | Real abstraction | Just indirection |
|---|---|---|
| Are there 2+ real implementations today, or a concrete near-term need for one? | Yes | No — speculative |
| Does the interface hide meaningfully different internal complexity? | Yes | No — it's a thin pass-through |
| Would tests benefit from swapping implementations? | Yes | Not really |
6. Identifying Objects and Responsibilities
Before any pattern or pillar applies, you have to decide what the objects even are. A reliable technique borrowed from CRC (Class-Responsibility-Collaborator) cards:
- Nouns in the requirements become candidate classes ("a
Customerplaces anOrdercontainingLineItems"). - Verbs become candidate methods — but ask which noun owns the verb. "Calculate total" — does
Ordercalculate its own total, or does an externalPricingService? Prefer giving behavior to the object that owns the data it needs (this is the essence of good cohesion, covered in the Coupling & Cohesion guide). - A responsibility that needs data from two objects equally is a signal you might need a third, coordinating object (a service, or a pattern like Mediator).
7. The Law of Demeter (Principle of Least Knowledge)
A method on object A should only talk to: itself, its own fields, objects passed as parameters, objects it creates, and its direct component objects — not objects reachable through those objects. Informally: "don't talk to strangers," or "use only one dot."
// Violates Law of Demeter — reaches through Order to Customer to Address to city.
// Any change to how an Order relates to a Customer breaks this call chain.
String city = order.getCustomer().getAddress().getCity();
// Respects the Law of Demeter — Order exposes exactly what callers need,
// hiding how it's actually composed internally.
String city = order.shippingCity();This is why chains like a.getB().getC().getD() ("train wrecks") are a code-smell flag in reviews — each . is a place your code becomes coupled to a structure it doesn't own. The fix is almost always to add a delegating method on the immediately-owned object.
Interview Questions
- What is the practical difference between data hiding and information hiding? Give an example where private fields with getters/setters still violate encapsulation.
- Why is inheriting from a concrete class you don't control considered risky? What is the "fragile base class problem"?
- When would you choose composition over inheritance, and what specifically does it buy you?
- Name the three kinds of polymorphism in Java and explain when each is resolved (compile time vs runtime).
- What's the difference between a real abstraction and "indirection dressed up as abstraction"?
- How do you decide which class should own a given piece of behavior when designing from a requirements paragraph?
- What is the Law of Demeter, and why does a chain like
a.getB().getC().getD()violate it? - Why does immutability make encapsulation stronger, and what does it buy you in concurrent code?