System Design Road

LLD concepts · part 2 of 7

SOLID, with code that breaks each rule and code that follows it

SOLID is five rules of thumb for code that stays easy to change as a project grows. They come up in nearly every low-level design interview, usually not by name but as “how would you add this?” or “how would you test that?”. Here, each rule starts with code that breaks it, run so you can see exactly what goes wrong, then the fix. Our running example is a food delivery app.

8 min read2 diagrams8 sections

The short version

  • Single responsibility: a class should have one reason to change. If a pricing change and an email change both edit the same class, it does too much.
  • Open/closed: add new behaviour by adding new code, not by editing tested code. Interfaces mark where behaviour will vary.
  • Liskov substitution: a child must work anywhere its parent works. A subclass that throws where the parent succeeds breaks every caller that trusted the parent, and Java’s own List.of does exactly this.
  • Interface segregation: small interfaces that classes can fully implement, instead of one big one that forces fake methods.
  • Dependency inversion: depend on interfaces and receive concrete classes from outside. It’s what makes a class testable with fakes, in milliseconds.

Before we start

SOLID is an acronym for five principles collected by Robert C. Martin in the early 2000s:

LetterPrincipleIn one line
SSingle responsibilityOne class, one reason to change
OOpen/closedExtend by adding code, not by editing it
LLiskov substitutionA child must work wherever its parent works
IInterface segregationSmall interfaces, fully implemented
DDependency inversionDepend on interfaces; receive the concrete classes

They’re not laws. They’re warnings about specific kinds of pain: changes that ripple everywhere, code that can’t be tested, subclasses that surprise their callers. Each section below shows that pain in running code, then the fix, then how to spot the problem in an interview and when it’s fine to bend the rule.

S: Single responsibility

The rule

A class should have one reason to change. “Reason to change” means a person or team who might ask for a change: the pricing team, the marketing team, the database team. If two different teams’ requests both mean editing the same class, it has two responsibilities.

Breaking it

Java
class OrderServiceAllInOne {
    final List sent = new ArrayList<>(), saved = new ArrayList<>();

    long placeOrder(String user, List itemPrices, String coupon) {
        if (itemPrices.isEmpty()) throw new IllegalArgumentException("empty"); // check
        long total = 0;                                                         // price
        for (long p : itemPrices) total += p;
        if ("HAPPY10".equals(coupon)) total -= total / 10;
        if (total < 199_00) total += 30_00;                                     // fee
        saved.add(user + ":" + total);                                          // store
        sent.add("SMS to " + user + ": placed, ₹" + total / 100);              // message
        return total;
    }
}

Four teams can ask to change this one method: whoever owns validation rules, the pricing team (coupons and fees), the database team, and the marketing team (message wording). Every change risks breaking the other three. And to test “is HAPPY10 applied correctly?”, you have to run validation, save to storage and send an SMS too.

The fix

Split it so each class has one job, and one class coordinates them:

OrderService+ placeOrder(user, items, coupon): longPriceCalculator+ total(items, coupon): longOrderStore+ save(user, total): voidMessenger+ orderPlaced(user, total): void
Java
final class PriceCalculator {
    long total(List itemPrices, String coupon) {
        long total = 0;
        for (long p : itemPrices) total += p;
        if ("HAPPY10".equals(coupon)) total -= total / 10;
        if (total < 199_00) total += 30_00;
        return total;
    }
}

final class OrderStore {
    final List saved = new ArrayList<>();
    void save(String user, long total) { saved.add(user + ":" + total); }
}

final class Messenger {
    final List sent = new ArrayList<>();
    void orderPlaced(String user, long total) {
        sent.add("SMS to " + user + ": order placed, ₹" + total / 100);
    }
}

final class OrderService {
    private final PriceCalculator prices;
    private final OrderStore store;
    private final Messenger messenger;
    OrderService(PriceCalculator p, OrderStore s, Messenger m) {
        prices = p; store = s; messenger = m;
    }
    long placeOrder(String user, List items, String coupon) {
        if (items.isEmpty()) throw new IllegalArgumentException("empty");
        long total = prices.total(items, coupon);
        store.save(user, total);
        messenger.orderPlaced(user, total);
        return total;
    }
}

class SrpDemo {
    public static void main(String[] args) {
        PriceCalculator prices = new PriceCalculator(); 1
        System.out.println("₹180 with HAPPY10: "
            + prices.total(List.of(180_00L), "HAPPY10"));
        System.out.println("₹250, no coupon:   " + prices.total(List.of(250_00L), null));

        OrderStore store = new OrderStore();
        Messenger msg = new Messenger();
        new OrderService(prices, store, msg)
            .placeOrder("asha", List.of(250_00L), "HAPPY10");
        System.out.println(store.saved + " " + msg.sent);
    }
}
  1. 1tested alone
  1. The pricing rules can now be tested with no storage and no messaging at all.
Text
₹180 with HAPPY10: 19200
₹250, no coupon:   25000
[asha:22500] [SMS to asha: order placed, ₹225]

The first line shows a pricing rule worth testing: ₹180 drops to ₹162 after the coupon, which falls under ₹199, so the ₹30 delivery fee is added. That’s exactly the kind of edge case you want to check in a fast test of PriceCalculator alone.

How to spot it

  • A class name ending in Manager, Service or Helper with a dozen unrelated methods.
  • The word “and” when you describe the class: “it validates and prices and saves”.
  • A test that needs a database just to check arithmetic.

When to bend it

Splitting too far is a mistake too: five classes of three lines each, for logic that never changes separately, is harder to read than one. Split along the lines where changes really come from.

Where it shows up

In the parking lot, ParkingLot deliberately does not allocate spots, price stays or talk to banks; it coordinates classes that do. Its “doesn’t do” column is single responsibility written down.

O: Open/closed

The rule

Open for extension, closed for modification. You should be able to add new behaviour by adding new code, without editing code that’s already written and tested.

Breaking it

Java
class DiscountsByType {
    long apply(String type, long total) {
        if (type.equals("NONE")) return total;
        if (type.equals("PERCENT10")) return total - total / 10;
        if (type.equals("FLAT50")) return Math.max(0, total - 50_00);
        throw new IllegalArgumentException("unknown discount " + type);
    }
}

Next week marketing wants a festival discount: 20% off, capped at ₹100. That means editing apply, which means re-testing every existing discount, and if the code is shared across teams, everyone’s build waits on that change. Every new rule repeats the cycle.

The fix

Make each discount a class behind one interface. A new discount is a new class; nothing existing changes.

Cart- discount: Discount+ total(itemsPaise): long«interface»Discount+ apply(total): longPercentFlatFestivalCap
Java
interface Discount {
    long apply(long total);
}

record Percent(int pct) implements Discount {
    public long apply(long t) { return t - t * pct / 100; }
}

record Flat(long paise) implements Discount {
    public long apply(long t) { return Math.max(0, t - paise); }
}

record FestivalCap(int pct, long maxOff) implements Discount { 1
    public long apply(long t) { return t - Math.min(t * pct / 100, maxOff); }
}

class OcpDemo {
    public static void main(String[] args) {
        long total = 800_00;
        List all =
            List.of(new Percent(10), new Flat(50_00), new FestivalCap(20, 100_00));
        for (Discount d : all) System.out.println(d + " -> " + d.apply(total));
    }
}
  1. 1added later
  1. The festival discount arrived later, as one new record. Percent and Flat weren’t touched, so their tests didn’t need to run again.
Text
Percent[pct=10] -> 72000
Flat[paise=5000] -> 75000
FestivalCap[pct=20, maxOff=10000] -> 70000

How to spot it

  • A switch or if chain on a type or a string that keeps growing.
  • “Every time we add an X, we have to edit Y, Z and W.”

When to bend it

You can’t make everything open to every change; that’s endless interfaces. Put the extension points where the requirements say change will come (“pricing rules change every quarter”), and nowhere else. The parking lot walkthrough explicitly gives no interface to spots and floors, because nothing suggested they’d vary.

Where it shows up

Every follow-up section in the problem walkthroughs is titled “extended without editing”: weekend pricing as a decorator in the parking lot, gomoku as a new WinRule in tic-tac-toe, a combined limit as a new algorithm in the rate limiter.

L: Liskov substitution

The rule

If code works with a parent type, it must keep working when given any child type. Named after Barbara Liskov, who described it in 1987. A child can do more than its parent promised, never less: no extra exceptions, no stricter inputs, no weaker results.

Breaking it

A wallet app has accounts you can withdraw from. Someone adds fixed deposits as a kind of account:

Java
class Account {
    protected long balance;
    Account(long balance) { this.balance = balance; }
    void withdraw(long paise) {
        if (paise > balance) throw new IllegalArgumentException("insufficient funds");
        balance -= paise;
    }
    long balance() { return balance; }
}

class FixedDeposit extends Account {
    FixedDeposit(long balance) { super(balance); }
    @Override void withdraw(long paise) { 1
        throw new UnsupportedOperationException("locked until maturity");
    }
}

class LspDemo {
    static long payBill(List accounts, long bill) { 2
        for (Account a : accounts) {
            long take = Math.min(bill, a.balance());
            a.withdraw(take);
            bill -= take;
            if (bill == 0) break;
        }
        return bill;
    }

    public static void main(String[] args) {
        try {
            List accounts =
                List.of(new FixedDeposit(10_000_00), new Account(5_000_00));
            payBill(accounts, 3_000_00);
        } catch (UnsupportedOperationException e) {
            System.out.println("bill payment crashed: " + e.getMessage());
        }
        try {
            List tags = List.of("veg", "spicy"); 3
            tags.add("new");
        } catch (UnsupportedOperationException e) {
            System.out.println("List.of(...).add threw UnsupportedOperationException");
        }
    }
}
  1. 1“is a” account, but…
  2. 2written for Account
  3. 3Java’s own example
  1. A fixed deposit is an account in everyday language, so inheriting seemed natural. But it can’t do something every account promised to do.
  2. payBill was written and tested against Account. It’s correct. It breaks only because a child broke its parent’s promise.
  3. The Java library has the same issue: List.of(...) returns a List whose add throws. Code that accepts any List and adds to it fails on these lists.
Text
bill payment crashed: locked until maturity
List.of(...).add threw UnsupportedOperationException

The fix

Model what each kind of account can actually do. Withdrawing is a capability some accounts have, not something every account promises:

Java
interface Holding {
    long balance();
}

interface Withdrawable extends Holding {
    void withdraw(long paise);
}

final class SavingsAccount implements Withdrawable {
    private long balance;
    SavingsAccount(long balance) { this.balance = balance; }
    public long balance() { return balance; }
    public void withdraw(long paise) {
        if (paise > balance) throw new IllegalArgumentException("insufficient funds");
        balance -= paise;
    }
}

final class Deposit implements Holding { 1
    private final long balance;
    Deposit(long balance) { this.balance = balance; }
    public long balance() { return balance; }
}

class LspFixedDemo {
    static long payBill(List from, long bill) {   // (2)
        for (Withdrawable a : from) {
            long take = Math.min(bill, a.balance());
            a.withdraw(take);
            bill -= take;
        }
        return bill;
    }

    public static void main(String[] args) {
        SavingsAccount s = new SavingsAccount(5_000_00);
        List all = List.of(new Deposit(10_000_00), s);
        long worth = all.stream().mapToLong(Holding::balance).sum();
        System.out.println("net worth " + worth + ", left to pay "
            + payBill(List.of(s), 3_000_00) + ", savings now " + s.balance());
    }
}
  1. 1no withdraw to break
  1. A deposit has a balance, but no withdraw method, so nobody can call one on it.
  2. payBill asks for exactly what it needs. Passing a deposit is now a compile error, not a crash in production.
Text
net worth 1500000, left to pay 0, savings now 200000

How to spot it

  • A subclass method that throws UnsupportedOperationException, or does nothing.
  • if (x instanceof SomeChild) checks before calling a parent’s method.
  • The classic textbook case: Square extends Rectangle, where setting the width also changes the height, surprising code that sets them separately.

When to bend it

Rarely. The Java collections bend it on purpose (unmodifiable lists) to keep one List type, and document it heavily. In your own designs, prefer splitting the interface.

Where it shows up

In tic-tac-toe, bots and win rules get a read-only BoardView, not the Board, so nothing can call a method it shouldn’t. And the chess walkthrough explains why canMove on each piece can’t keep its promise once pins exist.

I: Interface segregation

The rule

No class should be forced to implement methods it doesn’t use. Prefer several small interfaces to one big one.

Breaking it

Java
interface RestaurantPartner {
    String cook(String dish);
    String bookTable(int people);
    String deliver(String orderId);
}

class CloudKitchen implements RestaurantPartner { 1
    public String cook(String dish) { return "cooking " + dish; }
    public String bookTable(int people) { throw new UnsupportedOperationException(); }
    public String deliver(String orderId) { throw new UnsupportedOperationException(); }
}
  1. 1no tables, no riders
  1. A cloud kitchen only cooks. It’s forced to implement table booking and delivery, so it throws, which is an LSP violation caused by an interface that’s too big. Anyone holding a RestaurantPartner can’t trust bookTable without knowing which class it really is.

The fix

Split the interface by capability. Each class implements exactly what it can do; each caller asks for exactly what it needs.

Java
interface Kitchen { String cook(String dish); }
interface DineIn { String bookTable(int people); }
interface OwnDelivery { String deliver(String orderId); }

final class CloudKitchen2 implements Kitchen {
    public String cook(String dish) { return "cloud kitchen cooking " + dish; }
}

final class FamilyRestaurant implements Kitchen, DineIn {
    public String cook(String dish) { return "restaurant cooking " + dish; }
    public String bookTable(int people) { return "table for " + people; }
}

class IspDemo {
    static List lunchRush(List kitchens) { 1
        List out = new ArrayList<>();
        for (Kitchen k : kitchens) out.add(k.cook("thali"));
        return out;
    }

    public static void main(String[] args) {
        FamilyRestaurant r = new FamilyRestaurant();
        System.out.println(lunchRush(List.of(new CloudKitchen2(), r)));
        System.out.println(r.bookTable(4));
    }
}
  1. 1needs only cooking
  1. lunchRush accepts anything that cooks. It can’t call bookTable, so it can’t hit a missing one.
Text
[cloud kitchen cooking thali, restaurant cooking thali]
table for 4

How to spot it

  • Methods that throw UnsupportedOperationException or return dummy values.
  • Callers that use one method of a ten-method interface.
  • Test fakes that must implement many methods just to test one thing.

When to bend it

Don’t split into one-method interfaces for things that always go together. charge and refund on a payment gateway belong in one interface, because every gateway does both.

Where it shows up

BoardView in tic-tac-toe: the read-only half of the board, which is all that bots and win rules are allowed to see.

D: Dependency inversion

The rule

High-level code should depend on interfaces, not on concrete low-level classes. The concrete classes are handed in from outside. “Inversion” because the usual direction (business logic reaches down and creates the database class it needs) is flipped: the business logic declares what it needs, and something outside supplies it.

Breaking it

Java
class SmsGateway {                                   // a real provider in real life
    String send(String to, String text) { return "REAL SMS to " + to; }
}

class ReminderServiceHardwired {
    private final SmsGateway sms = new SmsGateway(); 1
    String remind(String user, java.time.LocalTime now) {
        if (now.getHour() < 9) return "too early";
        return sms.send(user, "Your table is booked for 8 pm");
    }
}
  1. 1creates its own
  1. The service creates its own SMS gateway and reads the time from a parameter (better than calling LocalTime.now() inside, but the gateway is still fixed). A test of “don’t send before 9 am” sends a real SMS when it passes the other branch, and there’s no way to see what was sent.

The fix

Depend on interfaces, and receive the implementations through the constructor. This is dependency injection, the usual way to follow the principle. Time is a dependency too.

Java
interface Sms {
    String send(String to, String text);
}

final class ReminderService {
    private final Sms sms;
    private final java.time.Clock clock;
    ReminderService(Sms sms, java.time.Clock clock) { 1
        this.sms = sms;
        this.clock = clock;
    }
    String remind(String user) {
        if (java.time.LocalTime.now(clock).getHour() < 9) return "too early";
        return sms.send(user, "Your table is booked for 8 pm");
    }
}

class DipDemo {
    public static void main(String[] args) {
        List outbox = new ArrayList<>();
        Sms fake = (to, text) -> {                           // (2)
            outbox.add(to + ": " + text);
            return "queued";
        };
        java.time.ZoneId ist = java.time.ZoneId.of("Asia/Kolkata");
        java.time.Clock early = java.time.Clock.fixed(
            java.time.LocalDateTime.of(2026, 11, 14, 7, 30).atZone(ist).toInstant(), ist);
        java.time.Clock noon = java.time.Clock.fixed(
            java.time.LocalDateTime.of(2026, 11, 14, 12, 0).atZone(ist).toInstant(), ist);

        System.out.println("7:30 am: " + new ReminderService(fake, early).remind("asha"));
        System.out.println("noon:    " + new ReminderService(fake, noon).remind("asha"));
        System.out.println("outbox:  " + outbox);
    }
}
  1. 1given, not created
  1. The service is handed an SMS sender and a clock. In production, main passes the real gateway and Clock.systemDefaultZone().
  2. A test passes a fake that records messages, and fixed clocks. Both cases run in microseconds, deterministically, with no network.
Text
7:30 am: too early
noon:    queued
outbox:  [asha: Your table is booked for 8 pm]

How to spot it

  • new of an infrastructure class (database, HTTP client, SMS sender) inside business logic.
  • Instant.now(), LocalDate.now() or new Random() deep inside a class.
  • Tests that need a network, a database or Thread.sleep.

When to bend it

Don’t inject everything. Plain values (ArrayList, String, records) and simple helpers with no side effects can be created directly. Inject things that talk to the outside world, things that are slow, and things you’ll want to fake: time, randomness, I/O.

Where it shows up

Every problem walkthrough in this track passes in a clock and a payment processor or dice: the parking lot tests 75-minute stays instantly; movie booking moves a fake clock past a hold’s expiry; snakes and ladders plays scripted dice.

How the five fit together

The principles reinforce each other:

If you followYou getWhich makes this easier
Single responsibilitysmall classes with one jobinterfaces that are naturally small (I)
Interface segregationsmall interfacesimplementations that keep their promises (L)
Liskov substitutionchildren that are safe to swap inextension by adding classes (O)
Open/closedextension points behind interfacesdepending on those interfaces (D)
Dependency inversionconcrete classes handed intests with fakes, and swapping implementations

And they share one cost: more, smaller pieces. The skill is applying them where the requirements say change is coming, not everywhere.

How this shows up in interviews

Interviewers rarely say “SOLID”. They ask questions whose best answers happen to follow it:

They askThey’re checking
“How would you add a new discount type?”Open/closed
“How would you test this without a real payment gateway?”Dependency inversion
“What does this class do?” (and your answer has three “and”s)Single responsibility
“Can a guest user do everything a user can?”Liskov substitution
“Does every implementation need all these methods?”Interface segregation
Interviewer

Interviewer: Your NotificationService sends SMS, email and push, retries failures, picks templates and records analytics. Anything you’d change?

You

You: It has at least four reasons to change. I’d keep NotificationService as a coordinator, and give each job its own piece: a Channel interface with SMS, email and push implementations (so a new channel is a new class), a retry wrapper around a channel, a TemplateRenderer, and analytics as a listener. Each is injected, so tests can check “a failed SMS is retried twice” with a fake channel and no real provider.