Object-Oriented Programming Explained: The Four Pillars and Composition Over Inheritance
Object-oriented programming explained for interviews: encapsulation, abstraction, inheritance, polymorphism, and composition over inheritance (is-a vs has-a) with a worked payment-system example.
You walk into the kitchen half-asleep, drop coffee beans in the grinder, and press one button. Something whirs, something spins, and out come grounds. You have no idea whether there is one motor or two in there, how fast the blades turn, or what stops the thing from catching fire when it jams — and you have never once needed to. The machine handed you a button and kept everything else to itself.
That quiet arrangement — a small set of buttons you press, a whole tangle of wiring you never touch — is the entire idea behind object-oriented programming. An object is an appliance: it shows you a few things you are allowed to do and hides the messy machinery that makes them work. By the end of this article you and I will have built a small payment system where all four "pillars" of OOP — encapsulation, abstraction, inheritance, polymorphism — show up on the same objects, and you will understand the one lesson that separates code that ages well from code that rots: favour composition over inheritance.
Let's start at the kitchen counter
Look at the appliances on the counter and notice two different things going on.
First, every one of them is sealed. The blender has a START button on the front and a motor bolted inside a plastic shell you would need a screwdriver to open. You press the button; the appliance decides what the motor does. You cannot reach in and spin the motor by hand, and that is not an annoyance — it is the whole safety guarantee. The blender promises "I will only ever run when the lid is on," and it can only promise that because you have no way to bypass the button.
Second, look at the blender and the stand mixer side by side. Different jobs, different attachments — but pop the shells and they share the same kind of electric motor. Two ways to get that shared motor into both appliances: you could declare "a blender is a kind of motor with blades welded on," or you could say "a blender has a motor as one of its parts." Those sound almost identical. They are not, and the difference between them is the most important decision in this whole article.
Hold both pictures in your head, because every idea that follows is one of them:
- The sealed shell — private machinery reachable only through a few public buttons — is encapsulation, and the button-instead-of-wiring promise is abstraction.
- Sharing the motor — by welding it into the design (is-a) or by bolting it in as a part (has-a) — is the inheritance versus composition choice.
Everything else is detail.
Where you already meet this
Once you start seeing objects as appliances, they are everywhere in the software you touched this morning. The pay button in a shopping app is a single method call — checkout.pay() — sitting on top of card networks, fraud checks, retries, and currency conversion you will never see. The app switcher on your phone treats every running app as the same shape (draw yourself, pause, resume) without caring whether it is a game or a calculator. A file, a socket, and a keyboard all answer read() and write(), so the same copy loop works on any of them.
The one worth slowing down on is that pay button, because it is the running example for the rest of this article. When you tap "Pay ₹499", the checkout screen does not contain a giant if ladder — "if the user picked a card, do this; if UPI, do that; if wallet, do the other." That screen holds exactly one thing: some object that promises it can pay. The screen calls pay and moves on. Whether that object talks to a card gateway or debits an in-app wallet is the object's private business — the same way the START button doesn't know or care how many motors are behind it. Build the system that way and you can add a brand-new payment method next quarter without the checkout screen changing a single line. Build it the other way and every new method means surgery on code that already works.
That is the payoff we are chasing. Let's earn it one pillar at a time.
What it actually looks like
Before any code, here is the whole destination on one card: four pillars, all riding on the same little payment system.
Read them as four promises an object makes:
- Encapsulation — "my data is mine; you change it only through my methods, so I can guarantee it never goes bad."
- Abstraction — "you call the button on my contract; the wiring behind it is not your problem."
- Inheritance — "I am a kind of something more general, and I reuse its type."
- Polymorphism — "call the same method on any of us, and each of us does the right thing for what we actually are."
They are not four unrelated tricks. They stack: encapsulation hides the state, abstraction names the button, inheritance says who counts as this kind of thing, and polymorphism makes one call work across all of them. Let's build them in that order, on real objects.
Step 1 — Encapsulation: lock the state behind methods
Start with the thing that most needs protecting: money. A Wallet holds a balance, and there is one rule that must never break — the balance can't go negative, and it can't be changed by anyone reaching in from outside. If the balance were a public field, any line anywhere could write wallet.balance = -9999 and the rule would be a suggestion, not a guarantee.
So we make the field private and hand out methods as the only doors. Every door checks the rule before it lets a change through.
public final class Wallet {
private long balancePaise; // private: no outside line can touch it
public Wallet(long openingPaise) {
if (openingPaise < 0) {
throw new IllegalArgumentException("opening balance cannot be negative");
}
this.balancePaise = openingPaise;
}
public long balancePaise() {
return balancePaise; // read-only window in
}
public void debit(long amountPaise) {
if (amountPaise <= 0) {
throw new IllegalArgumentException("amount must be positive");
}
if (amountPaise > balancePaise) {
throw new IllegalStateException("insufficient funds");
}
balancePaise -= amountPaise; // the rule is checked BEFORE the change
}
public void credit(long amountPaise) {
if (amountPaise <= 0) {
throw new IllegalArgumentException("amount must be positive");
}
balancePaise += amountPaise;
}
}The rule in plain words: the object owns its data, and every change goes through a method that keeps the data legal. Because balancePaise is private, there is no way to reach the field except through debit and credit, and both refuse an illegal move. The invariant "balance is a non-negative number that only moves by real deposits and withdrawals" is now something the Wallet can actually promise, not just hope for.
The tell for good encapsulation isn't "are the fields private" — it's "could a
caller ever put this object in a state it should never be in?" If the only way
to change Wallet is debit/credit, and both guard the invariant, the
answer is no. Notice too that money is stored in paise (integer), never
rupees as a double — floating point can't represent 0.10 exactly, and money
that drifts by a fraction of a paisa per transaction is a bug that reaches
production.
Step 2 — Abstraction: program to the button, not the wiring
Now the checkout screen. It needs to charge money, but it must not know or care how. So we write down the button as a contract — an interface — and let the screen depend only on that.
public interface PaymentMethod {
PaymentResult pay(long amountPaise); // the one button
String label(); // e.g. "Wallet", "Card", "UPI"
}public record PaymentResult(boolean ok, String detail) {}The Checkout takes a PaymentMethod and calls pay. That is the entire relationship — it holds the contract and presses the button.
public final class Checkout {
public PaymentResult charge(PaymentMethod method, long amountPaise) {
System.out.println("Charging via " + method.label() + " …");
PaymentResult result = method.pay(amountPaise);
System.out.println(" → " + (result.ok() ? "OK" : "FAILED") + ": " + result.detail());
return result;
}
}The rule: depend on what a thing can do, not on what it is. Checkout names PaymentMethod and nothing more concrete. Every wallet, card, or UPI detail lives behind that contract, exactly like the START button hiding the motor. Abstraction is encapsulation aimed one level up — encapsulation hides a field inside an object; abstraction hides a whole class behind an interface.
Encapsulation and abstraction get muddled constantly, so pin the difference: encapsulation is hiding how the data is stored (the private balance); abstraction is hiding which concrete class is doing the work (the interface the checkout screen leans on). One protects an invariant inside an object; the other lets a caller ignore which object it's holding.
Step 3 — Inheritance, and exactly where it bites
Inheritance is the "is-a" tool: a subclass declares it is a kind of its parent and inherits the parent's type and behaviour. Used with discipline it is fine — but it's the pillar people reach for too fast, so let's look straight at the trap that fails interview rounds.
The classic is the rectangle. A Square is a rectangle, right? Every square is a rectangle — the geometry teacher said so. So you write class Square extends Rectangle and override the setters to keep all sides equal. Watch it break.
class Rectangle {
protected int width, height;
void setWidth(int w) { this.width = w; }
void setHeight(int h) { this.height = h; }
int area() { return width * height; }
}
class Square extends Rectangle {
@Override void setWidth(int w) { this.width = w; this.height = w; } // keep sides equal
@Override void setHeight(int h) { this.width = h; this.height = h; }
}Now some perfectly reasonable code, written against Rectangle, sets a width and a height and expects them to stick:
Rectangle r = new Square();
r.setWidth(5);
r.setHeight(4);
// A Rectangle promises area == 5 * 4 == 20.
// But setHeight(4) on a Square just reset width to 4 too → area == 16.The rule: a subclass must be usable anywhere its parent is, with no surprises. Square breaks that — it silently changes a field the caller thought it had already set. This is the fragile base class problem, and its formal name is the Liskov Substitution Principle (the "L" in SOLID): if code works with a Rectangle, swapping in a Square must not change what it can rely on. "Every square is a rectangle" is true in geometry and false in code, because a mutable Rectangle promises independent width and height — and a square, by definition, can't keep that promise.
Inheritance couples a child to its parent's implementation, not just its interface — override one method and you can quietly violate an assumption three methods away. That's why a change to a base class can break subclasses written by someone who left the company two years ago. The deeper the hierarchy, the more places that fragility hides. Reach for inheritance only when the "is-a" is truly substitutable and the base class is stable.
Step 4 — Polymorphism: one call site, many runtime types
Here is where the design pays off. We have three real payment methods, each implementing the same contract its own way.
public final class WalletPayment implements PaymentMethod {
private final Wallet wallet;
public WalletPayment(Wallet wallet) { this.wallet = wallet; }
public PaymentResult pay(long amountPaise) {
if (amountPaise > wallet.balancePaise()) {
return new PaymentResult(false, "wallet: insufficient funds");
}
wallet.debit(amountPaise);
return new PaymentResult(true, "wallet: paid " + amountPaise + "p");
}
public String label() { return "Wallet"; }
}
public final class UpiPayment implements PaymentMethod {
private final String vpa;
public UpiPayment(String vpa) { this.vpa = vpa; }
public PaymentResult pay(long amountPaise) {
return new PaymentResult(true, "upi " + vpa + ": collected " + amountPaise + "p");
}
public String label() { return "UPI"; }
}Now look back at Checkout.charge. It contains exactly one call — method.pay(amountPaise) — and yet that one line runs wallet code, UPI code, or card code depending on the actual object handed in. There is no if, no switch, no type check. The object on the other end of the reference decides which pay runs. That runtime decision is dynamic dispatch, and it is the beating heart of polymorphism.
The rule: write the call once against the contract; let each object supply its own behaviour. The value this buys is the thing every interviewer is really probing — extensibility. When a new payment method arrives, you add one class that implements PaymentMethod. The checkout screen, the call site, the for loop that runs a list of methods — none of them change, because none of them ever named a concrete type. Compare that with the if/else ladder: there, every new method reopens and re-tests code that already worked. Polymorphism turns "edit everywhere" into "add one file."
This is the Open/Closed Principle in the
flesh — open to new payment methods (add a class), closed to modification
(the checkout code is never touched). Whenever you catch yourself writing if (type == A) … else if (type == B) …, that's polymorphism knocking: the
branches want to become classes, and the ladder wants to become one
dynamic-dispatch call. It's the same instinct behind the Strategy pattern in
design patterns.
Step 5 — Composition over inheritance: swap a part, don't freeze a tree
We've saved the card for last, because it teaches the headline lesson. A card payment isn't one behaviour — it's a bundle. It needs a gateway to talk to, and a retry policy for when the gateway is flaky (try three times? give up immediately?). Later it'll want a fraud check, a currency converter, and more.
The tempting move is inheritance: RetryingCardPayment extends CardPayment, then FraudCheckedRetryingCardPayment extends that… and now every combination of behaviours needs its own class. Two retry choices times two fraud choices is four classes; add a third axis and it's eight. The hierarchy explodes, and none of it can change at runtime — a RetryingCardPayment is retrying forever, welded that way at compile time.
Composition dissolves the whole mess. Instead of being a retrying-fraud-checked card by subclassing, a CardPayment has a RetryPolicy and has a Gateway as parts you hand it. Want no retries? Pass a different policy. Same class, different wiring.
public interface RetryPolicy {
boolean retryAfterFailure(int attempt);
static RetryPolicy noRetry() { return attempt -> false; }
static RetryPolicy upTo(int maxTries) { return attempt -> attempt < maxTries; }
}public final class CardPayment implements PaymentMethod {
private final String last4;
private final Gateway gateway; // HAS-A: a part, injected
private final RetryPolicy retry; // HAS-A: another part, swappable
public CardPayment(String last4, Gateway gateway, RetryPolicy retry) {
this.last4 = last4;
this.gateway = gateway;
this.retry = retry;
}
public PaymentResult pay(long amountPaise) {
int attempt = 1;
while (true) {
if (gateway.charge(amountPaise)) {
return new PaymentResult(true, "card ****" + last4 + ": approved on attempt " + attempt);
}
if (!retry.retryAfterFailure(attempt)) {
return new PaymentResult(false, "card ****" + last4 + ": declined after " + attempt + " attempt(s)");
}
attempt++;
}
}
public String label() { return "Card"; }
}The rule — the one to carry into every design round: prefer "has-a" to "is-a." Model a thing as a bundle of parts you can swap, not as a fixed spot in a family tree. Inheritance freezes behaviour at compile time and couples you to a base class; composition lets you assemble behaviour at runtime and swap any part without touching the rest. The blender doesn't inherit from a motor — it has one, and that's exactly why the same design accepts a bigger motor next year.
"Favour composition over inheritance" doesn't mean never inherit.
Inheritance earns its place for a genuine, stable "is-a" where the child is
fully substitutable — and implementing an interface (like PaymentMethod)
is inheritance of a type, which is exactly the good kind. The rule is
specifically about inheriting implementation from a concrete base class to
reuse code. When the motive is "I want to reuse that code," reach for a part,
not a parent.
Inheritance vs composition, side by side
Both give you code reuse and polymorphism. They differ on everything that matters as the system grows — which is why "is-a or has-a?" is the question a good reviewer asks first.
| Question | Inheritance (is-a) | Composition (has-a) |
|---|---|---|
| When is behaviour fixed? | Compile time — welded into the type | Runtime — swap the part whenever |
| Coupling to the reused code | Tight — child sees the parent's internals | Loose — you only see the part's interface |
| Combining N independent traits | N axes → a class per combination (explodes) | N parts, mixed freely — no explosion |
| A change to the reused code | Can silently break every subclass | Contained behind the part's contract |
| Right tool when… | a true, stable, substitutable "is-a" | you're reusing behaviour or need to swap it |
The single asymmetry to remember: inheritance decides once, forever, at compile time; composition decides per object, and you can change your mind. That flexibility is why nearly every design pattern — Strategy, Decorator, Adapter — is composition wearing a specific hat.
The complete implementation
Everything above, assembled into one package — the encapsulated Wallet, the PaymentMethod contract, three implementations (one of them composed from swappable parts), and the Checkout that drives them all polymorphically.
package dev.fiveyear.oop;
/** The money-holder: private state, guarded by methods. (Encapsulation.) */
final class Wallet {
private long balancePaise;
Wallet(long openingPaise) {
if (openingPaise < 0) {
throw new IllegalArgumentException("opening balance cannot be negative");
}
this.balancePaise = openingPaise;
}
long balancePaise() { return balancePaise; }
void debit(long amountPaise) {
if (amountPaise <= 0) {
throw new IllegalArgumentException("amount must be positive");
}
if (amountPaise > balancePaise) {
throw new IllegalStateException("insufficient funds");
}
balancePaise -= amountPaise;
}
void credit(long amountPaise) {
if (amountPaise <= 0) {
throw new IllegalArgumentException("amount must be positive");
}
balancePaise += amountPaise;
}
}
/** The contract every payment method promises. (Abstraction.) */
interface PaymentMethod {
PaymentResult pay(long amountPaise);
String label();
}
record PaymentResult(boolean ok, String detail) {}
/** A swappable part a card composes — not a subclass. (Composition.) */
interface RetryPolicy {
boolean retryAfterFailure(int attempt);
static RetryPolicy noRetry() { return attempt -> false; }
static RetryPolicy upTo(int maxTries) { return attempt -> attempt < maxTries; }
}
/** The external charger, injected so behaviour is deterministic and testable. */
interface Gateway {
boolean charge(long amountPaise);
}
final class WalletPayment implements PaymentMethod {
private final Wallet wallet;
WalletPayment(Wallet wallet) { this.wallet = wallet; }
public PaymentResult pay(long amountPaise) {
if (amountPaise > wallet.balancePaise()) {
return new PaymentResult(false, "wallet: insufficient funds");
}
wallet.debit(amountPaise);
return new PaymentResult(true, "wallet: paid " + amountPaise + "p");
}
public String label() { return "Wallet"; }
}
final class UpiPayment implements PaymentMethod {
private final String vpa;
UpiPayment(String vpa) { this.vpa = vpa; }
public PaymentResult pay(long amountPaise) {
return new PaymentResult(true, "upi " + vpa + ": collected " + amountPaise + "p");
}
public String label() { return "UPI"; }
}
final class CardPayment implements PaymentMethod {
private final String last4;
private final Gateway gateway; // HAS-A
private final RetryPolicy retry; // HAS-A
CardPayment(String last4, Gateway gateway, RetryPolicy retry) {
this.last4 = last4;
this.gateway = gateway;
this.retry = retry;
}
public PaymentResult pay(long amountPaise) {
int attempt = 1;
while (true) {
if (gateway.charge(amountPaise)) {
return new PaymentResult(true, "card ****" + last4 + ": approved on attempt " + attempt);
}
if (!retry.retryAfterFailure(attempt)) {
return new PaymentResult(false, "card ****" + last4 + ": declined after " + attempt + " attempt(s)");
}
attempt++;
}
}
public String label() { return "Card"; }
}
/** Depends only on the contract; runs each method polymorphically. */
final class Checkout {
PaymentResult charge(PaymentMethod method, long amountPaise) {
System.out.println("Charging via " + method.label() + " …");
PaymentResult result = method.pay(amountPaise);
System.out.println(" → " + (result.ok() ? "OK" : "FAILED") + ": " + result.detail());
return result;
}
}And here it is running — a gateway that declines its first two attempts and approves the third lets us watch retry policy change the outcome, and the wallet's invariant hold under an illegal debit:
package dev.fiveyear.oop;
import java.util.List;
public final class Demo {
/** A stub gateway: approves only on its Nth call. (Injected seam → deterministic.) */
static final class FlakyGateway implements Gateway {
private final int approveOn;
private int calls = 0;
FlakyGateway(int approveOn) { this.approveOn = approveOn; }
public boolean charge(long amountPaise) { return ++calls >= approveOn; }
}
public static void main(String[] args) {
Wallet wallet = new Wallet(50_00); // ₹50.00, held as paise
Checkout checkout = new Checkout();
// One loop, three concrete types — pure polymorphism.
List<PaymentMethod> methods = List.of(
new WalletPayment(wallet),
new UpiPayment("arav@bank"),
new CardPayment("4242", new FlakyGateway(3), RetryPolicy.upTo(3))
);
for (PaymentMethod m : methods) {
checkout.charge(m, 20_00); // charge ₹20.00 each
}
// Same CardPayment class, a different composed part → a different outcome.
checkout.charge(new CardPayment("4242", new FlakyGateway(3), RetryPolicy.noRetry()), 20_00);
// Encapsulation: the invariant refuses an illegal debit, state stays legal.
try {
wallet.debit(999_99);
} catch (IllegalStateException e) {
System.out.println("blocked: " + e.getMessage());
}
System.out.println("wallet still holds " + wallet.balancePaise() + "p");
}
}Running Demo.main prints exactly:
Charging via Wallet …
→ OK: wallet: paid 2000p
Charging via UPI …
→ OK: upi arav@bank: collected 2000p
Charging via Card …
→ OK: card ****4242: approved on attempt 3
Charging via Card …
→ FAILED: card ****4242: declined after 1 attempt(s)
blocked: insufficient funds
wallet still holds 3000pRead the last four lines against the pillars. The two Card lines are the same class producing opposite results purely because one composed a retry policy and one didn't — composition, visible. The blocked line is the Wallet refusing to go into an illegal state — encapsulation, visible. And the whole for loop drove three unrelated types through one pay call — polymorphism, visible. Four pillars, one demo.
The interview corner
OOP questions are the warm-up in almost every low-level-design round, and they are secretly a filter: anyone can recite four pillars, but the interviewer is listening for whether you know when each one is the wrong choice. Walk in ready to defend a design, not to define a word.
Ask these before you start modelling:
- "What are the real invariants — what must never be true of this object?" That question decides what gets encapsulated. If nothing can ever be inconsistent, you may not need a class at all; if money or state transitions are involved, the invariant is the whole point.
- "Which behaviours vary independently, and might they need to change at runtime?" Independent, swappable behaviours (retry, fraud, formatting) are a giant flashing sign for composition, not a subclass tree.
- "Is this genuinely an 'is-a', or am I just reaching for a parent to reuse its code?" If the honest answer is "I want that code," you want a part, not a parent.
The follow-up ladder — where a strong candidate keeps climbing:
- "Interface or abstract class — which, and why?" An interface is a pure contract with no state, and a class can implement many — reach for it to say "these unrelated types can all be paid with." An abstract class can hold fields and shared method bodies but you get only one — reach for it only when subclasses share real implementation, not just a shape. Default to the interface; it keeps you on composition's side of the fence.
- "You have three retry behaviours and two fraud behaviours — model it." Not six subclasses. Two parts — a
RetryPolicyand aFraudCheck— composed into oneCardPayment. This is the Strategy pattern; naming it out loud scores points (design patterns). - "How do you add a new payment method without touching existing code?" Add a class implementing
PaymentMethod; the checkout, the loop, everything else is untouched. That's the Open/Closed Principle, cashed by polymorphism, and it's exactly what SOLID is built to protect. - "Where would inheritance actually be correct here?" Implementing the
PaymentMethodinterface is inheritance of type — the safe kind. A concrete base class is right only for a true, stable, substitutable "is-a"; name the Liskov test as your check. - "This object is shared across threads — now what?" Encapsulation is the pre-condition for thread safety: you can only put a lock around state you fully control, and a public mutable field can't be guarded. Private state behind synchronized methods is the first step — the same reasoning behind the thread-safe LRU cache and multithreading.
Three mistakes that fail the round:
- Modelling "is-a" from English, not from substitutability. "A square is a rectangle," "a stack is a vector," "an admin is a user" — all true in prose, all landmines in code. The test is never the sentence; it's whether the child is usable everywhere the parent is, with no broken promise.
- Public fields with getters and setters for everything. A
getBalance/setBalancepair on a public-ish field isn't encapsulation — it's a public field with two extra steps. Real encapsulation exposes operations (debit,credit) that guard an invariant, not raw field access dressed up. - A
switchon type where polymorphism belongs.if (method.type == CARD) … else if (method.type == UPI) …is the anti-pattern the whole article exists to kill. Every branch is a class that wants to exist, and every new type means editing that ladder instead of adding a file.
Where to go from here
You now own the spine of every object-oriented design: hide state behind methods that guard an invariant (encapsulation), depend on a contract not a class (abstraction), share a type only for a true substitutable is-a (inheritance), let one call site serve many runtime types (polymorphism) — and when in doubt, hold a part instead of extending a parent (composition). Three natural next stops:
- SOLID design principles — five rules that turn "favour composition" and "code to a contract" into a checklist you can apply to any class. They're the grammar; the four pillars are the alphabet.
- Design patterns — Strategy, Decorator, Factory, Observer and friends are all just composition applied to a recurring problem. Once you see
CardPaymentholding a swappableRetryPolicy, you've already met Strategy; the rest are variations on "hold a part." - A real machine-coding problem end to end — take these pillars into a full vending machine or elevator design and watch encapsulated state, an interface per role, and composed parts turn a blank page into a clean class diagram under interview pressure.
Next time you press one button on a machine and grounds come out, you'll know you just used an object: a sealed shell, a public button, and a motor that's none of your business — which is exactly how good code is built, one appliance at a time.