There is a script like this in every codebase, and you have almost certainly written one. It fetches a wholesale price from a supplier’s API, applies your pricing rules, writes the quote to a database, logs what it did, and exits. Sixty lines, one main(), and it works perfectly.
#!/usr/bin/env python3
"""repricer - the 'before': one function that does everything."""
import argparse
import logging
import sqlite3
import sys
from datetime import datetime, timezone
import requests
def main():
parser = argparse.ArgumentParser(prog="repricer")
parser.add_argument("sku")
parser.add_argument("-q", "--qty", type=int, default=1)
parser.add_argument("--db", default="quotes.db")
parser.add_argument("--api", default="https://supplier.example.com")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
# --- fetch ---
resp = requests.get(f"{args.api}/skus/{args.sku}", timeout=5)
resp.raise_for_status()
wholesale = int(resp.json()["wholesale_cents"])
# --- rules: markup 40%, bulk tiers, tax 20%, charm .99 ---
price = (wholesale * 140 + 50) // 100
if args.qty >= 100:
disc = 15
elif args.qty >= 25:
disc = 10
elif args.qty >= 10:
disc = 5
else:
disc = 0
price = (price * (100 - disc) + 50) // 100
price = (price * 120 + 50) // 100
price = (price // 100) * 100 + 99
total = price * args.qty
# --- persist ---
conn = sqlite3.connect(args.db)
conn.execute(
"CREATE TABLE IF NOT EXISTS quotes (sku TEXT, qty INTEGER,"
" unit_retail_cents INTEGER, line_total_cents INTEGER, created_at TEXT)"
)
conn.execute(
"INSERT INTO quotes VALUES (?, ?, ?, ?, ?)",
(args.sku, args.qty, price, total, datetime.now(timezone.utc).isoformat()),
)
conn.commit()
conn.close()
# --- report ---
logging.info("quoted %s x%d", args.sku, args.qty)
print(f"{args.sku} x{args.qty} {price / 100:.2f}/unit total {total / 100:.2f}")
return 0
if __name__ == "__main__":
sys.exit(main())
It runs, and it is right:
INFO quoted WIDGET-1 x10
WIDGET-1 x10 15.99/unit total 159.90
Nothing is wrong with this code in the sense of bugs. It is wrong in the sense that will cost you, and the cost arrives the day someone asks a reasonable question. Write a test for the pricing rules. You can’t, not without standing up an HTTP server, creating a database file and capturing stdout — because the arithmetic you want to check is welded to a network call and a conn.commit(). Point it at Postgres. You can’t, without editing the function that also parses arguments. Charge tax differently per region. Now the if-ladder grows a second dimension inside a function that also does I/O. Every change touches everything, because everything is in one place.
This lesson is about the discipline that fixes that, and it has a lot of names — SOLID, separation of concerns, hexagonal architecture, dependency injection — most of which were coined for other languages and get parroted into Python badly. We are going to take them honestly, keep the parts that earn their place in a duck-typed language, and cheerfully drop the ceremony that doesn’t. Then we are going to refactor this exact script, live, until the pricing rules are tested with no mocks at all and the whole flow is tested with three tiny fakes and no network. Everything below was run on Python 3.12.3, with real output.
Why this matters
Here is the whole problem in one measurement. Pick any function and ask: what do I have to import, start, or fake to test this in isolation? For main() above, the honest answer is the internet, a filesystem, the system clock, and sys.argv. That is the number that matters, and it is the number this lesson drives to zero for the code you actually care about.
Testability is the symptom, not the disease. The disease is coupling: the pricing arithmetic depends on the network because they share a function, so you cannot exercise one without the other. Cut that dependency and testability falls out for free — and so does everything else you wanted. Once the rules don’t depend on the database, you can change databases without touching the rules. Once they don’t depend on requests, you can price a quote from a CSV, a queue, or a unit test. Decoupling is one move that pays out in five directions: testing, change, reuse, parallel work, and comprehension.
The mental model for the entire lesson is two words: pure core, imperative shell. Your program has logic (given these numbers, what is the price?) and it has effects (fetch, write, log, print). Logic is a function of its inputs — deterministic, repeatable, trivial to test. Effects touch the world — non-deterministic, slow, and the reason tests need scaffolding. The god-script fails because it interleaves them line by line. The fix is to pull them apart: push every effect out to the edges of the program, and leave a core in the middle that is nothing but functions of their inputs.
The tangled version (main()) |
The goal (pure core) | |
|---|---|---|
| To test the pricing math you need | An HTTP server, a DB file, sys.argv, captured stdout |
Nothing — call the function |
| To change the database you edit | The function that also parses args and prices | One adapter file |
| To reuse the rules elsewhere you | Copy-paste them out of main() |
import them |
| A new pricing rule touches | A function that also does I/O | One pure function |
| Two people editing at once | Conflict in main() |
Different files |
You already met the seed of this idea in Project Structure, Packaging & Documentation, which split its example into core.py (the logic, no I/O) and cli.py (the argument parsing and printing) with the one-line justification “core is testable without a terminal.” This lesson is that sentence, taken all the way down.
SOLID, translated honestly for Python
SOLID is five principles Robert Martin assembled around 2000, in and for a world of statically-typed, interface-heavy, deeply-inherited Java and C++. That origin matters, because a lot of SOLID advice is really advice for languages where the compiler forces you to name every type up front. Python doesn’t. Duck typing means a caller depends on the methods it calls, not on a declared type — so some of SOLID’s ceremony is solving a problem you don’t have, while its underlying spirit is as valuable in Python as anywhere.
So let’s be honest letter by letter, rather than reciting it. Here is the whole of SOLID, what it looks like in its Java homeland, and what actually survives translation:
| Letter | Principle | Java-era mechanism | Python reality | Weight in Python |
|---|---|---|---|---|
| S | Single Responsibility | One class, one job | Modules and functions with one reason to change | Full — language-agnostic |
| O | Open/Closed | Abstract base + subclasses | Pass a function; register a handler; Protocol |
Full, but the mechanism is lighter |
| L | Liskov Substitution | Subtype honours base’s contract | Same — wherever you do subclass | Full, but you subclass less |
| I | Interface Segregation | Split fat interfaces |
Duck typing already does this; Protocol makes it explicit |
Mostly free — ceremony dissolves |
| D | Dependency Inversion | Depend on interfaces, wire with a container | Depend on a Protocol; inject via arguments |
Full, and no container needed |
The short version: SRP, LSP and DIP carry their full weight in Python; OCP survives with a much lighter mechanism; and ISP is something Python largely hands you for free. Keep that framing as we go — it is the difference between writing Python and writing Java with Python’s syntax.
S — Single Responsibility: one reason to change
The Single Responsibility Principle is stated badly (“a class should do one thing”) and understood badly as a result. The precise version is Martin’s own: a module should have one reason to change, where “reason” means one group of people who ask for changes. It’s about who files the change request, not how many lines the function has.
Look at main() through that lens. Count the distinct people who could walk in tomorrow and force an edit:
| Who asks | What they change | Which concern |
|---|---|---|
| The supplier | New API URL, auth header, JSON shape | HTTP fetching |
| The finance team | Markup, tax, a new discount tier | Pricing rules |
| The DBA | New column, Postgres instead of SQLite | Persistence |
| The ops team | Log format, log level, structured logs | Logging |
| A user | New flag, different output format | CLI / presentation |
Five actors, five reasons to change, one function. That is the SRP violation — and notice it has nothing to do with length. A 200-line function that only ever changes when the pricing rules change is fine by SRP. A 12-line function that changes for three unrelated reasons is not. The refactor ahead gives each of those five actors their own file: the supplier gets adapters/http_prices.py, finance gets domain/pricing.py, the DBA gets adapters/sqlite_quotes.py, and so on. When finance changes the tax rate, they touch one pure function and nothing else can break.
O — Open/Closed: extend without editing
“Open for extension, closed for modification” means you should be able to add new behaviour without editing the existing, tested code that already works. The classic smell it targets is the growing conditional — every new case bolted on as another elif inside a function you have to re-test in full each time.
The Java answer is an abstract base class with a subclass per case. In Python you almost never need that. The lighter mechanisms — passing a function, or registering a handler in a dict — are usually the right call, and the Design Patterns: Factory, Strategy & Observer lesson covers them in depth. Here is the honest comparison of your options, cheapest first:
| Mechanism | Add a new case by | Cost | Reach for it when |
|---|---|---|---|
if/elif ladder |
Editing the function | Re-test everything; merge conflicts | 2-3 fixed cases that never grow |
| A function argument (strategy) | Passing a different callable | Almost none | Behaviour varies per call; the classic Python answer |
A registry (dict of handlers) |
Registering under a key | One line, no edit to the dispatcher | An open set of named cases (formats, commands) |
| Plugin entry-points | Shipping a separate package | Real machinery | Third parties extend you without a fork |
| ABC + subclass | Writing a subclass | Inheritance ceremony | You genuinely need a shared implementation, not just a shared shape |
Our PricingPolicy is OCP done the cheap way: the discount tiers are data (bulk_tiers=((100, 15), (25, 10), (10, 5))), so a new tier is a new tuple, not a new elif. The pricing function never changes when the tiers do. That is “closed for modification, open for extension” without a single subclass — you extended the behaviour by changing data the function reads, and the tested code stood still.
L — Liskov Substitution: honour the contract
The Liskov Substitution Principle says: anywhere your code accepts a base type, it must accept any subtype without knowing, and nothing may break. Not “the subclass sounds like an is-a” — substitutable in every context the base is used in. This is covered in full in Inheritance, Polymorphism & Abstraction, which works through the canonical Square(Rectangle) violation — a square genuinely is a rectangle in English, yet substituting one breaks any caller that sets width and height independently, because the square’s contract can’t honour that. The lesson’s one-sentence summary is worth reprinting: the relationship isn’t about what things are; it’s about what callers are allowed to assume.
LSP carries full weight, but here is the honest Python angle: you violate it less because you inherit less. Most of the time you’re passing objects that quack the right way, not building class hierarchies, so the classic LSP traps never come up. When they do come up, they come up hard, and they are always a broken promise rather than a broken type:
| Violation | What the subtype does | Why it breaks substitution |
|---|---|---|
| Strengthened precondition | Rejects inputs the base accepts | A caller that passed a valid value now gets an error |
| Weakened postcondition | Returns less than the base promised | A caller relying on the guarantee gets surprised |
| Narrowed return type | Returns None where the base returns a list |
Callers crash iterating None |
| Refusing an operation | Overrides a method to raise NotImplementedError |
The base promised it works; the subtype reneged |
| Silent no-op | Overrides to do nothing | The effect the caller depended on never happens |
That fourth row deserves a flag, because it’s the one people write on purpose thinking it’s clean. The inheritance lesson names it directly: overriding an inherited method to raise NotImplementedError is “a design smell (LSP!)” — you have a subclass that can’t actually be a substitute for its base, which means the inheritance was wrong. In our architecture, the ports sidestep all of this: FakePrices and HttpPriceSource don’t inherit from anything, so there’s no base contract to violate by accident. They independently satisfy the same Protocol, and the type checker verifies each one honours it. Structural typing turns “did I honour the contract?” from a code review argument into a mypy error.
I — Interface Segregation: Python’s is duck typing
Interface Segregation says no client should be forced to depend on methods it doesn’t use. In Java this is a real chore: you split fat interfaces into thin ones so a class that only needs read() isn’t forced to implement write(), seek(), flush() and close() as well.
Python gives you most of this for free, and it’s worth understanding why. A Python caller depends on the methods it actually calls, full stop — there is no declared interface to be “too fat.” If your service only ever calls .wholesale_cents(sku), then that method is the interface, whether or not the object has fifty others. Duck typing is interface segregation as a language default.
typing.Protocol (Python 3.8+) is how you make that implicit interface explicit and checkable without giving up the freedom. A Protocol is a structural type: a class satisfies it by shape, never by inheritance. Watch — this is the whole idea in fifteen lines:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Clock(Protocol):
def now(self) -> str: ...
class FixedClock: # note: does NOT inherit Clock
def now(self) -> str:
return "2026"
class NoNow:
pass
print(isinstance(FixedClock(), Clock)) # => True — it has .now()
print(isinstance(NoNow(), Clock)) # => False — it doesn't
True
False
FixedClock never mentions Clock, yet it is one, because it has the right method. That is ISP the Python way: the port names exactly the one method the service needs, and anything shaped like it — the real adapter, a test fake, a future replacement — qualifies automatically. You design ports to be small on purpose: one or two methods each, so nothing is ever forced to implement more than a caller uses. Here is how the two tools compare, because you will meet both:
abc.ABC + @abstractmethod |
typing.Protocol |
|
|---|---|---|
| Conformance | Nominal — must subclass it | Structural — just match the shape |
| The implementer must | import and inherit your base |
Know nothing about you |
| Who defines the relationship | The subclass, explicitly | The type checker, by shape |
isinstance works |
Always | Only with @runtime_checkable (methods only) |
| Can provide shared code | Yes — concrete methods, __init__ |
No — bodies are ... |
| Best for | A base with real shared implementation | A boundary / port — the ISP + DIP tool |
| Third-party types satisfy it | Only if they inherit (they won’t) | Yes, retroactively |
The last row is the quiet superpower. A Protocol can be satisfied by a class written years ago by someone who never heard of you — including standard-library types. An ABC can’t: the author would have had to inherit your base, and they didn’t. For ports and adapters, Protocol is almost always the right tool; reach for an ABC only when the base carries shared implementation the subclasses should inherit.
D — Dependency Inversion: depend on abstractions, inject them
The Dependency Inversion Principle has two halves, and both matter:
- High-level policy should not depend on low-level detail. Both should depend on an abstraction.
- Abstractions should not depend on details. Details should depend on abstractions.
In plain terms: your pricing logic (high-level policy) must not import requests and sqlite3 (low-level detail). Instead, the logic depends on a port — an abstraction saying “something can give me a wholesale price” — and the concrete HTTP adapter also depends on that port, by satisfying it. The arrow of dependency, which naturally runs policy → detail (“the service uses requests”), gets inverted to run detail → abstraction ← policy. Both sides now point at the small, stable thing in the middle.
The second half of DIP is a two-part move, and people usually do only the first:
| You depend on… | You obtain it by… | This is… | |
|---|---|---|---|
| Neither half | requests directly, imported and called inline |
Just calling it | The god-script |
| Abstraction only | A PriceSource Protocol |
self.prices = HttpPriceSource(...) inside the class |
Half a fix — still un-swappable |
| Both halves | A PriceSource Protocol |
Being handed one via __init__ (injection) |
Real DIP |
That third row is the whole game, and it’s where “dependency inversion” meets “dependency injection.” Inversion is what (depend on the abstraction); injection is how (receive it from outside instead of constructing it yourself). The distinction is the difference between a class that can be tested and one that can’t: if QuoteService builds its own HttpPriceSource in __init__, then testing it still requires a live server, Protocol or no Protocol. Only when the service is handed its collaborators can a test hand it fakes instead. We’ll build exactly that.
Cohesion, coupling, and the pure core
SOLID is five rules of thumb; underneath them sit two properties that are the actual goal. Cohesion is how much the things inside a module belong together. Coupling is how much one module depends on the internals of another. You want high cohesion (each module is about one thing) and low coupling (modules touch each other through narrow, stable seams, not by reaching into each other’s guts). Every SOLID letter is a tactic in service of those two.
The practical, un-hand-wavy way to measure coupling in Python is the question from the top of this lesson: what do I have to import to test this? A pure function that imports nothing but the standard library has essentially zero coupling — you can test it on the moon. A function that can only be tested with a live database and a network connection is maximally coupled to both. Score the god-script against its refactored form and the whole argument becomes a table:
| To test this in isolation… | God-script main() |
compute_quote (domain) |
QuoteService (service) |
|---|---|---|---|
| The network / an HTTP server | Required | No | No — inject a fake |
| A real database file | Required | No | No — inject a fake |
| The system clock frozen | Required | No | No — inject a fake |
sys.argv set up |
Required | No | No |
| stdout captured | Required | No | No |
| A mocking library | Effectively yes | No | No |
| Lines of test scaffolding | ~10 | 0 | ~6 (three tiny fakes) |
That right-hand column is what low coupling buys, and it isn’t an abstract virtue — it’s the difference between a test you write in ten seconds and a test you don’t write at all.
Separation of concerns is the design principle that gets you there: keep the parts of the program that change for different reasons in different places. The god-script mixes four concerns in the order the CPU happens to execute them:
| Concern | In main() it looks like |
Where it belongs |
|---|---|---|
| Input / config | argparse, --db, --api |
The composition root (CLI) |
| I/O — fetch & persist | requests.get, sqlite3.connect |
Adapters |
| Business rules | the markup / discount / tax / charm math | The domain core |
| Presentation | print(...), logging.info(...) |
The CLI / an adapter |
The pure core, imperative shell pattern is separation of concerns applied to one specific axis: does this code touch the outside world? Everything that does — the shell — goes to the edges. Everything that doesn’t — the core — goes to the middle and gets to be pure. Here is the litmus test for which side a piece of code lands on:
| Belongs in the pure core | Belongs in the imperative shell |
|---|---|
| Arithmetic, rules, validation of values | HTTP calls, DB reads/writes, file I/O |
dataclass definitions |
print, logging, stdout/stderr |
| Deciding what should happen | datetime.now(), random, uuid4() |
| Transforming inputs to outputs | Reading env vars / sys.argv |
Anything you can test with == |
Anything that needs a fake to test |
The core makes decisions; the shell carries them out. A pure function returns “save this quote”; it doesn’t do the saving. The moment a function both decides and acts, you’ve fused core and shell and lost the testability of the decision. Keep the two apart and the decisions become the easiest code you’ll ever test — and, not coincidentally, the code most worth testing, because that’s where your logic lives.
Ports and adapters: hexagonal, in Python
Put all of this together and you get an architecture with an unfortunate name and a genuinely useful shape: hexagonal architecture, also called ports and adapters (Alistair Cockburn, 2005; the hexagon is just a drawing, the idea has nothing to do with six of anything). It is the pattern that organises “pure core, imperative shell” into named layers with one iron rule about which way dependencies point.
The rule is: dependencies point inward, always. The domain core in the middle imports nothing but the standard library. The service around it imports the domain and the ports. The adapters on the outside import the service’s ports and the domain’s types. Nothing inner ever imports anything outer. Read the diagram outside-in — every arrow is an import, and they all aim at the pure centre:
The badges mark the six load-bearing facts. A test swaps a fake adapter into the same port the real one uses (1). All I/O — every side effect the program has — lives in the three adapter files and nowhere else (2), and each adapter maps between the outside world’s types and the domain’s, so a raw database row never leaks inward (3). The ports are the seam where dependency inversion and interface segregation meet: three tiny Protocols, each the one method its caller needs (4). The service knows only the sequence — fetch, compute, save — and depends on abstractions, not on requests or sqlite3 (5). And the domain core imports nothing outward, a rule Python enforces at the interpreter level, not merely by convention (6).
That last point is worth proving, because it turns an architectural guideline into a hard constraint. If the domain ever tries to import from the service — the arrow pointing the wrong way — you don’t get a code smell, you get a crash:
Traceback (most recent call last):
File "<string>", line 1, in <module>
File ".../pkg/domain.py", line 1, in <module>
from pkg.services import orchestrate
File ".../pkg/services.py", line 1, in <module>
from pkg.domain import rule
ImportError: cannot import name 'rule' from partially initialized module
'pkg.domain' (most likely due to a circular import) (.../pkg/domain.py)
The domain imports the service, which imports the domain, which isn’t finished importing yet — a circular import, and Python refuses. The inward-only rule isn’t discipline you have to remember; violate it between core and shell and the interpreter reminds you immediately. (Adapters importing each other can form cycles too, which is one more reason to keep them from doing so.)
Here is the layered structure as rules you can actually check in code review — what each layer contains, what it may import, and what it must never touch:
| Layer | Contains | May import | Must never import |
|---|---|---|---|
Domain (domain/) |
Pure functions, dataclasses |
Standard library only | requests, sqlite3, the service, adapters |
Ports (services/ports.py) |
Protocols |
Domain types, typing |
Any concrete adapter |
Service (services/) |
Orchestration, app rules | Domain, ports | requests, sqlite3, argparse |
Adapters (adapters/) |
I/O, external libraries | Service ports, domain types | Other adapters (avoid) |
Composition root (cli.py) |
Wiring, argparse, exit codes |
Everything — it’s the one place that may | (nothing — this is where it all meets) |
The composition root is the deliberate exception: someone has to know every concrete class exists and plug them together, and that someone is the CLI’s build_service(). Concentrating that knowledge in one file is the point — it’s the only place a dependency change ripples to, and it does no logic itself.
On disk this maps straight onto the src/ layout from the packaging lesson — the layers are just packages:
repricer/
├── src/
│ └── repricer/
│ ├── domain/
│ │ └── pricing.py # pure: dataclasses + functions, zero I/O
│ ├── services/
│ │ ├── ports.py # the Protocols (the seam)
│ │ └── quoting.py # QuoteService — orchestration only
│ └── adapters/
│ ├── http_prices.py # requests lives here and nowhere else
│ ├── sqlite_quotes.py # sqlite3 lives here and nowhere else
│ ├── clock.py # datetime.now() lives here
│ └── cli.py # the composition root
└── tests/
├── test_pricing.py # tests the core — no mocks, no fixtures
└── test_quoting.py # tests the service — three tiny fakes
And the ports are the hinges. Each Protocol is implemented once for real and once (or more) for tests, and that second implementation is the entire justification for the port existing:
Port (Protocol) |
The one method | Real adapter | Test double |
|---|---|---|---|
PriceSource |
wholesale_cents(sku) |
HttpPriceSource (requests) |
FakePrices (a dict) |
QuoteRepository |
save(quote, at) |
SqliteQuoteRepository |
FakeRepo (a list) |
Clock |
now() |
SystemClock |
FixedClock (a constant) |
Dependency injection for testability
“Dependency injection” is an intimidating name for a mundane idea: a function or object should be handed the things it depends on, rather than reaching out and making them itself. That’s it. def greet(name) injects the name instead of prompting for it; QuoteService(prices=..., quotes=..., clock=...) injects its collaborators instead of constructing them. You have been doing DI since your first function parameter — the pattern just gives it a name and applies it to the awkward dependencies (clocks, databases, HTTP clients) that people usually hard-wire.
Python has no need for the “DI container” frameworks that dominate the Java and C# worlds. Those exist to work around static typing and constructor verbosity; in Python, the container is the composition root you write by hand, and it’s usually a dozen lines. There are a few ways to inject, and the first one is right about 95% of the time:
| Style | Looks like | Verdict |
|---|---|---|
| Constructor injection | Service(clock=SystemClock()) |
The default. Dependencies visible in the signature; object is valid once built |
| Default-argument injection | def run(clock: Clock = SystemClock()): ... |
Handy for functions; ⚠️ never a mutable default |
| Parameter injection | def quote(sku, *, prices): ... |
Good for one-off functions that don’t hold state |
| Setter injection | svc.clock = SystemClock() |
Avoid — object exists in a half-built state |
| Global / singleton | from config import CLOCK |
Avoid — the thing this lesson is fixing |
| A DI framework/container | injector, dependency-injector |
Rarely needed in Python; the composition root does it for free |
Constructor injection is what our QuoteService uses — a @dataclass whose fields are its dependencies:
@dataclass
class QuoteService:
prices: PriceSource # a port, not HttpPriceSource
quotes: QuoteRepository # a port, not SqliteQuoteRepository
clock: Clock # a port, not SystemClock
policy: PricingPolicy
def quote_for(self, sku: str, qty: int) -> Quote:
if qty < 1:
raise ValueError(f"qty must be >= 1, got {qty}")
wholesale = self.prices.wholesale_cents(sku)
quote = compute_quote(sku, qty, wholesale, self.policy)
self.quotes.save(quote, self.clock.now())
return quote
Read the types: every field is a port, never a concrete class. The service literally cannot name HttpPriceSource or sqlite3 — it doesn’t import them. That is DIP and DI in one small class, and the payoff is the next section’s tests.
That if qty < 1: raise ValueError deserves a word, because “where does validation go?” is a question layering has to answer. There are three kinds of check and they live in three different places. Get this wrong and you either scatter the same rule across every layer or let bad data reach the core:
| Kind of check | Example | Where it lives |
|---|---|---|
| Input parsing | “--qty must be an integer” |
The edge — argparse, request deserialisation. Never reaches the core malformed |
| Application rule | “quantity must be ≥ 1 to quote” | The service — it’s about this use case, and it fails fast before any I/O |
| Domain invariant | “a Quote’s total is unit × qty; money is never negative” |
The domain — enforced in the dataclass/__post_init__, true of every quote always |
The service checks qty >= 1 because that’s an application rule — a precondition of quoting, not a truth about all quantities everywhere. A genuine domain invariant (a Quote can’t have a negative price) belongs in the domain object itself, so it holds no matter who constructs one. The rule of thumb: if it’s true in every use of the type, it’s a domain invariant; if it’s true only for this operation, it’s an application rule.
The clock is the clearest single win, so dwell on it. Injecting datetime.now() looks like overkill until you try to test anything time-dependent. The Testing lesson shows the alternative — monkeypatch-ing datetime — and it works, but it patches a name you don’t own and couples the test to how you read the clock. Inject a Clock port instead and the test hands over a FixedClock that returns a constant. No patching, no library, no coupling to the implementation. The same argument holds for the database and the HTTP client: inject the awkward dependency and the test provides a fake, instead of the test performing surgery on your imports.
This is the “no mocks-of-mocks” win the hard way pays for. When you can’t inject, you patch; and patching one thing often means patching what it calls, and stubbing what that returns, until the test is a tower of Mocks whose only assertion is that your mocks agree with each other. The Testing lesson names the trap precisely — “every unit test mocked the neighbours and all the mocks agreed with each other and none of them agreed with reality.” DI dissolves it: a fake is a real (if simple) implementation of the port, so it behaves, and your assertions are about behaviour (what ended up in the repository) rather than interaction (which method you happened to call). Here is the vocabulary, tied to that lesson:
| Double | What it is | You assert on | In our tests |
|---|---|---|---|
| Dummy | Filler that’s never used | Nothing | — |
| Stub | Returns canned answers | The result (state) | FakePrices returns a fixed price |
| Fake | A working, simplified impl | The result (state) | FakeRepo (a list), FixedClock |
| Mock | Pre-programmed with expectations | The interaction | (we need none) |
| Spy | Records how it was called | Calls, after the fact | FakeRepo.saved doubles as one |
Prefer stubs and fakes; they let you assert on what happened, which survives refactoring. Reach for a Mock only when the interaction itself is the behaviour under test (“did we charge the card exactly once?”). In this whole architecture, we never need one.
And the adapters? One integration test each
A fair objection: “you moved all the I/O into adapters and then tested everything else — who tests the adapters?” They do get tested, but differently, and this is where the architecture quietly produces a healthy test pyramid (the shape the Testing lesson argues for — many cheap tests, few expensive ones). Because you concentrated all the risky, slow, world-touching code into three thin adapters, the number of tests that need the real world is small and each one is tiny:
| Layer | Test style | How many | Speed | Needs |
|---|---|---|---|---|
| Domain | Pure unit — assert f(x) == y |
Many (dozens) | Microseconds | Nothing |
| Service | Unit with injected fakes | Some (one per flow) | Microseconds | Three fakes |
| Adapters | Integration — against the real thing | Few (one per adapter) | Milliseconds+ | A local DB / server |
| End-to-end | The repricer command, start to finish |
A handful | Slow | Everything wired |
The SqliteQuoteRepository gets one test against a real database — an in-memory one, sqlite3.connect(":memory:") — checking that a saved Quote round-trips. The HttpPriceSource gets one test against a local server like the lab’s fake_supplier.py, checking it parses the JSON correctly. That’s it — the adapters are thin, so there’s little logic to test, and the little there is (mapping a row, reading a field) gets exactly one integration test each. The pyramid isn’t something you impose afterwards; it’s the natural consequence of pushing I/O to the edges. Fat middle of fast tests, thin tip of slow ones.
When not to abstract
Everything so far pushes toward indirection: ports, injection, layers. Read uncritically, that becomes a disease of its own — a codebase where every class has an interface with exactly one implementation, every value is wrapped in three factories, and finding out what actually happens means opening six files. That is not clean architecture; it’s speculative generality, and it costs more than the duplication it fears. The discipline is knowing when a seam earns its keep and when it’s just ceremony.
The sharpest rule is about implementations. Do not add a Protocol for something with exactly one implementation and no test seam. An abstraction’s job is to let two or more things stand in the same slot; with one thing in the slot forever, the abstraction is a layer of indirection buying nothing. But read the rule carefully, because a test double is a second implementation. Our PriceSource has one production implementation (HttpPriceSource) — yet the port is justified, because FakePrices is a real second implementation that exists so the service is testable. The seam isn’t speculative; you’re using it right now, in the test suite.
That distinction is the whole decision. Abstract for the seam you have, not the future you imagine:
| Signal | Add the abstraction? | Why |
|---|---|---|
| A test needs to swap the dependency | Yes | The test double is your second implementation — the seam is real |
| The dependency does I/O (HTTP, DB, clock, files) | Yes | You will want to fake it; that day is now |
| Two real implementations exist today | Yes | That’s literally what an abstraction is for |
| “We might switch to Postgres someday” | Not yet | YAGNI — build the port when the second impl arrives, or when a test needs it |
| One impl, one caller, no I/O, no test double | No | A Protocol here is pure ceremony — use the concrete class |
| A pure helper function | No | Just call it; functions are already the simplest seam |
Notice that “does I/O” almost always implies “a test needs to swap it,” which is why the ports in this lesson all wrap I/O and none wrap pure logic. The pricing math is pure, so it needs no port — you test it directly. The database is I/O, so it gets a port — you fake it. The architecture isn’t “abstract everything”; it’s “abstract the boundary with the outside world, and leave the core concrete.” Premature abstraction has real, itemised costs:
| Premature-abstraction cost | What it looks like | The plainer alternative |
|---|---|---|
| Indirection tax | Six files to trace one call | The concrete class inline |
| A wrong abstraction | An interface shaped for imagined case B, awkward for real case A | Extract the interface after case B is real |
| Ceremony | Protocol + factory + registry for one impl |
A function |
| Harder onboarding | “Where does it actually fetch?” | One obvious adapter |
Sandi Metz’s rule is the one to remember: “duplication is far cheaper than the wrong abstraction.” Wait for the second real case before you generalise; you’ll extract a right abstraction from two concrete examples, instead of guessing one from zero. The one honest exception is the I/O boundary, because there the “second implementation” — the test fake — is real from day one.
Code smells and the refactorings that fix them
Refactoring is behaviour-preserving change: you improve the structure without changing what the program does (which is why you need tests first — they’re how you know you preserved it). Each of these smells has a standard fix, and you’ll recognise most of them in the god-script:
| Smell | How it shows up | Refactoring |
|---|---|---|
| Long function | main() does fetch + rules + save + print |
Extract function / split into layers |
| Feature envy | A method that mostly pokes at another object’s data | Move it to the data it envies |
| Primitive obsession | price is a bare int; (name, email) passed as loose args |
Introduce a value object (dataclass, Money) |
| Shotgun surgery | One change (new tax rule) edits five files | Gather the concern into one module — the opposite of SRP done well |
| Data clump | The same 3 args travel together everywhere | Introduce a parameter object (PricingPolicy) |
| God module | utils.py that imports half the app |
Split by concern; delete the junk drawer |
| Divergent change | One module edited for many unrelated reasons | Separate it — this is an SRP violation |
| Leaky abstraction | A sqlite3.Row reaches business logic |
Map at the boundary; return a domain object |
| Message chain | a.get_b().get_c().do() |
Hide the delegate behind one method |
The god-script is a long function with a data clump (the pricing knobs) and divergent change (five actors). Our refactor is Extract Function and Introduce Parameter Object applied until each concern sits in its own layer — and because we build the tests first, we can prove at every step that the behaviour didn’t move. Note the two subtler ones. Shotgun surgery is what a bad layering causes — a change smeared across many files; good layering makes each change local. And primitive obsession is why our domain uses a Quote dataclass and integer cents with clear names rather than passing raw floats around: a Quote can’t be confused with a wholesale price, and integer cents can’t silently accumulate float error the way 0.1 + 0.2 does.
Hands-on lab
You’ll take the god-script from the top and refactor it, live, into domain/ + services/ + adapters/, wire it with a composition root, and write the two kinds of test — a pure-domain test with no mocks and a service test with fakes. Then you’ll run both the old and new versions against a real (local) HTTP endpoint and confirm they agree to the cent.
Needs Python 3.10+ (we use X | None and list[...] syntax) and pip install requests. I’ll write python3.12; substitute your interpreter.
Step 0 — scaffold the project. We use the src/ layout from the packaging lesson, because it makes tests import the installed package, not a directory that happens to be lying around.
mkdir -p ~/repricer/src/repricer/{domain,services,adapters} ~/repricer/tests ~/repricer/tools
cd ~/repricer
touch src/repricer/domain/__init__.py src/repricer/services/__init__.py src/repricer/adapters/__init__.py
printf '__version__ = "0.1.0"\n' > src/repricer/__init__.py
Save the original 60-line script from the top of this lesson as ~/repricer/god_repricer.py. That’s our “before” — we’ll keep it around to prove the refactor changed nothing.
What just happened: four packages (domain, services, adapters, plus the root) and a tests/ folder beside src/. The layers are literally directories.
Step 1 — the pure domain (src/repricer/domain/pricing.py). This is the heart: dataclasses and functions, zero I/O. It imports nothing but dataclasses.
"""Pricing logic: pure functions and immutable data. No I/O of any kind."""
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class PricingPolicy:
"""The rules a run is priced under. Data, not behaviour."""
markup_pct: int # 40 -> add 40% to wholesale
bulk_tiers: tuple[tuple[int, int], ...] # (min_qty, discount_pct), high qty first
tax_pct: int # 20 -> add 20%
charm_ending: bool # round up to a .99 ending
@dataclass(frozen=True, slots=True)
class Quote:
"""The answer. Money is integer minor units (cents) - never float."""
sku: str
qty: int
unit_wholesale_cents: int
unit_retail_cents: int
line_total_cents: int
def _inc(cents: int, pct: int) -> int:
"""Increase `cents` by `pct`%, rounding to the nearest cent (half up)."""
return (cents * (100 + pct) + 50) // 100
def _dec(cents: int, pct: int) -> int:
"""Decrease `cents` by `pct`%, rounding to the nearest cent (half up)."""
return (cents * (100 - pct) + 50) // 100
def _charm(cents: int) -> int:
"""Round up to the next price ending in .99 (psychological pricing)."""
return (cents // 100) * 100 + 99
def bulk_discount_pct(qty: int, tiers: tuple[tuple[int, int], ...]) -> int:
"""Return the discount% for `qty`. `tiers` is ordered highest-qty first."""
for min_qty, pct in tiers:
if qty >= min_qty:
return pct
return 0
def compute_unit_retail(wholesale_cents: int, qty: int, policy: PricingPolicy) -> int:
"""Apply markup, bulk discount, tax and charm rounding, in that order."""
price = _inc(wholesale_cents, policy.markup_pct)
price = _dec(price, bulk_discount_pct(qty, policy.bulk_tiers))
price = _inc(price, policy.tax_pct)
return _charm(price) if policy.charm_ending else price
def compute_quote(
sku: str, qty: int, wholesale_cents: int, policy: PricingPolicy
) -> Quote:
"""Turn a wholesale price into a finished Quote. Pure: no I/O, no surprises."""
unit = compute_unit_retail(wholesale_cents, qty, policy)
return Quote(
sku=sku,
qty=qty,
unit_wholesale_cents=wholesale_cents,
unit_retail_cents=unit,
line_total_cents=unit * qty,
)
What just happened: the exact arithmetic from main()'s middle, lifted out and given names. frozen=True makes a Quote immutable (mutating it raises — we’ll see that in troubleshooting); integer cents mean no float drift. This file is the code most worth testing and, not by accident, the easiest.
Step 2 — the ports (src/repricer/services/ports.py). Three tiny Protocols: the seam.
"""Ports: the small structural interfaces the service depends on."""
from datetime import datetime
from typing import Protocol
from repricer.domain.pricing import Quote
class PriceSource(Protocol):
def wholesale_cents(self, sku: str) -> int: ...
class QuoteRepository(Protocol):
def save(self, quote: Quote, at: datetime) -> int: ...
class Clock(Protocol):
def now(self) -> datetime: ...
What just happened: you named the three things the service needs from the outside world, as shapes. Each is one method. No adapter is mentioned — the arrow points inward.
Step 3 — the service (src/repricer/services/quoting.py). Orchestration only.
"""Orchestration: fetch a price, compute a quote, persist it. No rules-math,
no requests/sqlite3 - just the sequence, depending on domain + ports."""
from dataclasses import dataclass
from repricer.domain.pricing import PricingPolicy, Quote, compute_quote
from repricer.services.ports import Clock, PriceSource, QuoteRepository
@dataclass
class QuoteService:
prices: PriceSource
quotes: QuoteRepository
clock: Clock
policy: PricingPolicy
def quote_for(self, sku: str, qty: int) -> Quote:
if qty < 1:
raise ValueError(f"qty must be >= 1, got {qty}")
wholesale = self.prices.wholesale_cents(sku)
quote = compute_quote(sku, qty, wholesale, self.policy)
self.quotes.save(quote, self.clock.now())
return quote
What just happened: the whole business flow in ten lines, and it imports neither requests nor sqlite3. It knows the order of operations, not the details. grep -r "import requests" src/repricer/services/ returns nothing — that’s the property to protect.
Step 4 — the adapters. Now the imperative shell, where I/O is allowed. src/repricer/adapters/http_prices.py:
"""HTTP adapter: implements PriceSource using requests."""
import requests
class HttpPriceSource:
def __init__(self, base_url: str, session: requests.Session | None = None) -> None:
self.base_url = base_url.rstrip("/")
self.session = session or requests.Session()
def wholesale_cents(self, sku: str) -> int:
resp = self.session.get(f"{self.base_url}/skus/{sku}", timeout=5)
resp.raise_for_status()
return int(resp.json()["wholesale_cents"])
src/repricer/adapters/sqlite_quotes.py — note the row-mapping stays here:
"""SQLite adapter: implements QuoteRepository. A sqlite3.Row never escapes this file."""
import sqlite3
from datetime import datetime
from repricer.domain.pricing import Quote
class SqliteQuoteRepository:
def __init__(self, conn: sqlite3.Connection) -> None:
self.conn = conn
self.conn.execute(
"CREATE TABLE IF NOT EXISTS quotes ("
" id INTEGER PRIMARY KEY, sku TEXT, qty INTEGER,"
" unit_wholesale_cents INTEGER, unit_retail_cents INTEGER,"
" line_total_cents INTEGER, created_at TEXT)"
)
def save(self, quote: Quote, at: datetime) -> int:
cur = self.conn.execute(
"INSERT INTO quotes"
" (sku, qty, unit_wholesale_cents, unit_retail_cents,"
" line_total_cents, created_at)"
" VALUES (?, ?, ?, ?, ?, ?)",
(
quote.sku, quote.qty, quote.unit_wholesale_cents,
quote.unit_retail_cents, quote.line_total_cents, at.isoformat(),
),
)
self.conn.commit()
return int(cur.lastrowid)
src/repricer/adapters/clock.py — the one line of nondeterminism, isolated:
"""Clock adapter: the real system clock."""
from datetime import datetime, timezone
class SystemClock:
def now(self) -> datetime:
return datetime.now(timezone.utc)
What just happened: every import requests, every import sqlite3, every datetime.now() in the whole application now lives in these three files. The core and the service are I/O-free by construction.
Step 5 — the composition root (src/repricer/adapters/cli.py). The one place that knows every concrete class:
"""The composition root: the ONE place that wires concrete classes together."""
import argparse
import logging
import sqlite3
import sys
from repricer.adapters.clock import SystemClock
from repricer.adapters.http_prices import HttpPriceSource
from repricer.adapters.sqlite_quotes import SqliteQuoteRepository
from repricer.domain.pricing import PricingPolicy
from repricer.services.quoting import QuoteService
DEFAULT_POLICY = PricingPolicy(
markup_pct=40,
bulk_tiers=((100, 15), (25, 10), (10, 5)),
tax_pct=20,
charm_ending=True,
)
def build_service(db_path: str, base_url: str) -> QuoteService:
"""Wire concrete adapters into the service. This is the composition root."""
conn = sqlite3.connect(db_path)
return QuoteService(
prices=HttpPriceSource(base_url),
quotes=SqliteQuoteRepository(conn),
clock=SystemClock(),
policy=DEFAULT_POLICY,
)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="repricer")
parser.add_argument("sku")
parser.add_argument("-q", "--qty", type=int, default=1)
parser.add_argument("--db", default="quotes.db")
parser.add_argument("--api", default="https://supplier.example.com")
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
service = build_service(args.db, args.api)
try:
quote = service.quote_for(args.sku, args.qty)
except Exception as exc: # noqa: BLE001 - top-level boundary
logging.error("quote failed for %s: %s", args.sku, exc)
return 1
logging.info(
"quoted %s x%d -> %d cents/unit", quote.sku, quote.qty, quote.unit_retail_cents
)
print(
f"{quote.sku} x{quote.qty} {quote.unit_retail_cents / 100:.2f}/unit "
f"total {quote.line_total_cents / 100:.2f}"
)
return 0
if __name__ == "__main__":
sys.exit(main())
Add the pyproject.toml (hatchling finds src/repricer automatically — see the packaging lesson):
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "repricer"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["requests>=2.31"]
[project.optional-dependencies]
dev = ["pytest>=8"]
[project.scripts]
repricer = "repricer.adapters.cli:main"
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
What just happened: build_service() is the hand-written “DI container” — the only function that names HttpPriceSource, SqliteQuoteRepository and SystemClock. main() does argument parsing, exit codes and presentation, and nothing else. Move to Postgres and only build_service changes.
Step 6 — install it, and write the tests that were impossible before. Editable install into a venv:
python3.12 -m venv .venv && source .venv/bin/activate
python -m pip install -e ".[dev]"
Now the pure-domain test — tests/test_pricing.py. No fixtures, no mocks, no I/O: values in, values out.
"""Tests for the pure domain. No fixtures, no mocks, no I/O."""
import pytest
from repricer.domain.pricing import PricingPolicy, bulk_discount_pct, compute_quote
POLICY = PricingPolicy(
markup_pct=40, bulk_tiers=((100, 15), (25, 10), (10, 5)), tax_pct=20,
charm_ending=True,
)
@pytest.mark.parametrize(
"qty, expected",
[(1, 0), (9, 0), (10, 5), (24, 5), (25, 10), (99, 10), (100, 15), (5000, 15)],
)
def test_bulk_tiers_are_boundaries(qty, expected):
assert bulk_discount_pct(qty, POLICY.bulk_tiers) == expected
def test_single_unit_quote():
# 1000 +40% = 1400; qty 1 -> 0% off; +20% tax = 1680; charm -> 1699
q = compute_quote("A-1", qty=1, wholesale_cents=1000, policy=POLICY)
assert q.unit_retail_cents == 1699
assert q.line_total_cents == 1699
def test_bulk_quote_applies_discount():
# 1000 +40% = 1400; qty 10 -> 5% off = 1330; +20% = 1596; charm -> 1599
q = compute_quote("A-1", qty=10, wholesale_cents=1000, policy=POLICY)
assert q.unit_retail_cents == 1599
assert q.line_total_cents == 15990
def test_policy_without_charm_leaves_exact_cents():
plain = PricingPolicy(markup_pct=40, bulk_tiers=(), tax_pct=20, charm_ending=False)
q = compute_quote("A-1", qty=1, wholesale_cents=1000, policy=plain)
assert q.unit_retail_cents == 1680
And the service test — tests/test_quoting.py. Three tiny fakes, injected. No network, no database, no clock, no mock:
"""Tests for the service. Inject three fakes - no network, no DB, no mock library."""
from dataclasses import dataclass, field
from datetime import datetime
import pytest
from repricer.domain.pricing import PricingPolicy, Quote
from repricer.services.quoting import QuoteService
POLICY = PricingPolicy(markup_pct=40, bulk_tiers=((10, 5),), tax_pct=20, charm_ending=True)
class FakePrices:
def __init__(self, table: dict[str, int]) -> None:
self.table = table
def wholesale_cents(self, sku: str) -> int:
return self.table[sku]
@dataclass
class FakeRepo:
saved: list[tuple[Quote, datetime]] = field(default_factory=list)
def save(self, quote: Quote, at: datetime) -> int:
self.saved.append((quote, at))
return len(self.saved)
class FixedClock:
def now(self) -> datetime:
return datetime(2026, 7, 15, 12, 0, 0)
def make_service(repo: FakeRepo) -> QuoteService:
return QuoteService(
prices=FakePrices({"A-1": 1000}), quotes=repo, clock=FixedClock(), policy=POLICY
)
def test_service_saves_the_quote_it_returns():
repo = FakeRepo()
quote = make_service(repo).quote_for("A-1", qty=1)
assert quote.unit_retail_cents == 1699
assert len(repo.saved) == 1
saved_quote, saved_at = repo.saved[0]
assert saved_quote == quote
assert saved_at == datetime(2026, 7, 15, 12, 0, 0)
def test_qty_below_one_is_rejected_before_any_io():
repo = FakeRepo()
with pytest.raises(ValueError, match="qty must be >= 1"):
make_service(repo).quote_for("A-1", qty=0)
assert repo.saved == [] # nothing was persisted
Run them:
pytest
............. [100%]
13 passed in 0.01s
What just happened: thirteen tests, no mock library, no network, no database file, in a hundredth of a second. The domain tests are assert compute_quote(...) == .... The service test injects FakePrices (a dict), FakeRepo (a list) and FixedClock (a constant), runs the real orchestration, and asserts on what landed in the fake — including that the frozen clock’s time was the one saved, and that an invalid qty is rejected before anything is persisted (repo.saved == []). Every one of these was impossible against the god-script.
Step 7 — prove the refactor changed nothing. Start the tiny local supplier so there’s a real endpoint to hit. Save this as tools/fake_supplier.py:
"""A local supplier so the lab has a real HTTP endpoint. Ctrl-C to stop."""
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"wholesale_cents": 1000}).encode())
def log_message(self, *a):
pass
if __name__ == "__main__":
print("fake supplier on http://127.0.0.1:8799")
HTTPServer(("127.0.0.1", 8799), Handler).serve_forever()
In one terminal: python tools/fake_supplier.py. In another, run the old script and the new command against it:
API=http://127.0.0.1:8799
python god_repricer.py WIDGET-1 -q 10 --db before.db --api "$API"
repricer WIDGET-1 -q 10 --db after.db --api "$API"
INFO quoted WIDGET-1 x10
WIDGET-1 x10 15.99/unit total 159.90
INFO quoted WIDGET-1 x10 -> 1599 cents/unit
WIDGET-1 x10 15.99/unit total 159.90
What just happened: identical money — 15.99/unit, 159.90 total — from both. The refactor was behaviour-preserving: we moved code between files, we didn’t change what it computes. And the new version writes a richer row (it kept the wholesale price too):
python -c "import sqlite3; print(*sqlite3.connect('after.db').execute('select * from quotes'))"
(1, 'WIDGET-1', 10, 1000, 1599, 15990, '2026-07-15T14:22:07.918643+00:00')
Step 8 — feel what the god-script would have cost. For contrast, here is the test you’d have to write to check that same 15.99 against the original script — it passes, but count the scaffolding:
import sqlite3
import god_repricer
def test_god_script_pricing(monkeypatch, tmp_path, capsys):
class FakeResp: # 1. fake the network
def raise_for_status(self): pass
def json(self): return {"wholesale_cents": 1000}
monkeypatch.setattr(god_repricer.requests, "get", lambda *a, **k: FakeResp())
db = tmp_path / "q.db" # 2. a temp DB
monkeypatch.setattr("sys.argv", # 3. fake argv
["god_repricer", "A-1", "-q", "10", "--db", str(db), "--api", "http://x"])
god_repricer.main()
out = capsys.readouterr().out # 4. capture stdout
assert "15.99/unit" in out
row = sqlite3.connect(db).execute( # 5. dig it back out of SQL
"select unit_retail_cents from quotes").fetchone()
assert row[0] == 1599
1 passed in 0.07s
What just happened: to check one arithmetic result, the god-script forces you to monkeypatch requests, fake sys.argv, redirect the database into a temp dir, capture stdout, and then run a SQL query to recover the answer — five pieces of scaffolding, and it’s seven times slower. The refactored version checks the same number with assert compute_quote(...).unit_retail_cents == 1599. That gap is the entire argument of this lesson, made concrete.
⚠️ Cleanup: rm -rf ~/repricer removes the lot; check the path before you press enter. Stop the supplier with Ctrl-C.
Now try these:
- Add regional tax: give
PricingPolicyaregion: strfield and maketax_pctdepend on it. Which files change? (Onlydomain/pricing.pyand the test — that’s SRP paying out.) - Add a second
PriceSourcethat reads prices from a CSV file. You should not touch the service or the domain at all — only add an adapter and wire it inbuild_service. - Write a service test that asserts a bulk quote (
qty=100) gets the 15% tier, using the same three fakes. No new machinery needed. - Try to
importQuoteServicefrom insidedomain/pricing.py. Read theImportError. Why does Python stop you? - Make
compute_quotetake theClocktoo (so it stamps its own time). Feel it get worse — the pure function is now impure. Undo it. That’s the pull toward the shell, resisted.
Common mistakes and troubleshooting
| Symptom / traceback | Cause | Fix |
|---|---|---|
| To test a rule you must start a server / write a file | Logic and I/O share a function | Extract the logic into a pure function; test that directly |
ImportError: cannot import name 'X' ... (most likely due to a circular import) |
An inner layer imported an outer one (domain → service) | Reverse it. Domain imports nothing outward. Move the shared type inward |
Protocol with exactly one implementation and no test double |
Speculative abstraction / YAGNI | Delete the port; use the concrete class until a second impl (or a test) needs it |
dataclasses.FrozenInstanceError: cannot assign to field 'sku' |
Mutating a frozen=True domain object |
Build a new one with dataclasses.replace(q, sku=...) — immutability is the feature |
| Tests pass alone, fail together (or in a different order) | Shared global/module state; global config | Inject config; make each test build its own objects. No module-level mutable state |
A sqlite3.Row / ORM object appears in a domain function |
Leaked persistence type inward | Map row → dataclass in the adapter; the domain never sees a Row |
everything.py / utils.py imports half the app |
God module — no cohesion | Split by concern into named modules; a util with one caller belongs beside it |
mock.patch targets a library you don’t own (patch("requests.get")) |
Mocking what you don’t own | Wrap it in your adapter and fake the adapter; assert on your seam, not theirs |
Deep inheritance where a subclass overrides to raise NotImplementedError |
Inheritance used where composition fits (LSP violation) | Replace the hierarchy with a Protocol + injected implementations |
| The “DI container” is now 300 lines of registration | DI turned into a hand-rolled framework | It’s just wiring — a plain build_service() function. Delete the framework |
AttributeError: 'FakeX' object has no attribute 'save' in a test |
The fake drifted from the port | Run mypy — a real fake must satisfy the same Protocol as the real adapter |
Service imports requests “just for one call” |
The shell leaking into the middle | That call belongs in an adapter behind a port; the service stays I/O-free |
Three of these are worth more than a table row.
1. The circular import is the architecture talking. When you see cannot import name ... (most likely due to a circular import) between two of your own modules, resist the urge to “fix” it with a function-local import or by merging the files. It is almost always a dependency pointing the wrong way — usually an inner layer that reached outward. The domain must import nothing but the standard library; the service imports the domain and ports; adapters import inward. Fix the direction and the cycle dissolves on its own. The interpreter is enforcing your layering for free, so let it.
2. Global state is what makes tests order-dependent. The nastiest test failures are the ones that only happen in a certain order, and the cause is nearly always shared mutable state at module scope — a global config, a module-level connection, a cached singleton. Test A mutates it, test B inherits the mutation, and whether B passes depends on whether A ran first. This is the same disease as the god-script, one level up: a hidden dependency that isn’t injected. The cure is identical — pass config and connections in, so each test constructs its own world and tears nothing down for the next. If a value is genuinely global (a clock, a settings object), inject it as a port so a test can substitute a fixed one.
3. “Don’t mock what you don’t own.” When you patch("requests.get"), your test is now coupled to requests’ internals — its call signature, its return shape, the fact that you use requests at all. Upgrade the library or switch to httpx and green tests lie. The Testing lesson states the rule; hexagonal architecture is what makes it easy to follow, because you already wrapped the third-party call in your own HttpPriceSource adapter. So you fake that — a type you own, with a shape you chose — and your tests never mention requests. The adapter itself gets one small integration test against a real (or local) endpoint, which is the right place for that risk to live.
Cheat-sheet
| Concept | The one-line version |
|---|---|
| SRP | One module, one reason to change — one group of people who ask for it |
| OCP | Extend by adding (a function, a registry entry, a policy value), not by editing |
| LSP | A subtype must be usable anywhere the base is, with nothing broken |
| ISP | Depend on the methods you call; keep ports tiny. Python’s ISP is duck typing |
| DIP | Depend on a Protocol; be handed the implementation, don’t build it |
| Cohesion / coupling | Things that change together live together; measure coupling by “what must I import to test this?” |
| Pure core | Functions of their inputs. No I/O. Test with == |
| Imperative shell | HTTP, DB, clock, files, print. Push it to the edges |
| Port | A typing.Protocol — the seam an adapter plugs into |
| Adapter | A concrete implementation of a port (HTTP, SQLite, a fake) |
| Composition root | The one function that knows every concrete class and wires them |
| Dependency injection | Pass collaborators in; don’t construct them inside |
| Domain imports | The standard library, and nothing outward. Ever |
When to add a Protocol |
When a test or a second impl needs the seam — not “someday” |
@dataclass(frozen=True, slots=True) |
Immutable value object; mutating raises FrozenInstanceError |
dataclasses.replace(obj, field=new) |
“Change” a frozen object by making a new one |
class P(Protocol): def m(self)->T: ... |
Define a port — structural, no inheritance needed |
@runtime_checkable |
Lets isinstance(x, P) work (checks method names, not signatures) |
mypy src/ |
Verifies fakes and adapters actually satisfy the ports |
Circular ImportError between your modules |
A dependency points the wrong way — fix the direction |
Interview and exam questions
Q: Which SOLID principles carry less weight in Python, and why?
A: Interface Segregation mostly dissolves — a Python caller already depends only on the methods it calls, so there are no fat interfaces to split; typing.Protocol just makes that implicit fact explicit and checkable. Open/Closed keeps its goal but sheds its mechanism: you rarely need an abstract base and a subclass tree, because passing a function or registering a handler in a dict achieves “extend without editing” far more cheaply. SRP, LSP and DIP carry full weight — they’re about change, contracts and dependencies, which duck typing doesn’t touch. The honest summary: keep SOLID’s spirit, drop the Java ceremony.
Q: What is dependency injection, and how is it different from dependency inversion?
A: Injection is how — an object is handed its collaborators (as constructor arguments, usually) instead of building them itself. Inversion is what — high-level code depends on an abstraction (a Protocol), not a concrete detail, so the dependency arrow points at the abstraction from both sides. You need both: inverting to a Protocol but still constructing HttpPriceSource inside the class leaves it untestable; injecting a concrete class without a Protocol works at runtime but loses the type-checked seam. Together they mean a test can hand the object a fake that satisfies the same port.
Q: You have a function that fetches JSON from an API and returns the average of a field. How do you make it testable?
A: Split it at the I/O boundary. One function does the fetch (the adapter — thin, tested once against a real endpoint); a pure function takes the already-fetched data and computes the average (the core — tested exhaustively with plain values, no network). The service wires them: fetch, then compute. The mistake is testing the whole thing by mocking requests; the fix is that the interesting logic (the average, its edge cases — empty list, one element) never touches the network in the first place.
Q: What does “the domain imports nothing” actually buy you, and how is it enforced?
A: It buys pure, trivially testable logic (test with ==, no scaffolding) and the freedom to change every outer detail — database, HTTP client, CLI — without touching the rules. It’s enforced two ways: socially, by code review and the layer table; and physically, by the interpreter — if the domain imports the service which imports the domain, you get ImportError: cannot import name ... (most likely due to a circular import). The inward-only dependency rule isn’t just a convention; reverse it between core and shell and Python refuses to import the module.
Q: When should you not introduce an abstraction?
A: When it has exactly one implementation and no test seam — a Protocol (or ABC, or factory) that only ever has one thing behind it is indirection buying nothing. The subtlety: a test double counts as a second implementation, so wrapping I/O in a port is justified from day one, because you’ll fake it in tests immediately. What’s not justified is a port for pure logic you test directly, or an interface built for an imagined future case (“we might switch databases”). Abstract for the seam you have, not the future you imagine — and remember Metz: “duplication is far cheaper than the wrong abstraction.”
Q: abc.ABC versus typing.Protocol — when do you reach for each?
A: Protocol for a boundary/port: it’s structural, so an implementer (including a third-party or stdlib type) satisfies it just by having the right methods, with no import and no inheritance — perfect for ISP and DIP. ABC when the base carries shared implementation the subclasses should inherit, or when you want to force explicit import-and-subclass registration. For ports-and-adapters it’s almost always Protocol; ABC when you’re building a base class with real concrete methods, not just a shape.
Q: What’s the “pure core, imperative shell” pattern?
A: Separate code by whether it touches the outside world. The shell (HTTP, DB, files, clock, print, env vars) goes to the edges; the core (rules, arithmetic, validation, transforms) sits in the middle and is a pure function of its inputs. The core decides what should happen and returns that decision; the shell carries it out. The payoff is that the decisions — where your actual logic lives — become deterministic and testable with no fakes, while the thin shell gets a handful of integration tests. The god-script’s sin is interleaving the two line by line.
Q: How do you test time-dependent code without monkeypatch?
A: Inject a clock. Define a Clock port with now(), have production use a SystemClock that returns datetime.now(...), and have tests inject a FixedClock that returns a constant. The test asserts against a known time with no patching, no global state and no coupling to how you read the clock. It’s the same move for any awkward dependency — database, HTTP client, random, UUIDs: wrap it in a port, inject the real thing in production and a fake in tests. Injection replaces patching.
Q: A colleague wraps every class in the app in an interface “for testability.” What do you tell them? A: That testability comes from injecting the things that do I/O, not from interfacing everything. Pure logic needs no interface — you test it directly with values. Blanketing the codebase in one-implementation Protocols adds indirection (more files to trace), risks wrong abstractions, and slows onboarding, all for no seam anyone uses. The rule is targeted: a port where a test or a real second implementation needs to swap in — which in practice means the I/O boundary — and concrete classes everywhere else.
Q (practical): Given a 50-line function that reads a CSV, filters rows by a business rule, and writes results to a database, sketch the refactor.
A: Three pieces. read_rows(path) -> list[Row] — an adapter (file I/O, thin). select(rows, criteria) -> list[Row] — a pure function (the business rule, exhaustively unit-tested with in-memory lists). save(rows) — an adapter (DB I/O, behind a Repository port). A small service strings them together: read, select, save. Tests: the pure select gets a dozen cases with no I/O; the service gets one test with a fake reader and a fake repo; each adapter gets one integration test. The 50-line function becomes a four-line orchestration plus three testable pieces.
Q (practical): How do you keep the dependency arrows pointing inward in a real repo?
A: Structurally and by tooling. Structurally: domain/ imports only the standard library, services/ imports domain + ports, adapters/ import inward, and one composition root wires it. By tooling: import-linter (or a ruff TID/banned-import rule, or a simple CI grep) can fail the build if domain/ imports requests or sqlite3, or if an inner layer imports an outer one. And the interpreter helps unasked — a wrong-way import between core and shell throws a circular-import error. Encode the layer rules as a check and they stop being a code-review debate.
Key takeaways
- Measure coupling by one question: what must I import to test this? The god-script’s answer is “the internet, a database, the clock, and
sys.argv”; a pure function’s answer is “nothing.” Driving that answer toward zero for your logic is the whole game, and it pays out in five directions at once — testing, change, reuse, parallel work, and comprehension. - Pure core, imperative shell. Push every side effect — HTTP, DB, clock,
print— to the edges, and leave a middle that’s nothing but functions of their inputs. The core decides; the shell acts. Decisions are where your logic lives and are trivial to test once they don’t also do I/O. - SOLID, honestly: keep the spirit, drop the ceremony. SRP, LSP and DIP carry full weight in Python. OCP keeps its goal but uses a lighter mechanism (a function or a registry, not an inheritance tree). ISP is largely free — duck typing means you already depend only on the methods you call, and
typing.Protocoljust makes that checkable. - Ports are
Protocols; adapters implement them; dependencies point inward. The domain imports nothing outward, the service imports the domain and ports, the adapters import inward — and the interpreter enforces the core/shell boundary for free with a circular-import error when you get it backwards. - Dependency injection is just passing collaborators in. Constructor injection (a
@dataclasswhose fields are ports) is the default; the “DI container” is a hand-writtenbuild_service()a dozen lines long. Inject the clock, the database and the HTTP client, and tests hand over fakes instead of performing surgery on your imports. - Fakes beat mocks. A fake is a real, simple implementation of a port, so it behaves and you assert on what happened (state), which survives refactoring — not on which method you called (interaction), which doesn’t. With this architecture, the pricing rules need no test double at all and the service needs three tiny ones; a
Mocknever appears. - Don’t abstract what has one implementation and no seam. A test double counts as a second implementation, which is why I/O boundaries earn a port from day one and pure logic doesn’t. Abstract for the seam you have, not the future you imagine — “duplication is far cheaper than the wrong abstraction.”
- Refactoring is behaviour-preserving — so test first. We proved the whole restructure changed nothing by running the old script and the new command side by side and getting the identical
15.99/unit. Layers, ports and injection are how you make that kind of change safe and boring, which is exactly what you want it to be.
This lesson leaned on three others and it’s worth saying how they connect. The src/ layout that houses these layers, and the pyproject.toml that wires the repricer command, are covered field by field in Project Structure, Packaging & Documentation. The fixtures-as-dependency-injection idea, the fake-versus-mock distinction, and “don’t mock what you don’t own” are the subject of Testing Python: unittest, pytest, Fixtures, Mocking & Coverage. The Protocol, MRO and super() mechanics behind LSP and ISP live in Inheritance, Polymorphism & Abstraction. And the cheap OCP mechanisms — strategy as a passed function, a registry of handlers — are the whole subject of Design Patterns: Factory, Strategy & Observer, which is the natural next step once you’re comfortable pulling logic out of main().