InterviewKata
Low-level design · Module 02 of 6 · Level 1: Foundations

OOP essentials

Every LLD answer is built from five ideas: encapsulation, abstraction, inheritance, composition and polymorphism. You know the words; this module shows what each one buys you in an interview, and the inheritance mistake that most designs make.

1. The idea

A car's dashboard is a good object. You see a speedometer and a few controls (its interface); the engine's details are sealed away (encapsulation). Every car has the same pedals whatever is under the bonnet (abstraction and polymorphism). And the car has an engine rather than being one (composition).
IdeaIn one lineWhy interviewers care
EncapsulationData is private; methods enforce the rulesAn account's balance can't go negative by accident
AbstractionCallers see what an object does, not howCode depends on "can pay", not on "is a Visa card"
InheritanceA subclass is a special kind of its parent ("is-a")Useful, but couples tightly; most misuse happens here
CompositionAn object is built from others it holds ("has-a")Flexible; prefer it to inheritance
PolymorphismOne call, many behaviours, chosen by the objectReplaces if/else on types

The most quoted rule from the Design Patterns book is "favour object composition over class inheritance". Inherit only when the child truly is the parent and can stand in for it everywhere (Module 03, Liskov).

2. How it works

The same checkout() call, two different objects. Watch the call travel to whichever implementation the order was handed.

3. Try it yourself

Is-a or has-a?

For each pair, choose inheritance (is-a) or composition (has-a). Then check.

4. In code

All five ideas in one small program: an encapsulated account, a payment interface with two implementations, and an order composed of line items.

from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass


class Account:
    """Encapsulation: the balance can only change through methods that enforce the rules."""

    def __init__(self, owner: str, balance: int = 0) -> None:
        self.owner = owner
        self._balance = balance   # private by convention: no one outside sets it

    @property
    def balance(self) -> int:
        return self._balance

    def withdraw(self, amount: int) -> None:
        if amount <= 0:
            raise ValueError("amount must be positive")
        if amount > self._balance:
            raise ValueError("insufficient funds")
        self._balance -= amount


class PaymentMethod(ABC):
    """Abstraction: callers know 'it can pay', not how."""

    @abstractmethod
    def pay(self, amount: int) -> str: ...


class CardPayment(PaymentMethod):
    def __init__(self, last4: str) -> None:
        self.last4 = last4

    def pay(self, amount: int) -> str:
        return f"charged {amount} to card ending {self.last4}"


class WalletPayment(PaymentMethod):
    def __init__(self, account: Account) -> None:
        self.account = account   # composition: a wallet HAS an account; it isn't one

    def pay(self, amount: int) -> str:
        self.account.withdraw(amount)
        return f"paid {amount} from {self.account.owner}'s wallet, {self.account.balance} left"


@dataclass(frozen=True)
class LineItem:
    sku: str
    price: int
    qty: int


class Order:
    """Composition: line items live and die with their order."""

    def __init__(self) -> None:
        self.items: list[LineItem] = []

    def add(self, sku: str, price: int, qty: int = 1) -> Order:
        self.items.append(LineItem(sku, price, qty))
        return self

    def total(self) -> int:
        return sum(i.price * i.qty for i in self.items)

    def checkout(self, method: PaymentMethod) -> str:
        return method.pay(self.total())   # polymorphism: works for any PaymentMethod


if __name__ == "__main__":
    order = Order().add("book", 30).add("pen", 5, 2)
    print("total:", order.total())
    wallet = Account("ana", 100)
    for method in (CardPayment("4242"), WalletPayment(wallet)):
        print(order.checkout(method))
    try:
        WalletPayment(wallet).pay(500)
    except ValueError as e:
        print("wallet:", e)
/** Encapsulation: the balance can only change through methods that enforce the rules. */
class Account {
  #balance: number; // truly private: no one outside can set it
  constructor(readonly owner: string, balance = 0) { this.#balance = balance; }
  get balance(): number { return this.#balance; }
  withdraw(amount: number): void {
    if (amount <= 0) throw new Error("amount must be positive");
    if (amount > this.#balance) throw new Error("insufficient funds");
    this.#balance -= amount;
  }
}

/** Abstraction: callers know "it can pay", not how. */
interface PaymentMethod { pay(amount: number): string; }

class CardPayment implements PaymentMethod {
  constructor(private last4: string) {}
  pay(amount: number): string { return `charged ${amount} to card ending ${this.last4}`; }
}

class WalletPayment implements PaymentMethod {
  constructor(private account: Account) {} // composition: a wallet HAS an account; it isn't one
  pay(amount: number): string {
    this.account.withdraw(amount);
    return `paid ${amount} from ${this.account.owner}'s wallet, ${this.account.balance} left`;
  }
}

interface LineItem { sku: string; price: number; qty: number; }

/** Composition: line items live and die with their order. */
class Order {
  private items: LineItem[] = [];
  add(sku: string, price: number, qty = 1): Order {
    this.items.push({ sku, price, qty });
    return this;
  }
  total(): number { return this.items.reduce((s, i) => s + i.price * i.qty, 0); }
  checkout(method: PaymentMethod): string { return method.pay(this.total()); } // polymorphism: works for any PaymentMethod
}

const order = new Order().add("book", 30).add("pen", 5, 2);
console.log("total:", order.total());
const wallet = new Account("ana", 100);
for (const method of [new CardPayment("4242"), new WalletPayment(wallet)]) console.log(order.checkout(method));
try {
  new WalletPayment(wallet).pay(500);
} catch (e) {
  console.log("wallet:", (e as Error).message);
}
import java.util.ArrayList;
import java.util.List;

/** Encapsulation: the balance can only change through methods that enforce the rules. */
class Account {
    final String owner;
    private int balance; // private: no one outside can set it

    Account(String owner, int balance) {
        this.owner = owner;
        this.balance = balance;
    }

    int balance() {
        return balance;
    }

    synchronized void withdraw(int amount) {
        if (amount <= 0) throw new IllegalArgumentException("amount must be positive");
        if (amount > balance) throw new IllegalArgumentException("insufficient funds");
        balance -= amount;
    }
}

/** Abstraction: callers know "it can pay", not how. */
interface PaymentMethod {
    String pay(int amount);
}

class CardPayment implements PaymentMethod {
    private final String last4;

    CardPayment(String last4) {
        this.last4 = last4;
    }

    public String pay(int amount) {
        return "charged " + amount + " to card ending " + last4;
    }
}

class WalletPayment implements PaymentMethod {
    private final Account account; // composition: a wallet HAS an account; it isn't one

    WalletPayment(Account account) {
        this.account = account;
    }

    public String pay(int amount) {
        account.withdraw(amount);
        return "paid " + amount + " from " + account.owner + "'s wallet, " + account.balance() + " left";
    }
}

record LineItem(String sku, int price, int qty) {}

/** Composition: line items live and die with their order. */
class Order {
    private final List<LineItem> items = new ArrayList<>();

    Order add(String sku, int price, int qty) {
        items.add(new LineItem(sku, price, qty));
        return this;
    }

    int total() {
        return items.stream().mapToInt(i -> i.price() * i.qty()).sum();
    }

    String checkout(PaymentMethod method) {
        return method.pay(total()); // polymorphism: works for any PaymentMethod
    }
}

public class Main {
    public static void main(String[] args) {
        Order order = new Order().add("book", 30, 1).add("pen", 5, 2);
        System.out.println("total: " + order.total());
        Account wallet = new Account("ana", 100);
        for (PaymentMethod m : List.of(new CardPayment("4242"), new WalletPayment(wallet))) System.out.println(order.checkout(m));
        try {
            new WalletPayment(wallet).pay(500);
        } catch (IllegalArgumentException e) {
            System.out.println("wallet: " + e.getMessage());
        }
    }
}

Output (the same in all three languages)

total: 40
charged 40 to card ending 4242
paid 40 from ana's wallet, 60 left
wallet: insufficient funds

5. In the interview

6. Remember it

Memory hook: "Hide the data, show the behaviour, hold rather than be"Encapsulate state, program to what objects do, and prefer composition to inheritance.
How this hook works: what “Hide the data, show the behaviour, hold rather than be” means

What the slogan means, phrase by phrase

“Hide the data”
Fields are private; only methods change them, and they enforce the rules.
“show the behaviour”
Callers use an interface: what the object does, not how.
“hold rather than be”
Build objects from parts they hold (has-a) more often than from parents they extend (is-a).

Why this picture fits: The car: a sealed engine (hidden data), the same pedals in every model (behaviour), and an engine it has rather than is.

The story → the concept

The carOOP
Sealed engine bayEncapsulation
Pedals and wheelThe interface
Any car drives the same wayPolymorphism
Has an engineComposition
A sports car is a carInheritance, used where it's true

Watch it on an example

  1. Hide: the wallet's balance only changes through withdraw(), which refuses overdrafts.
  2. Show: Order.checkout() takes any PaymentMethod.
  3. Hold: WalletPayment holds an Account; it doesn't extend it.

The trap it saves you from

Deep inheritance trees built for code reuse, which break when a parent changes.

It still works when…

The language has no classes (Go, or functional style). The same ideas hold with structs, interfaces and closures.

7. Common mistakes

Public fields with setters for everythingRules can't be enforced; any caller can break the object.
Inheriting to reuse codeStack extends ArrayList exposes insert-in-the-middle. Hold a list instead.
if/else on typesEach new type edits every switch. Use polymorphism.
Classes without behaviourA class with only fields is often a record or enum; fine, but don't confuse it with a design.

8. Practice exercise (25 minutes)

  1. Encapsulate (5 min). Add a deposit() with its own rule to the Account in the code.
  2. Add a payment method (5 min). Gift cards: implement PaymentMethod. Did Order change?
  3. Spot the inheritance (10 min). Find an extends in code you know. Is it really is-a? Could it be has-a?
  4. Explain it (5 min). Out loud: the five ideas, one sentence each, with an example.

9. Self-check quiz

Which is the best example of encapsulation?

Rules live with the data, and only methods change it.

Stack and ArrayList: is-a or has-a?

Inheriting would expose list operations that break stack behaviour.

What does polymorphism replace?

The object decides its behaviour.

What's the usual guidance on inheritance vs composition?

Inherit only for a true is-a that can stand in for its parent.

10. Go deeper

ResourceTimeWhy
Module 03: SOLID in practiceNextFive principles that build on these ideas.
refactoring.guru: design patternsReferenceClear illustrated explanations, with code in many languages.
P07: Vending machine45 minPolymorphism replacing a state switch.