Layers — Transport, Domain, Storage
A service has three jobs that change for three different reasons: talking HTTP, deciding what is true, and talking to the store. The framework changes when a major version ships. The rules change when the business changes its mind about how long a hold lasts. The queries change when an index is added or a table is split. Stagedoor's first version kept all three in one handlers.py of 3,000 lines, where route functions built SQL strings, read the environment mid-request, and raised HTTP exceptions from inside a loop over seats. Every change touched the same file, and every change to the file risked all three jobs at once.
A codebase that keeps the three jobs in three layers can change the framework without touching a rule, test a rule without a database, and swap a query without touching a route. The rule that makes it work is one sentence about imports, stated once in this topic and then obeyed by every piece of code the rest of the book quotes. Chapter 3 built the edge where bytes become typed values; this topic is what happens to the typed values once they are inside.
The Three Layers
Transport is everything that knows HTTP: the routes, the request and response models of Chapter 3, the status codes, the serialization. Domain is the rules, in plain Python: hold_seat, place_order, refund, functions that know what a seat is and what a hold means and know nothing about HTTP or SQL. Storage is the repository functions that run SQL against Postgres and return domain objects, never rows and never dictionaries. Each layer is allowed to know one thing about the layer below it and nothing about the layer above.
The demonstrated example is one seat hold passing through all three. Transport parses the body into a HoldRequest, pulls the caller from the token, and calls the domain. The domain asks storage for the seat, applies the one rule that matters here, and asks storage to create the hold. Storage runs two statements and returns a Hold. The race between two buyers reaching for the same seat is Chapter 6's subject and is deliberately absent from the sketch; the layering is what makes that fix a change to one storage function instead of a change to every route.
# transport/holds.py: knows HTTP, nothing else @router.post("/holds", status_code=201) async def create_hold(req: HoldRequest, user: CurrentUser, svc: Service) -> HoldResponse: hold = await hold_seat(svc.seats, svc.holds, svc.clock, user.id, req.event_id, req.seat_label) return HoldResponse.from_hold(hold) # domain/holds.py: plain Python; no fastapi, no SQL async def hold_seat(seats: SeatRepository, holds: HoldRepository, clock: Clock, user_id: int, event_id: int, label: SeatLabel) -> Hold: seat = await seats.get(event_id, label) if seat.status != "available": raise SeatUnavailable(label, held_until=seat.held_until) return await holds.create(seat.id, user_id, expires_at=clock.now() + HOLD_TTL) # storage/seats.py: SQL in, domain objects out async def get(self, event_id: int, label: str) -> Seat: cur = await self.conn.execute(SELECT_SEAT, (event_id, label)) row = await cur.fetchone() return Seat.from_row(row)
Three functions, three files, and each one reads as if the other two did not exist. The transport function is four lines and contains no rule. The domain function contains the rule, "a seat that is not available cannot be held," and nothing that would fail if HTTP were replaced by a message queue. The storage function turns a row into a Seat and would be the same function if the domain rule changed tomorrow. Chapter 8's worker calls hold_seat to expire holds and needs neither the router nor the request model, which is the first dividend the layering pays.
The Import Rule
Transport imports domain. Domain imports storage's interface, the SeatRepository and HoldRepository Protocols of Topic 20, and nothing else from storage. Storage imports nothing above it: not the routes, not the request models, not the domain functions. A domain function that imports fastapi has crossed the line, and so has a storage function that raises HTTPException, and so has a transport route that runs SQL. One sentence, and it holds in Stagedoor because a linter checks it on every commit instead of a reviewer remembering to.
The linter is import-linter, and the contract is one line naming the three packages in order: stagedoor.transport, stagedoor.domain, stagedoor.storage. A lower package that imports a higher one fails the build with the offending import named. Marek wrote that line before the first file was moved, so the restructuring of handlers.py was a series of red builds turning green rather than a review comment nobody enforced. A rule that a tool checks costs 30 seconds to set up; a rule that a person checks costs a little every week for the life of the codebase.
Where the Types Live
Request models are transport's. A HoldRequest exists to parse bytes at the edge, as Chapter 3 built it, and it is the wrong shape to pass into a rule, because it carries the client's vocabulary and the client's constraints. Domain types are domain's: Seat, Hold, Money, SeatLabel. They are what the rules take and return, and they are what storage constructs from rows and takes as arguments to writes. Storage owns no types of its own that anyone else sees; a row is a private detail that becomes a Seat before it leaves the function.
The translation between request model and domain type happens once, in transport, at the edge, which is Chapter 3's third verb. A HoldRequest becomes an event_id and a SeatLabel in the four-line route function, and a Hold becomes a HoldResponse on the way back out. Stagedoor's redesign has roughly forty domain types and thirty request and response models, and the number that appear on both sides of the line is zero. A model that is used as both is the first crack, because the next field added for the client is now a field the rule has to ignore.
Domain Errors Are Not HTTP Errors
The domain raises its own exceptions. SeatUnavailable carries the label and the moment the current hold expires; OrderNotPending carries the order's actual status; HoldExpired carries when. None of them knows a status code, because the same SeatUnavailable is raised whether the caller is a route, the worker, or a test. Transport maps each one to a response in the shape of Chapter 3: a 409 Problem Details for SeatUnavailable with a held_until field the client can show, a 409 for OrderNotPending, a 410 for HoldExpired.
TO_HTTP = {
SeatUnavailable: (409, "seat-unavailable"),
OrderNotPending: (409, "order-not-pending"),
HoldExpired: (410, "hold-expired"),
}
@app.exception_handler(DomainError)
async def domain_error(request, exc: DomainError):
status, kind = TO_HTTP[type(exc)]
return problem(status, kind, detail=str(exc), **exc.fields()) # RFC 9457 body
The mapping is one dictionary and one handler registered once. A new domain error is a new row in the dictionary, not a new try block in every route that might raise it. The handler builds the Problem Details body from the exception's own fields, so held_until reaches the client because the domain put it on the exception, not because a route remembered to copy it. Before the redesign, Stagedoor had 23 routes with their own except clauses, and four of them turned a seat conflict into a 500.
What the Layers Buy
The domain is testable with a fake repository in milliseconds. Chapter 12 builds that suite: an in-memory SeatRepository with three seats in it, a frozen clock, and 200 tests of the hold, order and refund rules that run in under a second with no Postgres, no HTTP client and no network. The storage layer is exercised separately against a real Postgres, with the same get and create functions called directly and their SQL checked for what it does under concurrent writes. And the transport can be swapped: the gRPC surface Chapter 3 weighed would be a second transport package calling the same hold_seat, with the domain untouched and the storage untouched.
The dividend that is not on the list is the one Marek noticed first. When the rule for holds changed from 10 minutes to a per-event value, the change was one function's signature and one row in storage, and the diff was 40 lines. In the 3,000-line file, the previous change of that kind had touched 11 route functions and missed one, and the missed one is why a hold on a late-added event lasted a default of 10 minutes when the organizer had set 5.
What They Cost, Honestly
Three files where one would do for a tiny service. A translation step at the edge that is real code, roughly 30 lines per resource, and real work to keep aligned. And the temptation to keep going: a "use case" layer above the domain, an "application service" layer above that, a "presenter" below the transport, until a seat hold passes through eight files and nobody can find the rule. The book's three layers are the minimum that separates the three reasons to change, and the book does not add a fourth. A service with four routes and one table can keep all three in one file and be right to; the layers earn their cost at the point where a second consumer of the rules appears, and for Stagedoor that consumer was the worker.
A fat model, the Rails-style Order.place!, puts the rule on the persisted object. It is convenient: the rule is one method away from the data it needs, and there is no repository to pass around. The rule now depends on the ORM and the database to run, so testing it means a database, and running it from somewhere the ORM is not means it cannot run.
Layers put the rule in a function that receives what it needs. More plumbing: a repository, a Protocol, a translation at the edge. The rule runs in a unit test with no database, in the worker with no HTTP, and in the second transport with no changes.
Either works at small scale. Only the second survives the day the rule must run somewhere the ORM is not, and for Stagedoor that day was the worker's first hold-expiry job.
- SQL in the route — the seat query is copied into the second route that needs it, the two copies drift over three months, and the index that one of them needed is missing for the other, which is the one that runs at on-sale.
HTTPExceptionin the domain — the hold-expiry rule cannot be reused by the worker of Chapter 8, which has no HTTP to raise into, so the worker gets its own copy of the rule and the two disagree within a quarter.- The ORM model as the API shape — a column rename is now an API break in the sense of Chapter 3, and a private column such as
password_hashis exposed by default until somebody notices. - One layer that imports everything — the
utilsmodule that the whole codebase depends on and that nobody can change, because every change to it is a change to every route, rule and query at once. - A storage function that returns rows or dictionaries — the domain reads column names, the column rename ripples into every rule, and the layer boundary is a folder name with no meaning.
- Layers added for symmetry — a use-case layer, an application layer and a presenter for a service with six rules, and the hold passes through eight files before anyone can find where "available" is checked.
- Draw the three layers and write the import rule into
import-linteron day one, before the first file is moved, so the restructuring is red builds turning green. - Keep the domain free of frameworks: plain functions, plain types, its own exceptions, and no import that names
fastapi,psycopgorredis. - Translate exactly once at the edge, request model to domain type on the way in and domain error to status code on the way out, in one route function and one mapping table.
- Let the worker and the API share the domain and storage layers and differ only in transport, so the rule that expires a hold is the rule that refused it.
- Return domain objects from every storage function and let the row be a private detail that never leaves the file it was fetched in.
Knowledge Check
A function in the domain layer imports fastapi to raise an HTTPException when a seat is held. Which consequence does the import rule exist to prevent?
- The route becomes slower, because the domain now loads the framework on every call
- The worker cannot reuse the rule, because it has no HTTP to raise the exception into
- The linter reports a circular import, because transport already imports the domain package
- The client receives a 500, because domain exceptions are not registered with the router
Where does SeatUnavailable become a 409, and why there?
- In the storage layer, where the seat's status is read from the row and the code chosen
- In every route that calls hold_seat, so each route can pick the code that fits its client
- In the domain function itself, which attaches the status code when it raises the exception
- In one transport mapping table, so a new domain error is one new row and nothing else
Marek returns the ORM model for users directly from GET /me. What goes wrong first?
- The password hash is serialized by default, and a later column rename breaks every client
- The response is slower to build, because the ORM model carries lazy-loaded relations
- The OpenAPI document cannot be generated at all, because ORM models carry no field constraints
- The domain layer can no longer call the storage layer, because the types now conflict
What does a fake in-memory SeatRepository make possible that a test database does not?
- Testing how two concurrent holds on one seat behave under Read Committed
- Checking that the seat query uses the right index before the code ships
- Running 200 tests of the hold and refund rules in under a second, with no Postgres
- Proving the storage layer's SQL is correct without any running database
You got correct