Object-Oriented Design Interview

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.

A public field against a guarded onePublic balance field• Any caller can set it negative• The invariant lives in every caller• A bug has no single place to fixPrivate field, withdraw()• The object rejects the invalid change• The invariant lives in one method• The bug can only be in one place
Encapsulation is not hiding data — it is owning the rule that keeps the data valid.

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

Java
public class Account {    public double balance;   // public field}

Now anywhere in the codebase:

Java
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 double

Three 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

Java
public class Account {    private final String id;    private Money balance;          // Money wraps a long of minor units — no floats    private Money overdraftLimit;    public void withdraw(Money amount) {        if (amount.isNegativeOrZero()) throw new IllegalArgumentException("amount must be positive");        Money after = balance.minus(amount);        if (after.isLessThan(overdraftLimit.negated())) throw new InsufficientFundsException(id);        balance = after;    }    public void deposit(Money amount) {        if (amount.isNegativeOrZero()) throw new IllegalArgumentException("amount must be positive");        balance = balance.plus(amount);    }    public Money getBalance() { return balance; }   // read-only: Money is immutable}

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.

Callers depend on what, never on howCaller: checkout()Interface: PaymentMethodCardPayment implements itCashPayment implements it
A new payment type is a new bottom row, and the top row never opens for editing.

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:

Java
public class ParkingLot {    public Money computeFee(Ticket ticket) {        long hours = ticket.durationHours();        if (ticket.getVehicleSize() == Size.MOTORCYCLE) return Money.rupees(20 * hours);        if (ticket.getVehicleSize() == Size.CAR)        return Money.rupees(50 * hours);        return Money.rupees(100 * hours);    }}

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:

Java
public interface PricingStrategy {    Money computeFee(Ticket ticket);}public class HourlyPricing implements PricingStrategy { /* per-size hourly rates */ }public class FlatWeekendPricing implements PricingStrategy { /* one rate, any duration */ }public 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 a subclass is honestGenuinely is-a• Circle is a Shape, with area()• SavingsAccount is an Account• Behaviour differs, contract does notThe classic wrong hierarchy• Square inherits from Rectangle• Truck extends Car to reuse a field• Subclass has to weaken a promise
Inheritance for reuse is the trap; the base class then breaks every subclass when it changes.

When it genuinely applies

Three conditions, all required:

  1. It is a true is-a relationship, permanently. A Motorcycle is a Vehicle and will never stop being one.
  2. 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.
  3. The shared part is genuinely shared behaviour, not coincidental similarity.

A clean example from the parking lot design:

Java
public abstract class Vehicle {    private final String plate;    public abstract VehicleSize getSize();}public class Motorcycle extends Vehicle {    public VehicleSize getSize() { return VehicleSize.SMALL; }}public class Car extends Vehicle {    public VehicleSize getSize() { return VehicleSize.MEDIUM; }}

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:

Java
public abstract class Vehicle {    public Money fee(long hours) { return baseRate().times(hours); }    protected abstract Money baseRate();}

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

Text
Account → SavingsAccount → InterestBearingSavingsAccount → PremiumInterestBearingSavingsAccount

Every 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 improvement the interviewer will suggestAn if-else chain on type• Every new type edits the same method• The branch list is duplicated elsewhere• Forgetting one branch compiles fineOne call, many objects• Each type carries its own behaviour• A new type adds a class, edits nothing• The compiler demands the method exists
An if-else is still right when the branches are a fixed, closed set that will never grow.

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.

Java
public class Notifier {    public void send(Recipient r, String message) {        if (r.getChannel() == Channel.EMAIL) {            emailClient.send(r.getEmail(), "Notification", message);        } else if (r.getChannel() == Channel.SMS) {            smsGateway.send(r.getPhone(), message.substring(0, Math.min(160, message.length())));        } else if (r.getChannel() == Channel.PUSH) {            pushService.push(r.getDeviceToken(), message);        } else {            throw new IllegalStateException("unknown channel");        }    }}

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

Java
public interface NotificationChannel {    void send(Recipient recipient, String message);}public class SmsChannel implements NotificationChannel {    private static final int MAX_LENGTH = 160;    public void send(Recipient r, String message) {        gateway.send(r.getPhone(), truncate(message, MAX_LENGTH));    }}// EmailChannel, PushChannel similarly. Getters and constructors omitted.public class Notifier {    private final Map<Channel, NotificationChannel> channels;    public void send(Recipient r, String message) {        channels.get(r.getChannel()).send(r, message);    }}

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.