Why OOP is the language of LLD
Every low-level design answer is written in the vocabulary of object-oriented programming (OOP): a style of programming where you bundle data and the code that works on that data into units called objects. When an interviewer asks "why is this an interface and not an abstract class?" or "why did you use composition here?", they are testing whether you understand OOP as a set of design decisions, not just as Java syntax.
Most candidates can define the four pillars. Far fewer can explain what each pillar buys you in a real design, when inheritance becomes a trap, or why equals and hashCode must agree. Those are exactly the follow-up questions LLD interviewers ask. This lesson covers OOP from the design angle, with Java as the main language and Python where the contrast helps.
By the end you should be able to justify every structural choice in a class diagram: why a field is private, why a type is an enum, why one class holds another rather than extending it.
Classes and objects
A class is a blueprint: it declares what data (fields) and behaviour (methods) its instances will have. An object is one instance created from that blueprint, with its own values for the fields.
class BankAccount {
private final String accountNumber;
private long balancePaise;
BankAccount(String accountNumber, long openingBalancePaise) {
this.accountNumber = accountNumber;
this.balancePaise = openingBalancePaise;
}
void deposit(long amountPaise) {
if (amountPaise <= 0) {
throw new IllegalArgumentException("amount must be positive");
}
balancePaise += amountPaise;
}
void withdraw(long amountPaise) {
if (amountPaise <= 0 || amountPaise > balancePaise) {
throw new IllegalArgumentException("invalid amount");
}
balancePaise -= amountPaise;
}
long balancePaise() { return balancePaise; }
String accountNumber() { return accountNumber; }
}
class AccountDemo {
public static void main(String[] args) {
BankAccount a = new BankAccount("ACC-1", 10_000);
BankAccount b = new BankAccount("ACC-2", 0);
a.withdraw(2_500);
b.deposit(2_500);
System.out.println(a.balancePaise() + " " + b.balancePaise()); // 7500 2500
}
}
Two design habits are already visible:
- Money is stored as an integer in the smallest unit (paise, cents). Floating-point types like
doublecannot represent 0.1 exactly, so sums drift. Interviewers notice this.BigDecimalis the other correct choice. - The object protects its own rules. Nobody outside can set a negative balance because the only way to change it is through
depositandwithdraw, which validate input.
An object has three properties worth naming: state (current field values), behaviour (its methods) and identity (it is a distinct object even if another has identical state). Identity matters later when we discuss equals.
The four pillars, with design meaning
Encapsulation
Encapsulation means keeping an object's data private and exposing only operations that keep that data valid. The design benefit is that an object's invariants — rules that must always hold, like "balance is never negative" — are enforced in one place.
Compare two versions of a shopping cart:
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
// Leaky: any caller can add a null item or clear the list behind the cart's back.
class LeakyCart {
public List<String> items = new ArrayList<>();
}
// Encapsulated: the cart controls every change and hands out a read-only view.
class Cart {
private static final int MAX_ITEMS = 50;
private final List<String> items = new ArrayList<>();
void add(String sku) {
if (sku == null || sku.isBlank()) throw new IllegalArgumentException("sku required");
if (items.size() >= MAX_ITEMS) throw new IllegalStateException("cart full");
items.add(sku);
}
List<String> items() {
return Collections.unmodifiableList(items);
}
}
The getter returns an unmodifiable view, so callers can read but not mutate. Returning the raw internal list is a classic encapsulation leak that interviewers look for.
Common misconception
"Private fields with a public getter and setter for each one is encapsulation." It is not much better than public fields. A setter like setBalance(long) lets anyone bypass the deposit rules. Expose behaviour (deposit, withdraw), not raw setters.
Abstraction
Abstraction means exposing what something does while hiding how it does it. In design, abstraction appears as interfaces that describe a capability: PaymentGateway.charge(amount) hides whether the implementation calls one provider's API or another's.
The benefit is that callers depend on a small, stable contract. You can replace the implementation, or swap in a fake during tests, without touching callers. Encapsulation hides data inside one object; abstraction hides implementation choices behind a contract. They are related but not the same.
Inheritance
Inheritance lets a class (the subclass or child) reuse and extend another class (the superclass or parent). It models an is-a relationship: a SavingsAccount is a BankAccount.
abstract class Account {
protected long balancePaise;
void deposit(long amount) { balancePaise += amount; }
abstract long monthlyInterest();
}
class SavingsAccount extends Account {
private final int ratePerMilleMonthly; // e.g. 3 means 0.3% per month
SavingsAccount(int ratePerMilleMonthly) { this.ratePerMilleMonthly = ratePerMilleMonthly; }
long monthlyInterest() { return balancePaise * ratePerMilleMonthly / 1000; }
}
class CurrentAccount extends Account {
long monthlyInterest() { return 0; }
}
Inheritance gives code reuse and lets you treat all accounts uniformly. But it also creates the tightest possible coupling between two classes, which we will return to in "Composition vs inheritance".
Polymorphism
Polymorphism ("many forms") means one call can run different code depending on the actual object type at runtime. Calling account.monthlyInterest() runs the savings version or the current version without the caller checking the type.
This is the single most useful pillar for LLD, because it is how you eliminate if-else chains on type:
import java.util.List;
interface Shape {
double area();
}
record Circle(double r) implements Shape {
public double area() { return Math.PI * r * r; }
}
record Rect(double w, double h) implements Shape {
public double area() { return w * h; }
}
class ShapeDemo {
public static void main(String[] args) {
List<Shape> shapes = List.of(new Circle(1), new Rect(2, 3));
double total = 0;
for (Shape s : shapes) {
total += s.area(); // no instanceof, no type switch
}
System.out.printf("%.2f%n", total); // 9.14
}
}
Adding a Triangle means adding one new class; the loop never changes. Strictly, Java has two kinds of polymorphism: compile-time (method overloading, chosen by the compiler from argument types) and runtime (method overriding, chosen by the JVM from the object's actual class). When people say "polymorphism" in design they almost always mean runtime polymorphism.
| Pillar | One-line meaning | What it buys in a design |
|---|---|---|
| Encapsulation | Hide data, expose safe operations | Invariants enforced in one place |
| Abstraction | Expose what, hide how | Callers depend on stable contracts; easy swapping and testing |
| Inheritance | Reuse via is-a | Shared code and uniform treatment, at the cost of tight coupling |
| Polymorphism | Same call, different behaviour | New types added without editing callers |
Interview tip
When asked about the pillars, give each one a design consequence, not only a definition. "Polymorphism lets me add a new vehicle type as a new class without editing the parking service's if-else" is a much stronger answer than "polymorphism means many forms".
Interfaces vs abstract classes
An interface declares a set of methods a class promises to provide. An abstract class is a class that cannot be instantiated directly and may contain both implemented methods and abstract (unimplemented) ones, plus fields with state.
Since Java 8, interfaces can also hold default methods (methods with a body that implementing classes inherit unless they override) and static methods. Java 9 added private interface methods. This blurred the line, so interviewers ask what difference remains.
interface Notifier {
void send(String to, String message);
// Default method: added later without breaking existing implementations.
default void sendAll(Iterable<String> recipients, String message) {
for (String r : recipients) {
send(r, message);
}
}
}
abstract class RetryingNotifier implements Notifier {
private final int maxAttempts; // state: only possible in a class
protected RetryingNotifier(int maxAttempts) { this.maxAttempts = maxAttempts; }
public final void send(String to, String message) {
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
if (trySend(to, message)) return;
}
throw new IllegalStateException("failed after " + maxAttempts + " attempts");
}
protected abstract boolean trySend(String to, String message);
}
class SmsNotifier extends RetryingNotifier {
SmsNotifier() { super(3); }
protected boolean trySend(String to, String message) {
System.out.println("SMS to " + to + ": " + message);
return true;
}
}
| Interface | Abstract class | |
|---|---|---|
| Instance fields (state) | No (only static final constants) | Yes |
| Constructors | No | Yes |
| Method bodies | default, static, private (Java 8/9+) | Any |
| A class can extend/implement | Many interfaces | Only one class |
| Access modifiers on methods | Public (or private helpers) | Any |
| Best for | A capability or role ("can notify", "is comparable") | A partial implementation shared by closely related classes |
The practical rule: start with an interface. Add an abstract class underneath it when several implementations share real code and state, as RetryingNotifier does. Callers still depend on the interface, so you keep flexibility.
Default methods exist mainly to evolve interfaces without breaking existing implementers — Java used them to add forEach and stream to collection interfaces. Using them as a back door for multiple inheritance of behaviour is possible but can cause conflicts: if a class implements two interfaces with the same default method, it must override it and choose explicitly (for example A.super.method()).
Composition vs inheritance
Composition means building a class by holding references to other objects and delegating work to them. It models a has-a relationship: a Car has an Engine.
The famous advice is favour composition over inheritance. Here is why, through two concrete failure modes.
Failure 1: the fragile base class
The fragile base class problem occurs when a harmless-looking change in a parent class breaks a subclass, because the subclass depended on the parent's internal details. A classic Java example extends HashSet to count insertions:
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
class CountingSet<E> extends HashSet<E> {
int added = 0;
@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); // HashSet.addAll calls add() for each element
}
}
class FragileDemo {
public static void main(String[] args) {
CountingSet<String> s = new CountingSet<>();
s.addAll(List.of("a", "b", "c"));
System.out.println(s.added); // 6, not 3
}
}
HashSet.addAll (inherited from AbstractCollection) internally calls add for each element, so every element is counted twice. The subclass depended on an implementation detail the parent never promised. If a future JDK changed that detail, the count would change again.
The composition fix wraps a set instead of extending one:
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
class CountingSet2<E> {
private final Set<E> inner = new HashSet<>();
private int added = 0;
boolean add(E e) {
added++;
return inner.add(e);
}
boolean addAll(Collection<? extends E> c) {
added += c.size();
return inner.addAll(c); // calls inner's add, not ours
}
int added() { return added; }
}
class CompositionDemo {
public static void main(String[] args) {
CountingSet2<String> s = new CountingSet2<>();
s.addAll(List.of("a", "b", "c"));
System.out.println(s.added()); // 3
}
}
In a real design you would usually also implement Set<E> and forward every method, which is called a forwarding or wrapper class — the idea behind the Decorator pattern in the structural patterns lesson.
Failure 2: breaking Liskov substitution
The Liskov substitution principle (LSP) says that anywhere code expects a parent type, passing a subclass must not break that code's expectations. The textbook violation is a Square extending Rectangle:
class Rectangle {
protected int w, h;
void setWidth(int w) { this.w = w; }
void setHeight(int h) { this.h = h; }
int area() { return w * h; }
}
class Square extends Rectangle {
@Override void setWidth(int w) { this.w = w; this.h = w; }
@Override void setHeight(int h) { this.w = h; this.h = h; }
}
class LspDemo {
static int stretch(Rectangle r) {
r.setWidth(5);
r.setHeight(4);
return r.area(); // caller reasonably expects 20
}
public static void main(String[] args) {
System.out.println(stretch(new Rectangle())); // 20
System.out.println(stretch(new Square())); // 16
}
}
Mathematically a square is a rectangle, but behaviourally a mutable square cannot honour the rectangle's contract that width and height change independently. The lesson: "is-a" must hold for behaviour, not just for the real-world category. The SOLID principles lesson goes deeper.
When inheritance is the right tool
Inheritance is fine when all of these hold: there is a true behavioural is-a relationship; the parent is designed for extension (documented hooks, or abstract methods); and both classes are under your control. Template-method style abstract classes, like RetryingNotifier above, are a healthy use.
| Question | If yes, lean towards |
|---|---|
| Does the child need to be usable everywhere the parent is, with no surprises? | Inheritance (or an interface) |
| Do you only want to reuse some code? | Composition |
| Do you need to change behaviour at runtime? | Composition |
| Would you need to inherit from two classes? | Composition plus interfaces |
| Is the parent from a library you do not control? | Composition |
Interview tip
A crisp answer: "Inheritance couples the subclass to the parent's implementation, so parent changes can break it, and it is fixed at compile time. Composition couples only to the collaborator's public interface and can be swapped at runtime. I use inheritance for genuine behavioural is-a relationships and composition for reuse."
Association, aggregation and composition
These three words describe how strongly two objects are linked. They map directly to UML arrows, covered in the UML lesson.
- Association: a general "uses" or "knows about" relationship. Both objects live independently. A
DoctortreatsPatients; neither owns the other. - Aggregation: a "has-a" where the whole contains parts, but the parts can exist without the whole and may be shared. A
TeamhasPlayers; if the team is disbanded, the players still exist. - Composition: a strong "owns" relationship; the parts are created and destroyed with the whole and are not shared. A
HousehasRooms; demolish the house and the rooms are gone.
import java.util.ArrayList;
import java.util.List;
class Player {
final String name;
Player(String name) { this.name = name; }
}
// Aggregation: players are created elsewhere and passed in; they outlive the team.
class Team {
private final List<Player> players = new ArrayList<>();
void addPlayer(Player p) { players.add(p); }
}
// Composition: rooms are created inside the house and never handed out for reuse.
class House {
private final List<Room> rooms = new ArrayList<>();
House(int roomCount) {
for (int i = 1; i <= roomCount; i++) {
rooms.add(new Room("Room " + i));
}
}
int roomCount() { return rooms.size(); }
private static class Room {
final String label;
Room(String label) { this.label = label; }
}
}
// Association: a method-level or field-level link with no ownership.
class Doctor {
String treat(Player patient) { return "treated " + patient.name; }
}
In code, the clue is who creates and who can share the part. If the whole creates the part and never exposes it, it is composition. If the part is passed in and could be shared, it is aggregation. In Java, garbage collection means lifetimes are not strictly enforced, so these distinctions are about design intent rather than memory management.
Immutability and value objects
An immutable object cannot change after construction. A value object is an object defined entirely by its values, with no identity: two Money(500, "INR") objects are interchangeable, just as two ₹500 notes are for paying a bill. Compare with an entity, such as a User, which keeps its identity even when its name changes.
Immutable objects are valuable in LLD because they are:
- Thread-safe for free: no thread can change them, so they can be shared without locks.
- Safe as map keys: their hash code never changes after insertion.
- Easy to reason about: no method call can surprise you by modifying them.
Rules for an immutable class in Java: make the class final (so no subclass adds mutable state), make every field private final, provide no setters, and make defensive copies of mutable inputs and outputs (such as lists or dates).
import java.util.List;
import java.util.Objects;
final class Money {
private final long amountMinor; // paise or cents
private final String currency;
Money(long amountMinor, String currency) {
if (amountMinor < 0) throw new IllegalArgumentException("negative amount");
this.amountMinor = amountMinor;
this.currency = Objects.requireNonNull(currency);
}
Money plus(Money other) {
if (!currency.equals(other.currency)) throw new IllegalArgumentException("currency mismatch");
return new Money(amountMinor + other.amountMinor, currency); // new object, no mutation
}
long amountMinor() { return amountMinor; }
String currency() { return currency; }
@Override public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Money m)) return false;
return amountMinor == m.amountMinor && currency.equals(m.currency);
}
@Override public int hashCode() { return Objects.hash(amountMinor, currency); }
@Override public String toString() { return currency + " " + amountMinor; }
}
final class Order {
private final List<String> skus;
Order(List<String> skus) {
this.skus = List.copyOf(skus); // defensive copy: caller's later changes don't leak in
}
List<String> skus() { return skus; } // List.copyOf is already unmodifiable
}
Java 16+ records generate the constructor, accessors, equals, hashCode and toString for you, which makes them ideal for value objects: record Money(long amountMinor, String currency) {}. Records are shallowly immutable — if a component is a mutable list, you still need a defensive copy in a compact constructor.
In Python, @dataclass(frozen=True) gives a similar result:
from dataclasses import dataclass
@dataclass(frozen=True)
class Money:
amount_minor: int
currency: str
def plus(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError("currency mismatch")
return Money(self.amount_minor + other.amount_minor, self.currency)
a = Money(500, "INR")
print(a == Money(500, "INR"), hash(a) == hash(Money(500, "INR"))) # True True
try:
a.amount_minor = 1
except Exception as e:
print(type(e).__name__) # FrozenInstanceError
equals and hashCode
Java's Object.equals by default compares identity (same object in memory). For value objects you override it to compare values. The contract that trips candidates up: if two objects are equal by equals, they must return the same hashCode. Hash-based collections (HashMap, HashSet) first use the hash code to pick a bucket, then use equals within the bucket. Break the contract and lookups silently fail.
import java.util.HashSet;
import java.util.Set;
class BrokenPoint {
final int x, y;
BrokenPoint(int x, int y) { this.x = x; this.y = y; }
@Override public boolean equals(Object o) {
return o instanceof BrokenPoint p && p.x == x && p.y == y;
}
// hashCode NOT overridden: equal points usually land in different buckets
}
class EqualsDemo {
public static void main(String[] args) {
Set<BrokenPoint> set = new HashSet<>();
set.add(new BrokenPoint(1, 2));
System.out.println(set.contains(new BrokenPoint(1, 2))); // almost always false
}
}
The full equals contract requires it to be reflexive (a.equals(a)), symmetric (a.equals(b) iff b.equals(a)), transitive, consistent across calls, and a.equals(null) must be false. Two practical rules follow:
- Base
equalsandhashCodeon the same fields, ideally immutable ones. - For entities, compare by a stable ID, not by all fields. Two
Userobjects with the same ID are the same user even if one has a newer email.
Common mistake
Putting a mutable object in a HashSet and then changing a field used in hashCode. The object now sits in the wrong bucket; contains returns false and remove fails, even though the object is "in" the set. Use immutable keys.
Enums with behaviour
A Java enum is a class with a fixed set of instances. Beyond naming constants, enums can hold fields, constructors and methods — even a different method body per constant. This often replaces a switch statement scattered across the codebase.
enum VehicleType {
BIKE(2, 1000),
CAR(4, 3000),
TRUCK(6, 8000);
private final int wheels;
private final long hourlyRatePaise;
VehicleType(int wheels, long hourlyRatePaise) {
this.wheels = wheels;
this.hourlyRatePaise = hourlyRatePaise;
}
long feeFor(int hours) {
return Math.max(1, hours) * hourlyRatePaise;
}
int wheels() { return wheels; }
}
enum Operation {
ADD { long apply(long a, long b) { return a + b; } },
MULTIPLY { long apply(long a, long b) { return a * b; } };
abstract long apply(long a, long b);
}
class EnumDemo {
public static void main(String[] args) {
System.out.println(VehicleType.CAR.feeFor(3)); // 9000
System.out.println(VehicleType.BIKE.feeFor(0)); // 1000 (minimum one hour)
System.out.println(Operation.MULTIPLY.apply(6, 7)); // 42
}
}
When to use an enum with behaviour vs a full interface with classes:
- Enum: the set of types is closed and small, and each type's behaviour is a few lines (vehicle types and rates, order statuses, directions).
- Interface plus classes: types may be added by other teams or plugins, or each type needs substantial logic and dependencies (payment methods calling external APIs).
Note that adding a constant to an enum does edit the enum file, so an enum is not fully open for extension. For an interview, it is a reasonable trade-off as long as you say so.
Access modifiers
Java has four access levels. The design rule is the most restrictive level that works.
| Modifier | Same class | Same package | Subclass (other package) | Everywhere |
|---|---|---|---|---|
private | Yes | No | No | No |
| (none) package-private | Yes | Yes | No | No |
protected | Yes | Yes | Yes | No |
public | Yes | Yes | Yes | Yes |
- Fields: almost always
private. protectedfields expose internals to every subclass, recreating the fragile base class problem. Preferprotectedmethods (hooks) overprotectedfields.- Package-private is underrated: it lets classes in one package collaborate while hiding them from the rest of the codebase.
Python has no enforced access modifiers. A leading underscore (_balance) is a convention meaning "internal"; a double underscore (__balance) triggers name mangling, which discourages but does not prevent access.
Overloading vs overriding
Overloading: several methods in the same class share a name but differ in parameter lists. The compiler picks one at compile time from the declared argument types.
Overriding: a subclass provides its own version of a method with the same signature as the parent. The JVM picks one at runtime from the object's actual class.
class Printer {
String print(Object o) { return "object"; }
String print(String s) { return "string"; } // overload
}
class Animal {
String sound() { return "..."; }
}
class Dog extends Animal {
@Override String sound() { return "woof"; } // override
}
class DispatchDemo {
public static void main(String[] args) {
Printer p = new Printer();
Object o = "hello";
System.out.println(p.print(o)); // object: overload chosen by DECLARED type
System.out.println(p.print("hello")); // string
Animal a = new Dog();
System.out.println(a.sound()); // woof: override chosen by ACTUAL type
}
}
The first line surprises many candidates: o really holds a String, but its declared type is Object, and overload resolution happens at compile time.
| Overloading | Overriding | |
|---|---|---|
| Where | Same class (or inherited) | Subclass |
| Signature | Different parameters | Same parameters (covariant return allowed) |
| Resolved | Compile time | Runtime |
static/private methods | Can be overloaded | Cannot be overridden (static methods are hidden) |
| Access | Any | Cannot be more restrictive than parent |
Always use @Override; the compiler then catches mistakes such as overloading equals(Point p) when you meant to override equals(Object o).
Dependency injection basics
Dependency injection (DI) means a class receives the objects it depends on from outside, instead of creating them itself. The simplest and best form is constructor injection.
interface PaymentGateway {
boolean charge(String userId, long amountPaise);
}
interface Clock {
long nowMillis();
}
// Without DI the service would call `new SomeGatewayClient()` and System.currentTimeMillis()
// directly, making it impossible to test without a real gateway and real time.
class CheckoutService {
private final PaymentGateway gateway;
private final Clock clock;
CheckoutService(PaymentGateway gateway, Clock clock) {
this.gateway = gateway;
this.clock = clock;
}
String checkout(String userId, long amountPaise) {
boolean ok = gateway.charge(userId, amountPaise);
return (ok ? "PAID" : "FAILED") + " at " + clock.nowMillis();
}
}
class DiDemo {
public static void main(String[] args) {
PaymentGateway alwaysOk = (user, amount) -> true; // fake for tests
Clock fixed = () -> 1_000L;
CheckoutService s = new CheckoutService(alwaysOk, fixed);
System.out.println(s.checkout("u1", 49_900)); // PAID at 1000
}
}
Benefits: tests pass fakes, production can swap implementations, and the constructor makes every dependency visible. Frameworks like Spring automate the wiring, but in an interview you do it by hand in main — this is sometimes called the composition root, the one place where concrete classes are chosen and connected.
Other forms exist — setter injection (dependency set after construction, useful only for optional dependencies) and field injection (frameworks set private fields via reflection, which hides dependencies and is discouraged in plain code).
Interview tip
Time is a dependency too. Code that calls LocalDateTime.now() directly cannot be tested for "parking fee after 3 hours" without waiting 3 hours. Injecting a Clock (Java has java.time.Clock built in) is a small move that impresses reviewers.
Putting it together: a tiny library model
A short example combining the ideas: a value object, an entity with an ID-based equals, an enum with behaviour, composition and injection.
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
enum MemberTier {
BASIC(2), PREMIUM(5);
final int maxLoans;
MemberTier(int maxLoans) { this.maxLoans = maxLoans; }
}
record Isbn(String value) {
Isbn {
if (value == null || value.length() != 13) throw new IllegalArgumentException("ISBN-13 required");
}
}
final class Member {
private final String id;
private final MemberTier tier;
private int activeLoans;
Member(String id, MemberTier tier) { this.id = id; this.tier = tier; }
boolean canBorrow() { return activeLoans < tier.maxLoans; }
void recordLoan() { activeLoans++; }
@Override public boolean equals(Object o) { return o instanceof Member m && m.id.equals(id); }
@Override public int hashCode() { return Objects.hash(id); }
}
interface Catalog {
boolean isAvailable(Isbn isbn);
void markBorrowed(Isbn isbn);
}
class InMemoryCatalog implements Catalog {
private final Map<Isbn, Integer> copies = new HashMap<>();
void addCopies(Isbn isbn, int n) { copies.merge(isbn, n, Integer::sum); }
public boolean isAvailable(Isbn isbn) { return copies.getOrDefault(isbn, 0) > 0; }
public void markBorrowed(Isbn isbn) { copies.merge(isbn, -1, Integer::sum); }
}
class LendingService {
private final Catalog catalog; // composition + injection
LendingService(Catalog catalog) { this.catalog = catalog; }
boolean lend(Member m, Isbn isbn) {
if (!m.canBorrow() || !catalog.isAvailable(isbn)) return false;
catalog.markBorrowed(isbn);
m.recordLoan();
return true;
}
}
class LibraryDemo {
public static void main(String[] args) {
InMemoryCatalog catalog = new InMemoryCatalog();
Isbn book = new Isbn("9780134685991");
catalog.addCopies(book, 1);
LendingService service = new LendingService(catalog);
Member asha = new Member("M1", MemberTier.BASIC);
System.out.println(service.lend(asha, book)); // true
System.out.println(service.lend(asha, book)); // false: no copies left
}
}
Each choice has a reason you can say aloud: Isbn is a validated value object so an invalid ISBN cannot exist; Member compares by ID because it is an entity; the tier's limit lives in the enum; LendingService depends on the Catalog interface so it can be tested against an in-memory version.
Interview questions
Q1. What are the four pillars of OOP, and what does each give you in a design?
Encapsulation hides data behind operations so invariants are enforced in one place. Abstraction exposes contracts that hide implementation so callers can work with any implementation. Inheritance reuses code through is-a relationships at the cost of tight coupling. Polymorphism lets one call run different code per type, which removes type checks and lets new types be added without editing callers.
Q2. Interface or abstract class — how do you choose?
Start with an interface to define a capability, because a class can implement many and callers stay decoupled. Use an abstract class when closely related implementations share real code and state, such as a retry loop with a configured attempt count. Often you use both: callers depend on the interface, and an abstract class implements it as a convenient base.
Q3. Why were default methods added to Java interfaces, and what problem can they cause?
They let library authors add methods to existing interfaces without breaking every implementation, as Java 8 did with Collection.stream(). The risk is the "diamond" conflict: if a class implements two interfaces that both provide the same default method, the class must override it and choose, for example with A.super.method().
Q4. Why favour composition over inheritance?
Inheritance binds a subclass to its parent's implementation details, so parent changes can break it (the fragile base class problem), and it is fixed at compile time. Composition depends only on the collaborator's public interface and can be swapped at runtime, including with a fake in tests. I still use inheritance for true behavioural is-a relationships with parents designed for extension.
Q5. Explain the fragile base class problem with an example.
Subclassing HashSet to count additions by overriding both add and addAll double-counts, because the inherited addAll calls add internally. The subclass relied on a detail the parent never documented as a contract. Wrapping a set and delegating (composition) avoids this because the wrapper's addAll calls the inner set, not itself.
Q6. Is a Square a Rectangle?
Mathematically yes, but if Rectangle has independent setWidth and setHeight, a Square subclass cannot honour that behaviour, so code that sets width 5 and height 4 expects area 20 and gets 16. That violates the Liskov substitution principle. Fixes include making shapes immutable or having both implement a common Shape interface without inheriting from each other.
Q7. Differentiate association, aggregation and composition.
Association is any link where objects use each other but live independently, like a doctor and patient. Aggregation is a whole-part relationship where parts can outlive the whole and be shared, like players in a team. Composition is ownership where parts are created and destroyed with the whole and not shared, like rooms in a house.
Q8. How do you make a class immutable in Java?
Make the class final, all fields private final, set everything in the constructor, provide no setters, and defensively copy any mutable inputs and outputs such as lists. Operations that "change" the object return a new instance. Records do most of this automatically, but a record holding a mutable list still needs a defensive copy.
Q9. What is the contract between equals and hashCode, and what breaks if you violate it?
Equal objects must have equal hash codes. Hash-based collections locate a bucket by hash code and then compare with equals, so if two equal objects hash differently, contains and get miss entries that are logically present. Base both methods on the same, preferably immutable, fields.
Q10. How should equals work for an entity versus a value object?
A value object is defined by all its values, so equals compares every field. An entity has identity that survives changes to its attributes, so equals compares a stable identifier such as a user ID. Comparing entities by all fields would make a user "not equal to themselves" after updating their email.
Q11. Overloading vs overriding — which is resolved when?
Overloading is resolved at compile time using the declared types of the arguments. Overriding is resolved at runtime using the actual class of the receiver object. So calling print(o) where o is declared Object but holds a String picks the Object overload.
Q12. When would you use an enum with behaviour instead of a class hierarchy?
When the set of variants is small and closed and each variant's logic is short, such as vehicle types with rates or directions with movement deltas. Enums give a fixed set of singletons, exhaustive switch support and no risk of an invalid value. If variants may grow from outside or need heavy logic and dependencies, an interface with classes is better.
Q13. What is dependency injection and why does it matter in LLD?
A class receives its collaborators through its constructor instead of creating them. This makes dependencies explicit, lets tests pass fakes, and lets the application swap implementations without editing the class. In an interview I wire everything in main, which acts as the composition root.
Q14. Why should money not be stored as a double?
Binary floating point cannot represent most decimal fractions exactly, so 0.1 + 0.2 is not exactly 0.3 and totals drift across many operations. Store money as an integer number of the smallest unit (paise or cents) or use BigDecimal with an explicit rounding mode. Wrapping it in a Money value object also carries the currency.
Key takeaways
- Explain each OOP pillar by what it buys in a design, not just its definition.
- Encapsulate behaviour, not just fields; avoid setters that bypass rules and never leak mutable internals.
- Prefer interfaces for contracts; add an abstract class underneath only to share real code and state.
- Favour composition; inheritance breaks through fragile base classes and Liskov violations.
- Composition means the whole owns and creates its parts; aggregation means parts are passed in and can be shared.
- Make value objects immutable and give them value-based
equalsandhashCode; compare entities by ID. - Enums can carry data and behaviour and often replace scattered
switchstatements. - Inject dependencies, including time, through constructors so classes are testable and swappable.
Next lesson
Continue with SOLID principles.

