Part 01 · Low Level Design

SOLID Principles

SRPOCPLSPISPDIP

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 noticeThe letter that fixes it
One class changes for pricing, PDF layout and database reasonsSingle Responsibility
Every new discount means editing the same if/elseOpen/Closed
A "special" discount returns a negative number and breaks totalsLiskov Substitution
Checkout depends on a store interface with 15 methods but uses 1Interface Segregation
Business logic news a Postgres class, so tests need a databaseDependency 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).

depends onimplementsCheckouthigh-level policyInvoiceSaverinterface: Save()MemoryStoretestsPostgresStoreproductionSave(inv)Save(inv)both stores point at the interfaceswap the store: Checkout's code doesn't change
Press play. Checkout knows only the InvoiceSaver interface. The same Save call lands in the memory store first, then in Postgres, and Checkout's code never changes.

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.

Predict first

Discounts are a list of objects that each implement Discount (Name, Amount). Marketing adds 'bKash cashback, 5%'. Which code changes?

Five letters, five smells

  1. 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.
  2. 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.
  3. L, Liskov Substitution: any implementation must be usable wherever the interface is expected, without surprising the caller. If Discount promises "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.
  4. 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-method InvoiceRepository. Small interfaces are easy to implement, fake and reason about.
  5. 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.
  6. 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

Step through it

Checkout.Process("INV-1", Rahim, 1200)

CheckoutCalculatorDiscount rulesPrinterInvoiceSaver

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 new a database, HTTP client or clock? Pass it in instead.

Try it: which letter is broken?

Try each case

Name the SOLID smell

class UserService { 900 lines }
breaks: S. Four reasons to change. Split validation, password hashing, email and storage.

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")
}

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 StudentOff returns 0 and the calculator leaves it off the invoice. 10% of 300 = 30, total 270.
  • "saved … to memory": Checkout called Save on an interface. Swap in a Postgres saver and these lines would say postgres, with no change to Checkout.
  • "store has 2 invoices": Count exists on the memory store, but Checkout never sees it, because it asked only for InvoiceSaver (ISP).
  • What each language changed: Go interfaces are satisfied implicitly, so MemoryStore never says "implements". Java uses records for the discounts, which keeps them tiny. Rust uses Box<dyn Discount> for the rule list and &mut dyn InvoiceSaver for the store; the borrow ends at the closing brace, so store.count() is allowed afterwards. C++ owns the rules in unique_ptr and 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

Try each case

Pick the design that will hurt

ServiceFee.Amount() returns -30
breaks: Breaks LSP: callers assume discounts reduce the total. Model fees as their own concept with their own rules.

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 PaysByBkash field 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 →