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
| Principle | In one line | The smell it fixes |
|---|---|---|
| Single responsibility | One reason to change per class | A class that calculates, formats and saves |
| Open/closed | Add behaviour with new code, not edits | A switch that grows with every new type |
| Liskov substitution | Subclasses keep their parent's promises | A subclass that throws "not supported" |
| Interface segregation | Small, focused interfaces | Implementers stuck with methods they don't need |
| Dependency inversion | Depend on interfaces; inject the concrete ones | new 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
- Name the principle when you use it, briefly: "pricing rules go behind an interface so new promotions don't touch the calculator (open/closed)".
- Inject dependencies through the constructor. It makes the design testable, and interviewers notice.
- Don't over-apply. Five interfaces for a tic-tac-toe board is worse than none. Use SOLID where change is likely.
- Liskov is the subtle one: the classic example is Square extending Rectangle, which breaks callers that set width and height independently.
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:
| Principle | The question | What changes |
|---|---|---|
| S | Who 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. |
| O | What keeps growing? | Discount tiers: the switch becomes DiscountRule objects, so a new tier is a new class. |
| L | Can 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. |
| I | What does each caller need? | The front-desk screen needs checkout and the invoice; the night audit needs totals only: two small interfaces. |
| D | What 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
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 kitchen | SOLID |
|---|---|
| A station per job | S |
| A new appliance, no rewiring | O |
| Any pan that fits the hob | L |
| Each cook's own toolset | I |
| Standard sockets | D |
Watch it on an example
- One job: ReceiptPrinter is separate from PriceCalculator.
- Add don't edit: BuyTwoGetOneFree is a new class; the calculator is untouched.
- 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
8. Practice exercise (25 minutes)
- Add a rule (5 min). Write a ThresholdDiscount ($5 off over $50) for the code below. Did anything else change?
- Find a god class (10 min). In code you know, list a class's reasons to change. Split one out.
- Spot Liskov (5 min). Find a subclass that can't stand in for its parent.
- 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?
Square extends Rectangle, but setting the width also changes the height. Which principle?
What does dependency inversion ask for?
When should you not add an interface?
10. Go deeper
| Resource | Time | Why |
|---|---|---|
| Inside a service: SOLID, patterns, hexagonal (Software architecture) | 30 min | The same principles at service level. |
| Modules 03a–03e: each principle in depth | Next | One module per principle, with its own lab and code. |
| Module 04: Design patterns | Later | Patterns are SOLID applied to recurring problems. |
| P06: Parking lot | 45 min | Open/closed in the spot finder and the fee calculator. |