Course Content
Object-Oriented Design Interview
14 sections · 29 lessons
Encapsulation, abstraction, inheritance and polymorphism
The four classic ideas of object-oriented programming are easy to define and easy to misuse. In this round none of them is scored as a definition; each is scored as a practical test you apply to a class on the whiteboard. This lesson takes them one at a time, each with the naive version that breaks, the repair, and the case where the repair is overkill.
Encapsulation
Encapsulation means a caller can change an object's state only through methods the object chose to expose — so the object can guarantee it is never in an invalid state.
The practical test
Forget the textbook definition. In this round, the test is one question:
Can a caller put this object into a state that should be impossible?
If yes, the encapsulation is broken, whatever the access modifiers say.
The naive version, and how it breaks
public class Account { public double balance; // public field}Now anywhere in the codebase:
account.balance = -5000; // an overdraft nobody authorisedaccount.balance += 100; // two threads doing this lose one depositaccount.balance = 0.1 + 0.2; // 0.30000000000000004 — money in a doubleThree separate bugs, and none of them is Account's fault, because Account has no say. The class has no rules; it is a box with a number in it. Every rule about balances now lives scattered across every caller, and adding a fourth rule means finding all of them.
The encapsulated version
1public class Account {2 private final String id;3 private Money balance; // Money wraps a long of minor units — no floats4 private Money overdraftLimit;56 public void withdraw(Money amount) {7 if (amount.isNegativeOrZero()) throw new IllegalArgumentException("amount must be positive");8 Money after = balance.minus(amount);9 if (after.isLessThan(overdraftLimit.negated())) throw new InsufficientFundsException(id);10 balance = after;11 }1213 public void deposit(Money amount) {14 if (amount.isNegativeOrZero()) throw new IllegalArgumentException("amount must be positive");15 balance = balance.plus(amount);16 }1718 public Money getBalance() { return balance; } // read-only: Money is immutable19}Now the invalid states are unreachable. There is no path to a balance below the overdraft limit, no path to a negative deposit, and no floating-point money. The rules live in one place, so a fourth rule is a change to one file.
Two subtleties interviewers probe
A getter that returns a mutable collection breaks encapsulation. getSpots() returning the live List<ParkingSpot> lets any caller call .clear() on your parking lot. Return Collections.unmodifiableList(spots), or better, expose the behaviour instead: lot.findAvailable(size) rather than lot.getSpots().
A setter for every field is the same as a public field, with more typing. Generated setters are how a private field ends up with no rules attached to it. Add a setter only when that specific mutation is a real operation with a real name — and then name it after the operation: markOutOfService(), not setStatus().
Abstraction
Abstraction here means one specific practice: callers depend on an interface that describes what is done, never on a class that describes how.
Why this matters more here than anywhere else
Every design lesson in this course has one thing that changes more often than the rest of the system: pricing rules in the parking lot, the scheduling policy in the elevator, the allocation rule in the locker network, the player's decision logic in blackjack.
Abstraction is what lets those change without touching anything else. It is the mechanism behind the Strategy and State patterns (The seven patterns that actually appear), so understanding it once explains both.
The concrete version
Start with the naive design. A parking lot that computes its own fee:
1public class ParkingLot {2 public Money computeFee(Ticket ticket) {3 long hours = ticket.durationHours();4 if (ticket.getVehicleSize() == Size.MOTORCYCLE) return Money.rupees(20 * hours);5 if (ticket.getVehicleSize() == Size.CAR) return Money.rupees(50 * hours);6 return Money.rupees(100 * hours);7 }8}It works. Then the lot introduces a flat weekend rate, then a monthly pass, then a first-hour free promotion. Each one is another branch inside ParkingLot, and now the class that manages floors and spots also encodes the marketing department's current campaign.
The repair is one interface:
1public interface PricingStrategy {2 Money computeFee(Ticket ticket);3}45public class HourlyPricing implements PricingStrategy { /* per-size hourly rates */ }6public class FlatWeekendPricing implements PricingStrategy { /* one rate, any duration */ }7public class MonthlyPassPricing implements PricingStrategy { /* zero if pass is valid */ }ParkingLot now holds a PricingStrategy and calls computeFee. It has no idea which one it holds. A new pricing rule is a new file and one line where the lot is constructed.
What abstraction is not
It is not putting an interface in front of everything. An interface with exactly one implementation that will never have a second one is a file you have to open twice to read one method. Ticket does not need a TicketInterface.
The test, which is the same test used throughout this course:
What change does this interface make cheap, and is that change likely?
Pricing rules change — put them behind an interface. A ticket having an identifier and a timestamp does not change — leave it concrete.
Inheritance, and its limits
Inheritance lets a class take another class's fields and methods and add to them. It is the most over-used tool in this round and the source of most models that fall apart under the extension question.
When it genuinely applies
Three conditions, all required:
- It is a true is-a relationship, permanently. A
Motorcycleis aVehicleand will never stop being one. - The subclass can be used anywhere the superclass is expected, with no caller checking which one it has. This is the Liskov Substitution Principle from the SOLID reference.
- The shared part is genuinely shared behaviour, not coincidental similarity.
A clean example from the parking lot design:
1public abstract class Vehicle {2 private final String plate;3 public abstract VehicleSize getSize();4}5public class Motorcycle extends Vehicle {6 public VehicleSize getSize() { return VehicleSize.SMALL; }7}8public class Car extends Vehicle {9 public VehicleSize getSize() { return VehicleSize.MEDIUM; }10}One level deep, each subclass answering the same question differently. That is inheritance doing its job.
The fragile base class problem
Here is the failure. Suppose Vehicle grows a helper the subclasses use:
1public abstract class Vehicle {2 public Money fee(long hours) { return baseRate().times(hours); }3 protected abstract Money baseRate();4}Then someone adds electric vehicles, which charge for parking and for electricity, so ElectricCar overrides fee. Then someone changes Vehicle.fee to apply a cap. Every subclass that overrode fee silently loses the cap, and no compiler error is produced. The base class became fragile: a change inside it broke subclasses that were written correctly against the old behaviour.
This gets worse with depth. Vehicle → MotorVehicle → Car → ElectricCar → LeasedElectricCar means a change at the top can break behaviour four levels down, and reading any one class requires reading five files.
The classic wrong hierarchy
Account → SavingsAccount → InterestBearingSavingsAccount → PremiumInterestBearingSavingsAccountEvery new dimension — interest, overdraft, currency, fee waiver — multiplies the classes, because inheritance can express only one axis of variation. Four true/false features inheritance-modelled give sixteen leaf classes. Composition gives four fields.
The honest summary
Inheritance is right for a small, closed, permanent set of variants that differ in what they answer, one level deep. It is wrong for anything that varies along more than one axis, or that might change at runtime. An object cannot change its class after construction; it can change a field.
Polymorphism
Polymorphism is one call site producing different behaviour depending on which object is there. In this round it shows up as the single most common improvement an interviewer will suggest: replace a chain of if-else branches with a method call.
The problem it removes
Here is a notification sender written without it. Every design lesson in this course produces something with this shape if you are not careful.
1public class Notifier {2 public void send(Recipient r, String message) {3 if (r.getChannel() == Channel.EMAIL) {4 emailClient.send(r.getEmail(), "Notification", message);5 } else if (r.getChannel() == Channel.SMS) {6 smsGateway.send(r.getPhone(), message.substring(0, Math.min(160, message.length())));7 } else if (r.getChannel() == Channel.PUSH) {8 pushService.push(r.getDeviceToken(), message);9 } else {10 throw new IllegalStateException("unknown channel");11 }12 }13}Now add WhatsApp. You edit Notifier. Add a per-channel retry rule. You edit Notifier again, and the method grows a second dimension of branching. Add a channel that needs a template, and Notifier grows knowledge of templates.
Three problems, all structural:
- Every new channel modifies existing, tested code — a violation of the Open/Closed Principle (the SOLID reference).
- The branches accumulate unrelated knowledge: character limits, device tokens, subject lines, all inside one method.
- You cannot test one channel in isolation without constructing the whole
Notifier.
The repair
1public interface NotificationChannel {2 void send(Recipient recipient, String message);3}45public class SmsChannel implements NotificationChannel {6 private static final int MAX_LENGTH = 160;7 public void send(Recipient r, String message) {8 gateway.send(r.getPhone(), truncate(message, MAX_LENGTH));9 }10}11// EmailChannel, PushChannel similarly. Getters and constructors omitted.1213public class Notifier {14 private final Map<Channel, NotificationChannel> channels;15 public void send(Recipient r, String message) {16 channels.get(r.getChannel()).send(r, message);17 }18}Notifier.send is now one line and will never change again. WhatsApp is a new class and a new map entry. The 160-character rule lives in the only class that cares about it.
When an if-else is the right answer
Do not perform this refactor reflexively. Two branches that will never become three are fine, and turning them into two classes and an interface makes the code longer and harder to follow.
The trigger to watch for is the same condition tested in more than one place. When if (channel == SMS) appears in send, again in formatPreview, and again in estimateCost, adding a channel means finding three places, and polymorphism is now clearly correct. One branch in one method is not that.