System Design Road

LLD concepts · part 1 of 7

Objects and the ideas behind them

Every low-level design interview is really asking one question: can you split a problem into objects that each do one job well, and connect them so the design is easy to change? Before patterns or parking lots, this chapter covers the ideas everything else is built on, and the specific bugs that happen when they’re misunderstood. All the code compiles and runs, and every output shown is real.

9 min read1 diagrams12 sections

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:

IdeaOne lineThe bug it prevents
Classes and objectsA class is a blueprint; objects are things built from it—
EncapsulationOnly the object changes its own dataA balance set to −₹500 from outside
AbstractionDepend on what, not howCode that breaks when you switch payment providers
InheritanceA child reuses and extends its parent— (it causes one; see below)
PolymorphismOne call, many behavioursLong if/else chains on types
CompositionBuild objects from partsThe fragile parent problem
Interfaces vs abstract classesContracts vs partial implementationsChoosing the wrong tool
EqualitySame value or same object?A set holding the same customer twice
ImmutabilityObjects that can’t change after creationShared 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

Java
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

Java
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());
    }
}
  1. 1the rule, in one place
  2. 2hands out the inside
  3. 3hands out a copy
  1. The rule lives in one method. Nobody can bypass it.
  2. A getter that returns the real list lets callers change the object’s insides without any method being called. Encapsulation is broken, quietly.
  3. Returning an unmodifiable copy keeps the inside private.
Text
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. setBalance would 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.

Checkout- method: PaymentMethod+ pay(paise): String«interface»PaymentMethod+ charge(paise): StringUpiCard

The code

Java
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)
    }
}
  1. Checkout can’t tell UPI from a card, and doesn’t need to.
  2. A test can pass a fake in one line. That’s the practical payoff of abstraction.
Text
UPI collect 25000 from asha@okbank
card *4242 charged 25000
test charge 25000

Abstraction 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:

Java
class CountingSet extends HashSet {
    int added;
    @Override public boolean add(E e) { added++; return super.add(e); }
    @Override public boolean addAll(Collectionextends 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);
    }
}
Text
added 3 items, counter says 6

Six, 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. abstract classes 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:

KindWhat it isDecided
Overriding (runtime)a subclass replaces a parent’s methodwhile running, by the object’s real class
Overloading (compile time)several methods with the same name, different parameterswhen compiling, by the variable’s declared type

That difference surprises people. Look:

Java
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()));
    }
}
  1. 1overriding
  2. 2overloading
  1. Overriding is decided by the real object: it’s an Alarm, so the Alarm version runs.
  2. Overloading is decided by the declared type: n is declared as Notice, so the compiler picks print(Notice), even though the object is an alarm.
Text
an alarm
printing an alarm as a notice
printing an alarm as an ALARM

The 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.

Java
final class CountingSet2 {
    private final Set inner = new HashSet<>(); 1
    private int added;

    boolean add(E e) { added++; return inner.add(e); }
    boolean addAll(Collectionextends 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());
    }
}
  1. 1has a set, isn’t one
  1. The counting set holds a HashSet privately.
  2. When the inner set’s addAll calls its own add, that’s the HashSet‘s add, not ours. Our counting can’t be fooled by the inner set’s details.
Text
added 3 items, counter says 3

Why it’s usually better

InheritanceComposition
Couplingto the parent’s internalsonly to the part’s public methods
Changing at runtimefixed when compiledswap the part (a different strategy, a fake in tests)
Combiningone parent onlyas many parts as you need
Exposingeverything public in the parentonly 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:

InterfaceAbstract class
What it says“can do”: a contract“is a kind of”: a partial implementation
Fieldsconstants onlyany fields
How many per classmanyone
Code insidedefault and static methodsanything
Constructornoyes
Use whenunrelated 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:

Java
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));
    }
}
  1. equals says two customers with the same phone are equal, but there’s no matching hashCode.
  2. A record gets correct equals, hashCode and toString automatically, based on all its fields.
Text
a.equals(b) = true, set size = 2
record: c.equals(d) = true, set size = 1, same object: false

The 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). equals compares value.
  • *If you override equals, override hashCode too*, using the same fields. Equal objects must have equal hash codes.
  • For simple data classes, use a record and 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 HashMap gets lost in the wrong bucket.
  • Thread-safe for free. No locks needed to read something that never changes.

How to make one in Java:

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());
    }
}
  1. 1returns a new object
  1. “Changing” an immutable object means creating a new one. The old one is untouched.
  2. Copy collections on the way in, or the caller can still change your object through the list they passed.
Text
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.

DecisionIdea
Wallet has a private balance and spend() checks itEncapsulation
Checkout takes a PaymentMethod, not a UPI classAbstraction
UPI and card each implement charge their own wayPolymorphism
A cashback rule is a separate object the wallet hasComposition
Amounts are a Money recordImmutability, correct equality
No PremiumWallet extends Wallet for a different feeComposition 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.
  • *instanceof chains:* polymorphism missed.
  • *Money as double, or mutable keys in maps:* value objects missed.
Interviewer

Interviewer: Why didn’t you make PremiumCustomer extends Customer?

You

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.