Part 01 · Low Level Design
SOLID Principles
Each SOLID letter taught through the smell it fixes: the class that does everything, the if-else that keeps growing, the subclass that surprises, the fat interface, the hard-wired database.
⏱ 2-3 hours · Station 6 of 80
Every Eid, the invoice code breaks
A shop's invoice code is one class: InvoiceService. It works out the total, applies discounts with a long if/else, formats the PDF text, and saves to Postgres with SQL inside the same methods.
Every Eid, marketing adds a new discount. Every Eid, someone edits that if/else, and the PDF layout or the save breaks in the same commit, because they live in the same 600 lines. The tests need a real database, so nobody runs them before Eid.
None of this is one big mistake. It's five small ones, and they're common enough that someone gave them a name: SOLID. Each letter is a smell you can spot in a code review, and a fix for it.
The problem: five smells in one class
| Smell you notice | The letter that fixes it |
|---|---|
| One class changes for pricing, PDF layout and database reasons | Single Responsibility |
Every new discount means editing the same if/else | Open/Closed |
| A "special" discount returns a negative number and breaks totals | Liskov Substitution |
| Checkout depends on a store interface with 15 methods but uses 1 | Interface Segregation |
Business logic news a Postgres class, so tests need a database | Dependency Inversion |
Think of a restaurant kitchen
In a good kitchen, the chef cooks, the cashier bills, the cleaner cleans: one job each (S). The menu gets a new dish by adding a recipe card, not by rewriting the kitchen (O). Any cook on shift can make the same biryani the card describes; a substitute who "improves" it with no rice breaks the promise (L). The delivery rider is told "pick up order 42", not handed the whole kitchen manual (I). And the chef asks for "a gas supply", not "a cylinder from the shop on Road 7", so the supplier can change without changing the cooking (D).
Where the comparison breaks down. A kitchen has people who adapt. In code, every boundary is a decision you write down: an interface, a separate type, a constructor parameter. Too many boundaries are as painful as too few, so apply SOLID where change actually happens.
Discounts are a list of objects that each implement Discount (Name, Amount). Marketing adds 'bKash cashback, 5%'. Which code changes?
Five letters, five smells
- S, Single Responsibility: a class should have one reason to change. Not "one method", but one owner or one kind of change. Calculating, printing and saving change for different reasons (pricing team, design team, infrastructure), so they're separate.
- O, Open/Closed: code should be open for extension, closed for modification. When a new variant arrives (a discount, a payment method), you add a type, you don't edit a switch. Usually done with an interface and a list of implementations.
- L, Liskov Substitution: any implementation must be usable wherever the interface is expected, without surprising the caller. If
Discountpromises "0 to subtotal", a discount that returns −50 or 2× the subtotal breaks every caller, even though it compiles. Subclasses and implementations must keep the parent's promises. - I, Interface Segregation: callers shouldn't depend on methods they don't use. Checkout only saves, so it asks for
InvoiceSaver{Save}, not a 15-methodInvoiceRepository. Small interfaces are easy to implement, fake and reason about. - D, Dependency Inversion: high-level policy (checkout) shouldn't depend on low-level detail (Postgres). Both should depend on an abstraction (
InvoiceSaver), and the detail is passed in (dependency injection). Then tests pass a memory store and production passes Postgres. - They're tools, not laws. Each letter adds a boundary. Add it where change is real (discounts change every Eid) and skip it where it isn't (the invoice ID format hasn't changed in years).
One invoice through the redesigned code
Checkout.Process("INV-1", Rahim, 1200)
Step 1/5 Checkout coordinates, nothing else
It asks the calculator for an invoice, the printer to print it, and the saver to save it. Its own code has no pricing, formatting or SQL.
- customer
- Rahim (student)
- subtotal
- 1200
Five questions for a code review
- S: who would ask me to change this class? If the answer has more than one team in it, split it.
- O: when the next variant comes, will I add code or edit code?
- L: could any implementation of this interface surprise a caller that only read the interface?
- I: does this caller use every method of the interface it asks for?
- D: does my business logic
newa database, HTTP client or clock? Pass it in instead.
Try it: which letter is broken?
Name the SOLID smell
class UserService { 900 lines }The invoice in code
A Calculator that only calculates and loops over Discount rules (S, O, L), a separate printer (S), and a Checkout that receives a small InvoiceSaver interface instead of creating a database (I, D).
package main
import "fmt"
type Customer struct {
Name string
Student bool
}
type Applied struct {
Name string
Amount int
}
type Invoice struct {
ID string
Customer Customer
Subtotal int
Discounts []Applied
Total int
}
// OCP: a new discount is a new type, not an edit to existing code.
// LSP: every Discount must honour the same promise: return 0..subtotal, never negative.
type Discount interface {
Name() string
Amount(c Customer, subtotal int) int
}
type PercentOff struct {
Label string
Percent int
}
func (p PercentOff) Name() string { return p.Label }
func (p PercentOff) Amount(_ Customer, sub int) int { return sub * p.Percent / 100 }
type StudentOff struct{ Taka int }
func (StudentOff) Name() string { return "student" }
func (s StudentOff) Amount(c Customer, sub int) int {
if !c.Student {
return 0
}
return min(s.Taka, sub)
}
// SRP: Calculator only works out numbers.
type Calculator struct{ rules []Discount }
func (calc Calculator) Build(id string, c Customer, subtotal int) Invoice {
inv := Invoice{ID: id, Customer: c, Subtotal: subtotal, Total: subtotal}
for _, r := range calc.rules {
if a := r.Amount(c, subtotal); a > 0 {
inv.Discounts = append(inv.Discounts, Applied{r.Name(), a})
inv.Total -= a
}
}
return inv
}
// SRP: printing is a separate job with a separate reason to change.
func Print(inv Invoice) {
fmt.Printf("invoice %s for %s\n", inv.ID, inv.Customer.Name)
fmt.Printf(" %-15s %5d tk\n", "subtotal", inv.Subtotal)
for _, d := range inv.Discounts {
fmt.Printf(" %-15s %5d tk\n", "- "+d.Name, d.Amount)
}
fmt.Printf(" %-15s %5d tk\n", "total", inv.Total)
}
// ISP: checkout only needs to save, so it asks for a small interface, not the whole store.
type InvoiceSaver interface {
Save(inv Invoice) string
}
// MemoryStore can do more (Count), but checkout never sees that.
type MemoryStore struct{ saved []Invoice }
func (m *MemoryStore) Save(inv Invoice) string { m.saved = append(m.saved, inv); return "memory" }
func (m *MemoryStore) Count() int { return len(m.saved) }
// DIP: Checkout depends on abstractions (Discount, InvoiceSaver), not on a database class.
type Checkout struct {
calc Calculator
saver InvoiceSaver
}
func (co Checkout) Process(id string, c Customer, subtotal int) {
inv := co.calc.Build(id, c, subtotal)
Print(inv)
fmt.Printf("saved %s to %s\n", inv.ID, co.saver.Save(inv))
}
func main() {
store := &MemoryStore{}
co := Checkout{
calc: Calculator{rules: []Discount{PercentOff{"eid sale 10%", 10}, StudentOff{Taka: 50}}},
saver: store,
}
co.Process("INV-1", Customer{"Rahim", true}, 1200)
co.Process("INV-2", Customer{"Nadia", false}, 300)
fmt.Println("store has", store.Count(), "invoices")
}from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol
@dataclass(frozen=True)
class Customer:
name: str
student: bool
@dataclass
class Invoice:
id: str
customer: Customer
subtotal: int
discounts: list[tuple[str, int]] = field(default_factory=list)
total: int = 0
class Discount(Protocol):
"""OCP: a new discount is a new class, not an edit to existing code.
LSP: every Discount must honour the same promise: return 0..subtotal, never negative."""
name: str
def amount(self, c: Customer, subtotal: int) -> int: ...
class PercentOff:
def __init__(self, label: str, percent: int):
self.name, self.percent = label, percent
def amount(self, c: Customer, subtotal: int) -> int:
return subtotal * self.percent // 100
class StudentOff:
name = "student"
def __init__(self, taka: int):
self.taka = taka
def amount(self, c: Customer, subtotal: int) -> int:
return min(self.taka, subtotal) if c.student else 0
class Calculator:
"""SRP: only works out numbers."""
def __init__(self, rules: list[Discount]):
self.rules = rules
def build(self, id: str, c: Customer, subtotal: int) -> Invoice:
inv = Invoice(id, c, subtotal, total=subtotal)
for r in self.rules:
a = r.amount(c, subtotal)
if a > 0:
inv.discounts.append((r.name, a))
inv.total -= a
return inv
def print_invoice(inv: Invoice) -> None:
"""SRP: printing is a separate job with a separate reason to change."""
print(f"invoice {inv.id} for {inv.customer.name}")
print(f" {'subtotal':<15} {inv.subtotal:>5} tk")
for name, amount in inv.discounts:
print(f" {'- ' + name:<15} {amount:>5} tk")
print(f" {'total':<15} {inv.total:>5} tk")
class InvoiceSaver(Protocol):
"""ISP: checkout only needs to save, so it asks for a small interface."""
def save(self, inv: Invoice) -> str: ...
class MemoryStore:
"""Can do more (count), but checkout never sees that."""
def __init__(self) -> None:
self.saved: list[Invoice] = []
def save(self, inv: Invoice) -> str:
self.saved.append(inv)
return "memory"
def count(self) -> int:
return len(self.saved)
class Checkout:
"""DIP: depends on abstractions (Discount, InvoiceSaver), not on a database class."""
def __init__(self, calc: Calculator, saver: InvoiceSaver):
self.calc, self.saver = calc, saver
def process(self, id: str, c: Customer, subtotal: int) -> None:
inv = self.calc.build(id, c, subtotal)
print_invoice(inv)
print(f"saved {inv.id} to {self.saver.save(inv)}")
store = MemoryStore()
co = Checkout(Calculator([PercentOff("eid sale 10%", 10), StudentOff(50)]), store)
co.process("INV-1", Customer("Rahim", True), 1200)
co.process("INV-2", Customer("Nadia", False), 300)
print("store has", store.count(), "invoices")interface Customer {
readonly name: string;
readonly student: boolean;
}
interface Invoice {
readonly id: string;
readonly customer: Customer;
readonly subtotal: number;
readonly discounts: { name: string; amount: number }[];
total: number;
}
// OCP: a new discount is a new class, not an edit to existing code.
// LSP: every Discount must honour the same promise: return 0..subtotal, never negative.
interface Discount {
readonly name: string;
amount(c: Customer, subtotal: number): number;
}
class PercentOff implements Discount {
readonly name: string;
readonly percent: number;
constructor(label: string, percent: number) {
this.name = label;
this.percent = percent;
}
amount(_c: Customer, subtotal: number): number {
return Math.floor((subtotal * this.percent) / 100);
}
}
class StudentOff implements Discount {
readonly name = "student";
readonly taka: number;
constructor(taka: number) {
this.taka = taka;
}
amount(c: Customer, subtotal: number): number {
return c.student ? Math.min(this.taka, subtotal) : 0;
}
}
// SRP: only works out numbers.
class Calculator {
readonly rules: Discount[];
constructor(rules: Discount[]) {
this.rules = rules;
}
build(id: string, customer: Customer, subtotal: number): Invoice {
const inv: Invoice = { id, customer, subtotal, discounts: [], total: subtotal };
for (const r of this.rules) {
const a = r.amount(customer, subtotal);
if (a > 0) {
inv.discounts.push({ name: r.name, amount: a });
inv.total -= a;
}
}
return inv;
}
}
// SRP: printing is a separate job with a separate reason to change.
function printInvoice(inv: Invoice): void {
const row = (label: string, n: number) => console.log(` ${label.padEnd(15)} ${String(n).padStart(5)} tk`);
console.log(`invoice ${inv.id} for ${inv.customer.name}`);
row("subtotal", inv.subtotal);
for (const d of inv.discounts) row(`- ${d.name}`, d.amount);
row("total", inv.total);
}
// ISP: checkout only needs to save, so it asks for a small interface.
interface InvoiceSaver {
save(inv: Invoice): string;
}
// Can do more (count), but checkout never sees that.
class MemoryStore implements InvoiceSaver {
readonly saved: Invoice[] = [];
save(inv: Invoice): string {
this.saved.push(inv);
return "memory";
}
count(): number {
return this.saved.length;
}
}
// DIP: depends on abstractions (Discount, InvoiceSaver), not on a database class.
class Checkout {
readonly calc: Calculator;
readonly saver: InvoiceSaver;
constructor(calc: Calculator, saver: InvoiceSaver) {
this.calc = calc;
this.saver = saver;
}
process(id: string, c: Customer, subtotal: number): void {
const inv = this.calc.build(id, c, subtotal);
printInvoice(inv);
console.log(`saved ${inv.id} to ${this.saver.save(inv)}`);
}
}
const store = new MemoryStore();
const co = new Checkout(new Calculator([new PercentOff("eid sale 10%", 10), new StudentOff(50)]), store);
co.process("INV-1", { name: "Rahim", student: true }, 1200);
co.process("INV-2", { name: "Nadia", student: false }, 300);
console.log("store has", store.count(), "invoices");struct Customer { name: String, student: bool }
struct Invoice { id: String, customer_name: String, subtotal: u32, discounts: Vec<(String, u32)>, total: u32 }
// OCP: a new discount is a new type, not an edit to existing code.
// LSP: every Discount must honour the same promise: return 0..=subtotal.
trait Discount {
fn name(&self) -> &str;
fn amount(&self, c: &Customer, subtotal: u32) -> u32;
}
struct PercentOff { label: String, percent: u32 }
impl Discount for PercentOff {
fn name(&self) -> &str { &self.label }
fn amount(&self, _: &Customer, sub: u32) -> u32 { sub * self.percent / 100 }
}
struct StudentOff { taka: u32 }
impl Discount for StudentOff {
fn name(&self) -> &str { "student" }
fn amount(&self, c: &Customer, sub: u32) -> u32 { if c.student { self.taka.min(sub) } else { 0 } }
}
// SRP: only works out numbers.
struct Calculator { rules: Vec<Box<dyn Discount>> }
impl Calculator {
fn build(&self, id: &str, c: &Customer, subtotal: u32) -> Invoice {
let mut inv = Invoice { id: id.into(), customer_name: c.name.clone(), subtotal, discounts: vec![], total: subtotal };
for r in &self.rules {
let a = r.amount(c, subtotal);
if a > 0 {
inv.discounts.push((r.name().to_string(), a));
inv.total -= a;
}
}
inv
}
}
// SRP: printing is a separate job with a separate reason to change.
fn print_invoice(inv: &Invoice) {
println!("invoice {} for {}", inv.id, inv.customer_name);
println!(" {:<15} {:>5} tk", "subtotal", inv.subtotal);
for (name, amount) in &inv.discounts {
println!(" {:<15} {:>5} tk", format!("- {name}"), amount);
}
println!(" {:<15} {:>5} tk", "total", inv.total);
}
// ISP: checkout only needs to save, so it asks for a small trait.
trait InvoiceSaver { fn save(&mut self, inv: Invoice) -> String; }
// Can do more (count), but checkout never sees that.
struct MemoryStore { saved: Vec<Invoice> }
impl InvoiceSaver for MemoryStore {
fn save(&mut self, inv: Invoice) -> String { self.saved.push(inv); "memory".into() }
}
impl MemoryStore { fn count(&self) -> usize { self.saved.len() } }
// DIP: depends on abstractions (Discount, InvoiceSaver), not on a database type.
struct Checkout<'a> { calc: Calculator, saver: &'a mut dyn InvoiceSaver }
impl Checkout<'_> {
fn process(&mut self, id: &str, c: &Customer, subtotal: u32) {
let inv = self.calc.build(id, c, subtotal);
print_invoice(&inv);
let id = inv.id.clone();
println!("saved {id} to {}", self.saver.save(inv));
}
}
fn main() {
let mut store = MemoryStore { saved: vec![] };
{
let calc = Calculator { rules: vec![
Box::new(PercentOff { label: "eid sale 10%".into(), percent: 10 }),
Box::new(StudentOff { taka: 50 }),
] };
let mut co = Checkout { calc, saver: &mut store };
co.process("INV-1", &Customer { name: "Rahim".into(), student: true }, 1200);
co.process("INV-2", &Customer { name: "Nadia".into(), student: false }, 300);
}
println!("store has {} invoices", store.count());
}import java.util.ArrayList;
import java.util.List;
public class Main {
record Customer(String name, boolean student) {}
record Applied(String name, int amount) {}
record Invoice(String id, Customer customer, int subtotal, List<Applied> discounts, int total) {}
// OCP: a new discount is a new class, not an edit to existing code.
// LSP: every Discount must honour the same promise: return 0..subtotal, never negative.
interface Discount {
String name();
int amount(Customer c, int subtotal);
}
record PercentOff(String name, int percent) implements Discount {
public int amount(Customer c, int subtotal) { return subtotal * percent / 100; }
}
record StudentOff(int taka) implements Discount {
public String name() { return "student"; }
public int amount(Customer c, int subtotal) { return c.student() ? Math.min(taka, subtotal) : 0; }
}
// SRP: only works out numbers.
record Calculator(List<Discount> rules) {
Invoice build(String id, Customer c, int subtotal) {
List<Applied> applied = new ArrayList<>();
int total = subtotal;
for (Discount r : rules) {
int a = r.amount(c, subtotal);
if (a > 0) {
applied.add(new Applied(r.name(), a));
total -= a;
}
}
return new Invoice(id, c, subtotal, applied, total);
}
}
// SRP: printing is a separate job with a separate reason to change.
static void print(Invoice inv) {
System.out.printf("invoice %s for %s%n", inv.id(), inv.customer().name());
System.out.printf(" %-15s %5d tk%n", "subtotal", inv.subtotal());
for (Applied d : inv.discounts()) System.out.printf(" %-15s %5d tk%n", "- " + d.name(), d.amount());
System.out.printf(" %-15s %5d tk%n", "total", inv.total());
}
// ISP: checkout only needs to save, so it asks for a small interface.
interface InvoiceSaver {
String save(Invoice inv);
}
// Can do more (count), but checkout never sees that.
static final class MemoryStore implements InvoiceSaver {
private final List<Invoice> saved = new ArrayList<>();
public String save(Invoice inv) { saved.add(inv); return "memory"; }
int count() { return saved.size(); }
}
// DIP: depends on abstractions (Discount, InvoiceSaver), not on a database class.
record Checkout(Calculator calc, InvoiceSaver saver) {
void process(String id, Customer c, int subtotal) {
Invoice inv = calc.build(id, c, subtotal);
print(inv);
System.out.printf("saved %s to %s%n", inv.id(), saver.save(inv));
}
}
public static void main(String[] args) {
MemoryStore store = new MemoryStore();
Checkout co = new Checkout(new Calculator(List.of(new PercentOff("eid sale 10%", 10), new StudentOff(50))), store);
co.process("INV-1", new Customer("Rahim", true), 1200);
co.process("INV-2", new Customer("Nadia", false), 300);
System.out.println("store has " + store.count() + " invoices");
}
}#include <algorithm>
#include <cstdio>
#include <memory>
#include <string>
#include <utility>
#include <vector>
struct Customer {
std::string name;
bool student;
};
struct Invoice {
std::string id;
Customer customer;
int subtotal;
std::vector<std::pair<std::string, int>> discounts;
int total;
};
// OCP: a new discount is a new class, not an edit to existing code.
// LSP: every Discount must honour the same promise: return 0..subtotal, never negative.
class Discount {
public:
virtual ~Discount() = default;
virtual std::string name() const = 0;
virtual int amount(const Customer& c, int subtotal) const = 0;
};
class PercentOff : public Discount {
public:
PercentOff(std::string label, int percent) : label_(std::move(label)), percent_(percent) {}
std::string name() const override { return label_; }
int amount(const Customer&, int sub) const override { return sub * percent_ / 100; }
private:
std::string label_;
int percent_;
};
class StudentOff : public Discount {
public:
explicit StudentOff(int taka) : taka_(taka) {}
std::string name() const override { return "student"; }
int amount(const Customer& c, int sub) const override { return c.student ? std::min(taka_, sub) : 0; }
private:
int taka_;
};
// SRP: only works out numbers.
class Calculator {
public:
explicit Calculator(std::vector<std::unique_ptr<Discount>> rules) : rules_(std::move(rules)) {}
Invoice build(const std::string& id, const Customer& c, int subtotal) const {
Invoice inv{id, c, subtotal, {}, subtotal};
for (const auto& r : rules_) {
if (int a = r->amount(c, subtotal); a > 0) {
inv.discounts.push_back({r->name(), a});
inv.total -= a;
}
}
return inv;
}
private:
std::vector<std::unique_ptr<Discount>> rules_;
};
// SRP: printing is a separate job with a separate reason to change.
void print(const Invoice& inv) {
std::printf("invoice %s for %s\n", inv.id.c_str(), inv.customer.name.c_str());
std::printf(" %-15s %5d tk\n", "subtotal", inv.subtotal);
for (const auto& [name, amount] : inv.discounts) std::printf(" %-15s %5d tk\n", ("- " + name).c_str(), amount);
std::printf(" %-15s %5d tk\n", "total", inv.total);
}
// ISP: checkout only needs to save, so it asks for a small interface.
class InvoiceSaver {
public:
virtual ~InvoiceSaver() = default;
virtual std::string save(const Invoice& inv) = 0;
};
// Can do more (count), but checkout never sees that.
class MemoryStore : public InvoiceSaver {
public:
std::string save(const Invoice& inv) override {
saved_.push_back(inv);
return "memory";
}
size_t count() const { return saved_.size(); }
private:
std::vector<Invoice> saved_;
};
// DIP: depends on abstractions (Discount, InvoiceSaver), not on a database class.
class Checkout {
public:
Checkout(Calculator calc, InvoiceSaver& saver) : calc_(std::move(calc)), saver_(saver) {}
void process(const std::string& id, const Customer& c, int subtotal) {
Invoice inv = calc_.build(id, c, subtotal);
print(inv);
std::printf("saved %s to %s\n", inv.id.c_str(), saver_.save(inv).c_str());
}
private:
Calculator calc_;
InvoiceSaver& saver_;
};
int main() {
MemoryStore store;
std::vector<std::unique_ptr<Discount>> rules;
rules.push_back(std::make_unique<PercentOff>("eid sale 10%", 10));
rules.push_back(std::make_unique<StudentOff>(50));
Checkout co(Calculator(std::move(rules)), store);
co.process("INV-1", {"Rahim", true}, 1200);
co.process("INV-2", {"Nadia", false}, 300);
std::printf("store has %zu invoices\n", store.count());
}All six print exactly this:
invoice INV-1 for Rahim
subtotal 1200 tk
- eid sale 10% 120 tk
- student 50 tk
total 1030 tk
saved INV-1 to memory
invoice INV-2 for Nadia
subtotal 300 tk
- eid sale 10% 30 tk
total 270 tk
saved INV-2 to memory
store has 2 invoices
Reading the output:
- INV-1: Rahim is a student. Eid sale 10% of 1200 = 120, student 50, total 1030. Both rules ran without the calculator knowing what they are.
- INV-2: Nadia isn't a student, so
StudentOffreturns 0 and the calculator leaves it off the invoice. 10% of 300 = 30, total 270. - "saved … to memory": Checkout called
Saveon an interface. Swap in a Postgres saver and these lines would saypostgres, with no change to Checkout. - "store has 2 invoices":
Countexists on the memory store, but Checkout never sees it, because it asked only forInvoiceSaver(ISP). - What each language changed: Go interfaces are satisfied implicitly, so
MemoryStorenever says "implements". Java uses records for the discounts, which keeps them tiny. Rust usesBox<dyn Discount>for the rule list and&mut dyn InvoiceSaverfor the store; the borrow ends at the closing brace, sostore.count()is allowed afterwards. C++ owns the rules inunique_ptrand takes the saver by reference, which says "I use it, I don't own it".
SOLID on a real codebase
- SRP is about people, not lines. A 300-line class that one team owns and changes for one reason is fine. A 60-line class that three teams fight over isn't.
- OCP works best with a list or registry. New behaviour is a new type added in one place (the list of discount rules, the map of payment methods). The core loop never changes.
- Write the contract down. For LSP to hold, the interface's promise must be explicit: "returns 0..subtotal", "never blocks", "idempotent". Put it in a comment and a shared test that every implementation runs.
- Prefer many small interfaces, defined by the caller. In Go especially, the consumer declares the interface it needs (
InvoiceSaver), and big types satisfy it without knowing. - DIP is what makes unit tests fast. Inject the database, the clock, the HTTP client and the random number generator; tests pass fakes and run in milliseconds.
- Don't apply all five everywhere. An interface per class and a factory per interface is over-engineering. SOLID is for the parts that change.
Designs that cause problems later
Pick the design that will hurt
ServiceFee.Amount() returns -30
Pause and think
A new rule arrives: "the student discount can't be combined with any other discount; give the customer whichever is larger." Can you add it without editing the calculator?
Reveal
Not as a plain new Discount, because it's a rule about how discounts combine, not a discount. That's a real change in policy, so it's fair for it to touch the calculator, or better, to introduce one new concept: a CombinePolicy (sum all / take the best / exclusive groups) that the calculator asks. Open/Closed doesn't mean "never edit anything"; it means the common kind of change (another discount) needs no edits. When a new kind of change appears, you add a new extension point for it, once.
Quick quiz
1. What does "one reason to change" mean in SRP?
Answer
All the code in a class should change for the same kind of reason, usually requested by the same person or team. Pricing rules, PDF layout and storage are three reasons, so three places.
2. How does an interface plus a list of implementations satisfy Open/Closed?
Answer
New behaviour is added as a new implementation registered in the list; the code that loops over the list doesn't change.
3. Give an example of breaking Liskov Substitution.
Answer
An implementation that violates the interface's promise, like a discount returning a negative amount, or a read-only file whose Write throws, so callers that trust the interface get surprised.
4. What's the difference between dependency inversion and dependency injection?
Answer
Inversion is the design rule: high-level code depends on an abstraction, not a concrete low-level class. Injection is the technique: the concrete object is passed in (usually via the constructor) instead of being created inside.
Your challenge: bKash cashback
Marketing adds a third discount: bKash cashback, 5%, for customers paying by bKash.
- Add a
PaysByBkashfield to the customer and a new discount type that gives 5% when it's true. - Add it to the rule list. Don't touch the calculator, printer or checkout.
- Make Rahim pay by bKash.
Before you run it, predict Rahim's new total.
You've succeeded when INV-1 shows a new line - bkash 5% 60 tk and total 970 tk, INV-2 is unchanged, and Calculator, the printer and Checkout have no edits.
In one line
One reason to change per class, new behaviour as new types instead of new branches, implementations that keep their interface's promises, small interfaces shaped by the caller, and business logic that receives its dependencies instead of creating them. Use SOLID where change really happens.
Station stamp
Finish the chapter
Done reading? Press Complete to save your progress. XP is Pro-only — on Pro this chapter is worth +10 XP.
Go Pro to earn XP →