The short version
- An object bundles data with the rules for changing it. Encapsulation means nobody can break those rules from outside, including through a getter that hands out the inside.
- Abstraction means callers depend on what something does, not how. In Java, that’s usually an interface.
- Inheritance reuses code but ties a child to its parent’s inner workings. A subclass that counted added items reported 6 instead of 3 because of how its parent was written.
- Polymorphism lets one call run different code depending on the object. Overriding is decided at runtime; overloading at compile time, which surprises people.
- Prefer composition: build objects from smaller parts. Make value objects immutable, and always pair equals with hashCode.
Before we start
Think about a wallet in an app like Paytm or Google Pay. It has some data: an owner and a balance. And it has rules: you can’t spend more than you have, amounts must be positive, and every change is recorded. An object is exactly that: data, plus the rules for changing it, kept together in one place.
This chapter covers:
| Idea | One line | The bug it prevents |
|---|---|---|
| Classes and objects | A class is a blueprint; objects are things built from it | — |
| Encapsulation | Only the object changes its own data | A balance set to −₹500 from outside |
| Abstraction | Depend on what, not how | Code that breaks when you switch payment providers |
| Inheritance | A child reuses and extends its parent | — (it causes one; see below) |
| Polymorphism | One call, many behaviours | Long if/else chains on types |
| Composition | Build objects from parts | The fragile parent problem |
| Interfaces vs abstract classes | Contracts vs partial implementations | Choosing the wrong tool |
| Equality | Same value or same object? | A set holding the same customer twice |
| Immutability | Objects that can’t change after creation | Shared data changing under your feet |
Classes and objects
A class is a blueprint: it says what data each object will hold and what it can do. An object is one thing built from that blueprint, with its own data.
Every object has three things:
- State: its current data (Asha’s wallet has ₹500).
- Behaviour: what it can do (spend, add money).
- Identity: which one it is. Two wallets can both hold ₹500 and still be different wallets.
That third point matters more than it sounds. Two Wallet objects with the same balance are equal in value but not the same object. We’ll come back to that in the equality section, because mixing the two up causes real bugs.
Encapsulation: protect the rules
The problem
class OpenWallet {
public long balancePaise; // anyone can change this
}Any code anywhere can write wallet.balancePaise = -50000. The rule “you can’t go below zero” exists only in people’s heads. And when a negative balance appears in production, there’s no single place to look: any line in the program could have done it.
The idea
Make the data private and allow changes only through methods that enforce the rules. Now there is exactly one place where the balance changes, and it always checks.
The code
final class Wallet {
private final String owner;
private long balancePaise;
private final List history = new ArrayList<>();
Wallet(String owner) { this.owner = owner; }
void add(long paise) {
if (paise <= 0) throw new IllegalArgumentException("amount must be positive");
balancePaise += paise;
history.add("+" + paise);
}
boolean spend(long paise) {
if (paise <= 0) throw new IllegalArgumentException("amount must be positive");
if (paise > balancePaise) return false; 1
balancePaise -= paise;
history.add("-" + paise);
return true;
}
long balance() { return balancePaise; }
List historyLeaky() { return history; } 2
List history() { return List.copyOf(history); } 3
}
class EncapsulationDemo {
public static void main(String[] args) {
Wallet w = new Wallet("asha");
w.add(500_00);
System.out.println("spend 700: " + w.spend(700_00) + ", balance " + w.balance());
w.historyLeaky().clear(); // someone "just tidying up"
System.out.println("after clearing the leaky list: history = " + w.history());
w.spend(100_00);
try { w.history().clear(); }
catch (UnsupportedOperationException e) {
System.out.println("copy can't be changed");
}
System.out.println("history still = " + w.history());
}
} - 1the rule, in one place
- 2hands out the inside
- 3hands out a copy
- The rule lives in one method. Nobody can bypass it.
- A getter that returns the real list lets callers change the object’s insides without any method being called. Encapsulation is broken, quietly.
- Returning an unmodifiable copy keeps the inside private.
spend 700: false, balance 50000
after clearing the leaky list: history = []
copy can't be changed
history still = [-10000]The leaky getter let outside code erase the wallet’s history, with no method on Wallet ever being called. That’s the most common way encapsulation breaks in real code: not public fields, but getters that hand out mutable insides.
What to remember
- Private fields, and methods that enforce rules.
- No setter for things that shouldn’t change freely.
setBalancewould undo everything. - Getters return copies or unmodifiable views of collections.
- An invariant is a rule that must always be true (“balance is never negative”). Encapsulation is how you keep invariants true.
Abstraction: depend on what, not how
The problem
A checkout charges customers by UPI. If the checkout code calls a specific UPI library directly, then adding cards means editing checkout, and testing checkout means calling a real UPI service.
The idea
Define what a payment method does (an interface), and write checkout against that. How each method works is hidden inside its own class.
The code
interface PaymentMethod {
String charge(long paise);
}
final class Upi implements PaymentMethod {
private final String vpa;
Upi(String vpa) { this.vpa = vpa; }
public String charge(long paise) { return "UPI collect " + paise + " from " + vpa; }
}
final class Card implements PaymentMethod {
private final String last4;
Card(String last4) { this.last4 = last4; }
public String charge(long paise) { return "card *" + last4 + " charged " + paise; }
}
final class Checkout {
private final PaymentMethod method; // (1)
Checkout(PaymentMethod method) { this.method = method; }
String pay(long paise) { return method.charge(paise); }
}
class AbstractionDemo {
public static void main(String[] args) {
System.out.println(new Checkout(new Upi("asha@okbank")).pay(250_00));
System.out.println(new Checkout(new Card("4242")).pay(250_00));
System.out.println(new Checkout(p -> "test charge " + p).pay(250_00)); // (2)
}
}Checkoutcan’t tell UPI from a card, and doesn’t need to.- A test can pass a fake in one line. That’s the practical payoff of abstraction.
UPI collect 25000 from asha@okbank
card *4242 charged 25000
test charge 25000Abstraction and encapsulation are often confused. Encapsulation hides data inside an object. Abstraction hides which implementation sits behind an interface. They work together, but they’re different ideas.
Inheritance: reuse, with strings attached
The idea
A class can extend another: it gets the parent’s fields and methods, can add its own, and can override methods to behave differently. Used well, it captures a real “is a” relationship with shared behaviour: a Rook is a Slider, in chess, and inherits the code for sliding.
The problem nobody mentions
Inheritance ties the child to how the parent is written, not just to what it does. Here’s a classic example (from Joshua Bloch’s Effective Java). We want a set that counts how many items were ever added to it, so we extend HashSet and count in both add methods:
class CountingSet extends HashSet {
int added;
@Override public boolean add(E e) { added++; return super.add(e); }
@Override public boolean addAll(Collection extends E> c) {
added += c.size();
return super.addAll(c);
}
}
class InheritanceDemo {
public static void main(String[] args) {
CountingSet s = new CountingSet<>();
s.addAll(List.of("tea", "coffee", "lassi"));
System.out.println("added 3 items, counter says " + s.added);
}
} added 3 items, counter says 6Six, not three. Inside the JDK, HashSet.addAll happens to work by calling add for each item, and add is our overridden version. So every item was counted twice. Nothing in HashSet‘s public description says addAll calls add; it’s an internal detail. A child class that extends a parent depends on those details, and if the parent changes them in a future version, the child breaks without either class changing.
This is called the fragile base class problem, and it’s the main reason for the next section.
When inheritance is right
- There’s a genuine “is a” relationship and shared behaviour (pieces in chess).
- The parent was designed to be extended: it says which methods subclasses may override, and what calls what.
abstractclasses with clear hooks, like the Template Method pattern in chapter 6, are designed this way. - You control both classes.
When only data differs, use a field, not a subclass. That’s the lesson of the vehicle trap in the parking lot and X and O in tic-tac-toe.
Polymorphism: one call, many behaviours
The idea
Polymorphism means one piece of code can work with objects of different classes, and each object responds in its own way. method.charge(paise) in the checkout above is polymorphic: the same line runs UPI code or card code depending on the object.
There are two kinds in Java, and they’re decided at different times:
| Kind | What it is | Decided |
|---|---|---|
| Overriding (runtime) | a subclass replaces a parent’s method | while running, by the object’s real class |
| Overloading (compile time) | several methods with the same name, different parameters | when compiling, by the variable’s declared type |
That difference surprises people. Look:
class Notice {
String describe() { return "a notice"; }
}
class Alarm extends Notice {
@Override String describe() { return "an alarm"; }
}
class Printer {
static String print(Notice n) { return "printing " + n.describe() + " as a notice"; }
static String print(Alarm a) { return "printing " + a.describe() + " as an ALARM"; }
}
class PolymorphismDemo {
public static void main(String[] args) {
Notice n = new Alarm(); // declared Notice, really Alarm
System.out.println(n.describe()); 1
System.out.println(Printer.print(n)); 2
System.out.println(Printer.print(new Alarm()));
}
}- 1overriding
- 2overloading
- Overriding is decided by the real object: it’s an
Alarm, so theAlarmversion runs. - Overloading is decided by the declared type:
nis declared asNotice, so the compiler picksprint(Notice), even though the object is an alarm.
an alarm
printing an alarm as a notice
printing an alarm as an ALARMThe middle line is the trap: the right describe(), but the wrong print. The rule to remember: use overriding when behaviour should depend on what the object really is. Don’t rely on overloading for that.
Why it matters for design
Polymorphism is what lets you replace a chain of if (type == X) checks with objects that each know what to do. Every strategy, state and observer in chapter 6 works because of it.
Prefer composition over inheritance
The idea
Instead of being a HashSet, our counting set can have one, and pass calls through to it. This is composition: building an object out of other objects.
final class CountingSet2 {
private final Set inner = new HashSet<>(); 1
private int added;
boolean add(E e) { added++; return inner.add(e); }
boolean addAll(Collection extends E> c) {
added += c.size();
return inner.addAll(c); // (2)
}
int added() { return added; }
int size() { return inner.size(); }
}
class CompositionDemo {
public static void main(String[] args) {
CountingSet2 s = new CountingSet2<>();
s.addAll(List.of("tea", "coffee", "lassi"));
System.out.println("added 3 items, counter says " + s.added());
}
} - 1has a set, isn’t one
- The counting set holds a
HashSetprivately. - When the inner set’s
addAllcalls its ownadd, that’s theHashSet‘sadd, not ours. Our counting can’t be fooled by the inner set’s details.
added 3 items, counter says 3Why it’s usually better
| Inheritance | Composition | |
|---|---|---|
| Coupling | to the parent’s internals | only to the part’s public methods |
| Changing at runtime | fixed when compiled | swap the part (a different strategy, a fake in tests) |
| Combining | one parent only | as many parts as you need |
| Exposing | everything public in the parent | only what you choose |
“Prefer composition” doesn’t mean “never inherit”. It means: reach for composition first, and inherit when there’s a real “is a” relationship with shared behaviour, ideally from a class designed for it.
Interfaces versus abstract classes
Both let you write code against something general. They’re for different jobs:
| Interface | Abstract class | |
|---|---|---|
| What it says | “can do”: a contract | “is a kind of”: a partial implementation |
| Fields | constants only | any fields |
| How many per class | many | one |
| Code inside | default and static methods | anything |
| Constructor | no | yes |
| Use when | unrelated classes share a capability (Comparable, PaymentMethod) | closely related classes share state and code (Slider in chess) |
A useful rule of thumb: start with an interface. Add an abstract class underneath it only when several implementations turn out to share real code. That way callers still depend on the small interface, never on the shared base.
Equality: same value or same object?
The problem
Two Customer objects for the same phone number should count as one customer. Put them in a Set and you’d expect one entry:
class LooseCustomer {
final String phone;
LooseCustomer(String phone) { this.phone = phone; }
@Override public boolean equals(Object o) { // (1)
return o instanceof LooseCustomer c && c.phone.equals(phone);
}
}
record Customer(String phone, String name) { } // (2)
class EqualityDemo {
public static void main(String[] args) {
LooseCustomer a = new LooseCustomer("9876543210");
LooseCustomer b = new LooseCustomer("9876543210");
Set loose = new HashSet<>(List.of(a));
loose.add(b);
System.out.println("a.equals(b) = " + a.equals(b)
+ ", set size = " + loose.size());
Customer c = new Customer("9876543210", "Asha");
Customer d = new Customer("9876543210", "Asha");
Set good = new HashSet<>(List.of(c));
good.add(d);
System.out.println("record: c.equals(d) = " + c.equals(d)
+ ", set size = " + good.size() + ", same object: " + (c == d));
}
} equalssays two customers with the same phone are equal, but there’s no matchinghashCode.- A record gets correct
equals,hashCodeandtoStringautomatically, based on all its fields.
a.equals(b) = true, set size = 2
record: c.equals(d) = true, set size = 1, same object: falseThe two loose customers are “equal”, yet the set holds both. A HashSet first uses hashCode to find the right bucket, and without an override, two different objects almost always get different hash codes. So it never even calls equals.
The rules:
==compares identity (the same object).equalscompares value.- *If you override
equals, overridehashCodetoo*, using the same fields. Equal objects must have equal hash codes. - For simple data classes, use a
recordand get both right for free. - Entities with an identity (an order with an id) usually compare by id; value objects (money, a seat, a square on a board) compare by all their fields.
Immutability: objects that can’t change
An immutable object can’t change after it’s created. That sounds limiting, but it removes whole kinds of bugs:
- Safe to share. You can hand it to any method or thread; nobody can change it under you.
- Safe as a map key. A key that changes after being put in a
HashMapgets lost in the wrong bucket. - Thread-safe for free. No locks needed to read something that never changes.
How to make one in Java:
record Money(long paise, String currency) {
Money {
if (paise < 0) throw new IllegalArgumentException("negative money");
Objects.requireNonNull(currency);
}
Money plus(Money other) { 1
if (!currency.equals(other.currency))
throw new IllegalArgumentException("currency");
return new Money(paise + other.paise, currency);
}
}
final class Basket {
private final List items;
Basket(List items) { this.items = List.copyOf(items); } // (2)
List items() { return items; } // already unmodifiable
}
class ImmutabilityDemo {
public static void main(String[] args) {
Money price = new Money(199_00, "INR");
Money total = price.plus(new Money(40_00, "INR"));
System.out.println(price + " -> " + total);
List mine = new ArrayList<>(List.of("bread"));
Basket b = new Basket(mine);
mine.add("butter"); // change the caller's list
System.out.println("basket still " + b.items());
}
} - 1returns a new object
- “Changing” an immutable object means creating a new one. The old one is untouched.
- Copy collections on the way in, or the caller can still change your object through the list they passed.
Money[paise=19900, currency=INR] -> Money[paise=23900, currency=INR]
basket still [bread]Records, final fields, List.copyOf and Map.copyOf do most of the work. Throughout the problem walkthroughs in this track, tickets, moves, bookings and decisions are all records for exactly these reasons.
Putting it together
Here’s how these ideas show up in a single small design: a wallet that supports several payment methods.
| Decision | Idea |
|---|---|
Wallet has a private balance and spend() checks it | Encapsulation |
Checkout takes a PaymentMethod, not a UPI class | Abstraction |
UPI and card each implement charge their own way | Polymorphism |
| A cashback rule is a separate object the wallet has | Composition |
Amounts are a Money record | Immutability, correct equality |
No PremiumWallet extends Wallet for a different fee | Composition over inheritance |
How this shows up in interviews
Interviewers rarely ask “define encapsulation”. They watch for it in your design:
- Public fields or setters for everything: encapsulation missed.
- A subclass for every variation: inheritance where a field or a strategy would do.
- *
instanceofchains:* polymorphism missed. - *Money as
double, or mutable keys in maps:* value objects missed.
Interviewer: Why didn’t you make PremiumCustomer extends Customer?
You: Because premium customers don’t behave differently in my design; they get a different discount. That’s data, or at most a different DiscountPolicy the customer has. If I used a subclass, a customer who upgrades would have to become a different object, and every place that stores a Customer would be affected. With composition, upgrading is changing one field.
Stay in the loop
Get new problems and walkthroughs by email
One short email when something new is published: a deep-dive walkthrough, an LLD problem, or a real outage retold. No spam, unsubscribe any time.