InterviewKata
Low-level design · Module 03 of 11 · Level 1: Foundations

SOLID in practice

SOLID is five principles for code that survives change. Interviewers rarely ask you to recite them; they test them, by asking for a change at minute 40. This module shows each principle as a smell and a fix, and the two that matter most in interviews.

1. The idea

A good kitchen has a station for each job (single responsibility), takes new appliances without rewiring the walls (open/closed), accepts any brand of pan that fits the hob (Liskov), gives each cook only the tools they need (interface segregation), and plugs everything into standard sockets rather than wiring it in (dependency inversion).
PrincipleIn one lineThe smell it fixes
Single responsibilityOne reason to change per classA class that calculates, formats and saves
Open/closedAdd behaviour with new code, not editsA switch that grows with every new type
Liskov substitutionSubclasses keep their parent's promisesA subclass that throws "not supported"
Interface segregationSmall, focused interfacesImplementers stuck with methods they don't need
Dependency inversionDepend on interfaces; inject the concrete onesnew MySqlDatabase() inside business logic

The principles were collected by Robert C. Martin. In interviews, open/closed and dependency inversion come up most, because the follow-up twist ("add a new X") tests exactly them. Each principle has its own deep-dive module next: 03a · 03b · 03c · 03d · 03e.

2. How it works

A new promotion arrives. Watch what changes in a design that breaks open/closed, and in one that follows it.

3. Try it yourself

Which principle is broken?

Five small code smells. Pick the principle each one breaks, then check.

4. In code

The "after" design: discount rules behind an interface, injected into the calculator, and a separate receipt printer. The second line of output comes from adding one class.

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


@dataclass(frozen=True)
class Item:
    sku: str
    price: int   # cents
    qty: int


@dataclass(frozen=True)
class Cart:
    customer_tier: str
    items: tuple[Item, ...]

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


# O: open for extension. A new promotion is a new class; PriceCalculator never changes.
class DiscountRule(ABC):
    @abstractmethod
    def discount(self, cart: Cart) -> int: ...


class TierDiscount(DiscountRule):
    PERCENT = {"gold": 10, "silver": 5}

    def discount(self, cart: Cart) -> int:
        return cart.subtotal() * self.PERCENT.get(cart.customer_tier, 0) // 100


class BuyTwoGetOneFree(DiscountRule):
    def __init__(self, sku: str) -> None:
        self.sku = sku

    def discount(self, cart: Cart) -> int:
        return sum((i.qty // 3) * i.price for i in cart.items if i.sku == self.sku)


# D: the calculator depends on the DiscountRule interface; the concrete rules are passed in.
class PriceCalculator:
    def __init__(self, rules: list[DiscountRule]) -> None:
        self.rules = rules

    def total(self, cart: Cart) -> int:
        return max(0, cart.subtotal() - sum(r.discount(cart) for r in self.rules))


# S: formatting a receipt is a different reason to change, so it's a different class.
class ReceiptPrinter:
    def print(self, cart: Cart, total: int) -> str:
        return f"{cart.customer_tier} customer: subtotal {cart.subtotal()}, total {total}"


if __name__ == "__main__":
    cart = Cart("gold", (Item("coffee", 400, 3), Item("mug", 1200, 1)))
    printer = ReceiptPrinter()
    print(printer.print(cart, PriceCalculator([TierDiscount()]).total(cart)))
    # A new promotion arrives: add a class, pass it in. No existing class was edited.
    print(printer.print(cart, PriceCalculator([TierDiscount(), BuyTwoGetOneFree("coffee")]).total(cart)))
interface Item { sku: string; price: number; qty: number; } // price in cents

class Cart {
  constructor(readonly customerTier: string, readonly items: Item[]) {}
  subtotal(): number { return this.items.reduce((s, i) => s + i.price * i.qty, 0); }
}

// O: open for extension. A new promotion is a new class; PriceCalculator never changes.
interface DiscountRule { discount(cart: Cart): number; }

class TierDiscount implements DiscountRule {
  private static PERCENT: Record<string, number> = { gold: 10, silver: 5 };
  discount(cart: Cart): number { return Math.floor((cart.subtotal() * (TierDiscount.PERCENT[cart.customerTier] ?? 0)) / 100); }
}

class BuyTwoGetOneFree implements DiscountRule {
  constructor(private sku: string) {}
  discount(cart: Cart): number {
    return cart.items.filter((i) => i.sku === this.sku).reduce((s, i) => s + Math.floor(i.qty / 3) * i.price, 0);
  }
}

// D: the calculator depends on the DiscountRule interface; the concrete rules are passed in.
class PriceCalculator {
  constructor(private rules: DiscountRule[]) {}
  total(cart: Cart): number { return Math.max(0, cart.subtotal() - this.rules.reduce((s, r) => s + r.discount(cart), 0)); }
}

// S: formatting a receipt is a different reason to change, so it's a different class.
class ReceiptPrinter {
  print(cart: Cart, total: number): string { return `${cart.customerTier} customer: subtotal ${cart.subtotal()}, total ${total}`; }
}

const cart = new Cart("gold", [{ sku: "coffee", price: 400, qty: 3 }, { sku: "mug", price: 1200, qty: 1 }]);
const printer = new ReceiptPrinter();
console.log(printer.print(cart, new PriceCalculator([new TierDiscount()]).total(cart)));
// A new promotion arrives: add a class, pass it in. No existing class was edited.
console.log(printer.print(cart, new PriceCalculator([new TierDiscount(), new BuyTwoGetOneFree("coffee")]).total(cart)));
import java.util.List;
import java.util.Map;

record Item(String sku, int price, int qty) {} // price in cents

record Cart(String customerTier, List<Item> items) {
    int subtotal() {
        return items.stream().mapToInt(i -> i.price() * i.qty()).sum();
    }
}

// O: open for extension. A new promotion is a new class; PriceCalculator never changes.
interface DiscountRule {
    int discount(Cart cart);
}

class TierDiscount implements DiscountRule {
    private static final Map<String, Integer> PERCENT = Map.of("gold", 10, "silver", 5);

    public int discount(Cart cart) {
        return cart.subtotal() * PERCENT.getOrDefault(cart.customerTier(), 0) / 100;
    }
}

class BuyTwoGetOneFree implements DiscountRule {
    private final String sku;

    BuyTwoGetOneFree(String sku) {
        this.sku = sku;
    }

    public int discount(Cart cart) {
        return cart.items().stream().filter(i -> i.sku().equals(sku)).mapToInt(i -> (i.qty() / 3) * i.price()).sum();
    }
}

// D: the calculator depends on the DiscountRule interface; the concrete rules are passed in.
class PriceCalculator {
    private final List<DiscountRule> rules;

    PriceCalculator(List<DiscountRule> rules) {
        this.rules = rules;
    }

    int total(Cart cart) {
        return Math.max(0, cart.subtotal() - rules.stream().mapToInt(r -> r.discount(cart)).sum());
    }
}

// S: formatting a receipt is a different reason to change, so it's a different class.
class ReceiptPrinter {
    String print(Cart cart, int total) {
        return cart.customerTier() + " customer: subtotal " + cart.subtotal() + ", total " + total;
    }
}

public class Main {
    public static void main(String[] args) {
        Cart cart = new Cart("gold", List.of(new Item("coffee", 400, 3), new Item("mug", 1200, 1)));
        ReceiptPrinter printer = new ReceiptPrinter();
        System.out.println(printer.print(cart, new PriceCalculator(List.of(new TierDiscount())).total(cart)));
        // A new promotion arrives: add a class, pass it in. No existing class was edited.
        System.out.println(printer.print(cart, new PriceCalculator(List.of(new TierDiscount(), new BuyTwoGetOneFree("coffee"))).total(cart)));
    }
}

Output (the same in all three languages)

gold customer: subtotal 2400, total 2160
gold customer: subtotal 2400, total 1760

5. In the interview

One class through all five

A hotel's CheckoutDesk.checkout(stay) adds up the nights, applies loyalty discounts in a switch on the guest's tier, formats the invoice as a PDF, charges the card with new CardClient(key), and emails the guest. Each principle asks one question of it:

PrincipleThe questionWhat changes
SWho asks for changes here?Finance (the bill), marketing (discounts), the brand team (invoice look), payments, guest messaging: the bill, the invoice and the messages become separate classes.
OWhat keeps growing?Discount tiers: the switch becomes DiscountRule objects, so a new tier is a new class.
LCan every subtype stand in?A corporate account that "can't be charged, it's invoiced monthly" was a subclass of Guest; it becomes a payment arrangement the stay holds.
IWhat does each caller need?The front-desk screen needs checkout and the invoice; the night audit needs totals only: two small interfaces.
DWhat is wired in?The desk takes a PaymentGateway and a Notifier it owns; main chooses the card provider and the email service.

The order helps: separating responsibilities first shows where extension points belong, and the interfaces from the last two steps are the seams you test through. In an interview, apply only the two or three that the prompt's likely changes call for.

Saying it out loud

Interviewers listen for the problem before the label. A sentence shape that works: what you notice → what it will cost → what you'll change, and only then the principle's name.

Example
Label first"This violates the open/closed principle."
Problem first"Every new discount tier edits this switch, so marketing's next request means re-testing checkout. I'll make each tier a rule object; that's open/closed."

If asked "would you always do that?", give the condition: "Only because tiers change monthly here. With two fixed tiers, the switch would be simpler."

Questions interviewers ask about SOLID

Which principle do you use most, and where?

Dependency inversion at boundaries: payments, storage, time and messaging go behind interfaces the business code owns, so they can be swapped and faked. Open/closed second, wherever the product keeps adding variants.

How is single responsibility different from interface segregation?

SRP looks at a class from the inside: who can ask it to change. ISP looks from the outside: what each caller needs to see. One class can be well split and still offer a fat interface, or the reverse. (Modules 03a and 03d.)

When would you deliberately not apply them?

For code unlikely to change, prototypes and small scripts, and whenever an abstraction would have one implementation forever. Each principle adds indirection, so apply it where the change it protects against is likely.

Isn't SOLID only about classes?

The words come from object-oriented design, but the ideas are general: functions with one reason to change, behaviour passed in as functions (open/closed and inversion without interfaces), narrow parameter types. Functional code applies them with higher-order functions instead of class hierarchies.

What's wrong with a service locator?

It hides dependencies inside method bodies, so you discover them at run time or when a test fails. Constructor injection shows them in the signature. (Module 03e.)

Give a Liskov violation that never throws.

A subclass that quietly returns a different result than the parent promises, such as a due date moved to Monday. Callers get wrong answers, not errors; a contract test run against every subtype catches it. (Module 03c.)

How do SOLID and design patterns relate?

Many patterns are ready-made applications: strategy and decorator give open/closed extension points, an adapter makes inversion possible at a vendor boundary, and template method trades some flexibility for reuse. (Module 04.)

How would you bring a 2,000-line legacy class closer to SOLID without a rewrite?

Add characterisation tests first; extract one responsibility at a time, starting where changes are most frequent; inject the external dependencies so the rest becomes testable; stop when the parts that change often are separated.

6. Remember it

Memory hook: "One job, add don't edit, keep promises, small interfaces, plug in"Five principles, one phrase each.
How this hook works: what “One job, add don't edit, keep promises, small interfaces, plug in” means

What the slogan means, phrase by phrase

“One job”
Single responsibility: one reason to change.
“add don't edit”
Open/closed: new behaviour in new classes.
“keep promises”
Liskov: a subclass works wherever its parent does.
“small interfaces”
Interface segregation.
“plug in”
Dependency inversion: depend on interfaces, inject the implementations.

Why this picture fits: The kitchen: stations, appliances that plug in, pans that fit any hob, cooks with only the tools they need, standard sockets.

The story → the concept

The kitchenSOLID
A station per jobS
A new appliance, no rewiringO
Any pan that fits the hobL
Each cook's own toolsetI
Standard socketsD

Watch it on an example

  1. One job: ReceiptPrinter is separate from PriceCalculator.
  2. Add don't edit: BuyTwoGetOneFree is a new class; the calculator is untouched.
  3. Plug in: the calculator receives its rules through the constructor.

The trap it saves you from

Treating SOLID as a checklist and adding abstractions nobody needs.

It still works when…

You're in a 45-minute interview. Apply the two that pay off immediately (O and D) where the prompt says things will change.

7. Common mistakes

Abstracting everythingInterfaces with one implementation that will never change are noise.
God classesA Manager that parses, validates, calculates and saves has many reasons to change.
Subclasses that refuse parent methodsthrow new UnsupportedOperationException is a Liskov warning sign.
new inside business logicHard-coded concrete dependencies can't be swapped or tested.

8. Practice exercise (25 minutes)

  1. Add a rule (5 min). Write a ThresholdDiscount ($5 off over $50) for the code below. Did anything else change?
  2. Find a god class (10 min). In code you know, list a class's reasons to change. Split one out.
  3. Spot Liskov (5 min). Find a subclass that can't stand in for its parent.
  4. Inject (5 min). Find a new X() inside business logic and pass it in instead.

9. Self-check quiz

A new promotion type needs an edit to PriceCalculator's switch statement. Which principle is broken?

New behaviour should arrive as new code, not edits.

Square extends Rectangle, but setting the width also changes the height. Which principle?

Callers of Rectangle are surprised.

What does dependency inversion ask for?

High-level code shouldn't name low-level classes.

When should you not add an interface?

Abstraction has a cost; pay it where change is likely.

10. Go deeper

ResourceTimeWhy
Inside a service: SOLID, patterns, hexagonal (Software architecture)30 minThe same principles at service level.
Modules 03a–03e: each principle in depthNextOne module per principle, with its own lab and code.
Module 04: Design patternsLaterPatterns are SOLID applied to recurring problems.
P06: Parking lot45 minOpen/closed in the spot finder and the fee calculator.