Project boundaries
Build a small HTTP service with clear boundaries, tests, logs and a repeatable runtime.
THE IDEA
Why code needs boundaries
Consider one function that reads a JSON request, loads a discount from a database, calculates a price, saves the order and sends an email. To test the price you need a database and a mail server. To change the email provider you edit the pricing function. When something fails you cannot tell which part is to blame.
The fix is to keep decisions (calculations, rules) apart from effects (network, disk, database, clock). The rules become plain functions of plain values: easy to test, easy to reuse. Thin adapters translate between the outside world and those plain values.
# tangled: needs a database to test the maths
def price(order_id):
row = db.query("select ...", order_id)
return sum(q * p for q, p in row.items) * (100 - row.pct) // 100
# separated: the maths takes values; the adapter fetches them
def price(items, pct):
return sum(q * p for q, p in items) * (100 - pct) // 100Dependencies point one way
Each file that imports another depends on it. Draw an arrow for every import and you get a graph. Two rules keep it healthy: arrows point from the outside (web, adapters) toward the middle (domain), and there are no cycles. A cycle means A cannot be understood without B and B not without A; in Python it can also fail at import time with a partially initialised module.
- broken No import cycles between modules
cycle through web, domain - broken Every import points the allowed way (web, then adapters, then domain)
domain imports web (Order uses calc_subtotal); domain imports storage (apply_discount uses load_discount_rate) - broken Pure calculations and plain data live in domain
not in domain: calc_subtotal, add_tax, format_money - broken Each I/O piece lives in its own adapter (web, storage, notify)
misplaced: save_order, send_receipt
Predict first. Which is easier to test: price(items), or a function that also opens a database and sends email?
Breaking a cycle
The usual cause of a cycle is a low-level piece reaching up to something higher: a pricing rule that calls the database itself. The cure is passing the value in: the caller fetches the rate and hands it over, so the rule no longer needs to know a database exists. The arrow disappears instead of just moving.
Chapter context
Engineering makes a program understandable when it fails or changes. Separate pure calculations from external I/O so behavior can be checked cheaply and errors can be connected to specific inputs.
TRY IT YOURSELF
Part 1: repair the dependency diagram
- Move each piece of code to the module where it belongs. Pure rules go in
domain; each I/O piece goes in its adapter. - Tick the box that rewires
apply_discountso it no longer calls the database. Watch the arrows turn grey.
Where each piece lives
Change the code, not just the folder
- broken No import cycles between modules
cycle through web, domain - broken Every import points the allowed way (web, then adapters, then domain)
domain imports web (Order uses calc_subtotal); domain imports storage (apply_discount uses load_discount_rate) - broken Pure calculations and plain data live in domain
not in domain: calc_subtotal, add_tax, format_money - broken Each I/O piece lives in its own adapter (web, storage, notify)
misplaced: save_order, send_receipt
Allowed imports: web may import domain, storage and notify. Storage and notify may import domain. Domain imports nothing from this project.
Part 2: the same idea in Python
Exercise: pull the calculation out of the database function
order_total(order_id)looks a row up inDB(a stand-in for a database) and calculates the price in the same function. To test the maths you currently need a database.- Split it. Write a **pure** function
total_minor(items, discount_pct): sum ofqty * price, minustotal * discount_pct // 100. For[(2, 125), (3, 40)]and 10 it returns 333. - Keep
order_total(order_id, db=DB)as a thin adapter: look the row up indb, then returntotal_minor(...)of its items and discount. It must contain no arithmetic of its own.
Solve both parts to finish this lesson.
Stuck, or something not working? You can come back to it any time.
CHECK YOUR UNDERSTANDING
Which is easier to test: price(items) or a function that also opens a database and sends email?
Chapter reference: math, worked example & code
This section belongs to the whole chapter, so it is the same on every lesson in it and may cover a different lesson's topic. The code for the experiment you just ran is inside the experiment itself.
Math and notation
For money examples, use integer minor units rather than binary floating point. The subtotal is sum(quantity × unit_price), and the domain must specify rounding and tax rules before those operations are implemented.
Worked example
Two items cost 125 minor units each and three cost 40 each. The subtotal is 2×125 + 3×40 = 370. A test can exercise this calculation without starting an HTTP server. Request parsing and response formatting need separate checks.
Reference code
The following example uses the Python standard library and does not call a remote service.
def subtotal(items):
return sum(quantity * price for quantity, price in items)
assert subtotal([(2, 125), (3, 40)]) == 370
assert subtotal([]) == 0
Python examples are provided for study and local execution; this page does not execute Python.
Common mistakes
Test coverage counts executed lines, not verified requirements. A container does not turn an unsafe operation into a safe one. JSON validity alone does not establish application validity.