10 Layered Architecture
10.1 Learning Objectives
By the end of this lecture you should be able to:
Define layered (n-tier) architecture and name the four canonical layers—
presentation, business logic (service), data access, database— with each layer’s single responsibility. Trace a request down and back up the stack, and state the dependency rule: each layer talks only to the layer directly below it.
Assign any given piece of code to its correct layer—
and recognize misplaced code (SQL in a route handler, HTTP status codes in a service) on sight. Demonstrate the testing payoff: unit-test business rules with a fake repository (no database, no HTTP), and explain why the mud-ball version cannot be tested this way.
Weigh the trade-offs—
separation of concerns and maintainability against performance overhead, rigidity, and pass-through code— and identify when the pattern is (and isn’t) the right choice. Position layered architecture among its alternatives (MVC, microservices, event-driven, hexagonal, clean) and articulate what each changes.
Refactor a tangled single-file application into layers without changing its externally observable behavior.
10.2 Motivation: You’ve Been Building This All Semester
10.2.1 The pattern you already live in
Look at what the last three lectures produced, stacked in order:
FastAPI lecture—
route handlers that parse HTTP, validate input with Pydantic, and return JSON. REST lecture—
the contract those handlers expose: resources, methods, status codes. Database lecture—
crud.py over SQLAlchemy models over PostgreSQL, and exercise E8, which wired the API to real storage without changing the contract tests.
That separation—
10.2.2 Why architecture patterns exist at all
An architecture pattern is a pre-made decision about where
code goes. Its value is boring and enormous: when every feature
asks “where do I put this?”, a team without an answer produces the
big ball of mud—
10.2.3 Intuition: a restaurant
A layered application is a restaurant. The waiter
(presentation) talks to customers, writes down orders in a fixed
format, and never cooks. The chef (business logic) decides
whether the order is possible, applies the house rules, and never
talks to customers. The pantry clerk (data access) is the
only person who goes into the storeroom (database). The
customer never wanders into the pantry; the chef never argues with a
diner. Each role is replaceable—
10.3 Running Example: TerpTasks Grows House Rules
TerpTasks (multi-user since the database lecture) now has actual campus policies:
A user may have at most 20 open tasks (the procrastination cap—
our version of the reference outline’s 18-credit limit). A task’s due date may not be in the past at creation.
Only the task’s owner may complete or delete it.
And here is how the intern (of REST-lecture fame) implemented rule 1, in the single file that is the whole application:
# mudball.py -- the whole app in one file: presentation + rules + SQL
@app.post("/tasks")
def create_task(body: dict):
conn = sqlite3.connect("terptasks.db")
# HTTP parsing, business rule, and SQL -- all in one place
n = conn.execute(
"SELECT COUNT(*) FROM tasks WHERE owner=? AND done=0",
(body["owner"],),
).fetchone()[0]
if n >= 20:
return JSONResponse(status_code=422,
content={"error": "too many open tasks"})
if body["due"] < str(date.today()):
return JSONResponse(status_code=422,
content={"error": "due date in past"})
cur = conn.execute(
"INSERT INTO tasks (owner, title, due, done) VALUES (?, ?, ?, 0)",
(body["owner"], body["title"], body["due"]),
)
conn.commit()
return {"id": cur.lastrowid}It works. Now run three thought experiments, and keep the answers in mind through the whole lecture:
Change storage (SQLite → PostgreSQL): edit every handler.
Test the 20-task rule: you must boot HTTP and a real database just to check an if.
Add a CLI or a Discord bot that creates tasks: copy-paste the rules, then keep two copies in sync forever.
Every one of these pains is a coupling pain—
10.4 The Pattern
Formal definition. Layered architecture organizes a system into horizontal layers, where each layer has one specific responsibility and communicates only with the layer directly adjacent to it (in the strict form, only downward).
+----------------------+ |
| Presentation Layer | HTTP, validation, serialization (api.py) |
+----------------------+ |
| calls |
+----------------------+ |
| Business Logic | the rules: policies, workflows, (services.py) |
| (Service Layer) | authorization, transactions |
+----------------------+ |
| calls |
+----------------------+ |
| Data Access Layer | SQL / ORM operations, persistence (repositories.py) |
+----------------------+ |
| reads/writes |
+----------------------+ |
| Database | PostgreSQL / SQLite / MySQL / ... |
+----------------------+ |
In plain English: slice the app by technical job, not by feature. Everything that speaks HTTP lives at the top; everything that speaks SQL lives near the bottom; everything that decides lives in the middle.
The dependency rule is what makes it a discipline rather than a diagram: dependencies point down, one layer at a time. Presentation imports the service; the service imports the repository; nothing imports upward. The service must not know what a status code is; the repository must not know what a “procrastination cap” is.
Intuition. Each boundary is a replaceability
contract, and you’ve already seen each one pay off: the REST
lecture swapped storage without touching the contract (repository
boundary); FastAPI’s dependency_overrides swapped a fresh store
into tests (the same boundary, exploited by tests). The layer
boundary is to code what the API contract was to clients—
Common misconceptions.
“Layers = folders.” Making api/, services/, repositories/ directories does nothing if api.py still writes SQL. The pattern is the dependency rule, not the file tree.
“Layers = tiers.” Layers are logical separations; tiers are physical (separate machines). Our four layers run happily in one process. (The names blur in practice—
“n-tier” is used for both— but the distinction matters when someone proposes “splitting into services” to fix a code-organization problem.) “More layers = better architecture.” Each layer is real overhead—
in latency, in ceremony, in files-touched-per-change. Four is canonical because it matches four genuinely different reasons to change. “MVC is the same thing.” MVC organizes the presentation layer itself (how UI code separates state, display, and input). A Spring or FastAPI “controller” is the presentation layer of an n-tier app; the service and repository below it are outside MVC’s story.
10.4.1 The request flow, traced
GET /tasks/123—
Browser/client "GET /tasks/123" |
| |
v |
Presentation api.py: parse path, authenticate --> service.get_task(user, 123) |
v |
Service services.py: check task 123 is visible to user (rule 3) |
v |
Repository repositories.py: SELECT ... FROM tasks WHERE id = ? |
v |
Database returns the row |
^ |
Repository row -> Task object |
^ |
Service object passes policy -> returned as-is |
^ |
Presentation Task -> JSON, status 200 (or domain error -> 403/404) |
^ |
Browser/client receives the representation |
Notice the translation duty at each boundary going up: the
repository turns rows into domain objects; the presentation turns
domain results into HTTP. The service in the middle speaks only
domain language—
10.5 The Four Layers, One at a Time
10.5.1 Presentation layer
Responsibility: everything about talking to the
outside—
# app/api.py (excerpt) -- speaks HTTP, delegates everything else
@router.post("/tasks", status_code=201)
def create_task(body: TaskCreate,
svc: TaskService = Depends(get_service)) -> TaskOut:
try:
task = svc.create_task(owner=body.owner, title=body.title, due=body.due)
except DomainError as exc:
raise HTTPException(status_code=422, detail=str(exc)) # translate!
return TaskOut.from_task(task)The handler is thin on purpose: parse, delegate, translate,
serialize. The try/except is the layer boundary made
visible—
10.5.2 Business logic layer (service layer)
Responsibility: the application’s rules and
workflows—
# app/services.py (excerpt) -- speaks pure domain language
MAX_OPEN_TASKS = 20
class TaskService:
def __init__(self, repo: TaskRepository):
self.repo = repo # depends on the layer below
def create_task(self, owner: str, title: str, due: date) -> Task:
if self.repo.count_open(owner) >= MAX_OPEN_TASKS:
raise TooManyOpenTasks(f"{owner} already has {MAX_OPEN_TASKS} open tasks")
if due < date.today():
raise DueDateInPast(f"{due} is in the past")
return self.repo.add(owner=owner, title=title, due=due)Compare this to the mud-ball: the same if statements,
but now they live in a class with no HTTP and no SQL in sight. That
has two consequences worth dwelling on. First, every front
door gets the rules for free—
10.5.3 Data access layer (repository)
Responsibility: all communication with the database—
# app/repositories.py (excerpt) -- speaks SQL, hides it from everyone above
class SqliteTaskRepository:
def count_open(self, owner: str) -> int:
row = self.conn.execute(
"SELECT COUNT(*) FROM tasks WHERE owner = ? AND done = 0",
(owner,), # parameterized: injection-safe
).fetchone()
return row[0]The interface the service sees (count_open, add, get,
complete) is written in domain vocabulary; the SQL is an
implementation detail behind it. Swap in
PostgresTaskRepository or the database lecture’s SQLAlchemy
crud.py, and the service cannot tell the difference—
10.5.4 Database layer
Responsibility: durable storage and integrity—
10.6 The Payoff: Testing Each Layer in Isolation
The reference outline lists “easier testing” as an advantage; here
is that advantage in runnable form—
The fake repository. Because the service depends on an interface, tests hand it an in-memory stand-in:
# tests/test_service_unit.py (excerpt)
class FakeTaskRepository:
"""In-memory repo: no SQL, no files, no server. Milliseconds."""
def __init__(self):
self.tasks, self.next_id = {}, 1
def count_open(self, owner):
return sum(1 for t in self.tasks.values()
if t.owner == owner and not t.done)
def add(self, owner, title, due):
t = Task(self.next_id, owner, title, due, False)
self.tasks[t.id] = t; self.next_id += 1
return t
# ... get / complete, ~15 lines total
def test_twenty_first_task_is_rejected():
svc = TaskService(FakeTaskRepository())
for i in range(20):
svc.create_task("alice", f"task {i}", date(2099, 1, 1))
with pytest.raises(TooManyOpenTasks):
svc.create_task("alice", "one too many", date(2099, 1, 1))Step through what just happened. We tested the flagship
business rule—
Each layer gets the kind of test it deserves:
Layer | Test style | Uses | Speed |
service | unit, with FakeTaskRepository | pure Python | microseconds |
repository | integration, real (temp) SQLite | SQL actually runs | milliseconds |
presentation | contract, TestClient over full stack | REST lecture's tests | milliseconds |
database | schema tests (database lecture) | constraints fire | milliseconds |
The pyramid shape is deliberate: many fast rule tests, fewer stack
tests. The REST lecture’s contract tests don’t disappear—
10.7 The Honest Trade-offs
10.7.1 Performance overhead
Every request traverses every layer—
10.7.2 Rigidity: one feature, four files
Add a “priority” field end-to-end and you will touch the schema,
the repository, the service, and the API schema—
10.7.3 Pass-through code (the real disease)
The most common degeneration: layers that merely forward.
# services.py -- adding nothing
def get_task(self, task_id):
return self.repo.get(task_id) # no rule, no translation, no checkWhen most of a service looks like this, the middle layer is dead
weight—
10.7.4 When to use it (and not)
Use layered architecture when: building CRUD-plus-rules applications
(the vast majority of business software—
10.8 The Neighborhood: Comparison with Other Architectures
Architecture | Main idea | Relation to layered |
Layered | organize by technical responsibility (UI, business, data) | --- |
MVC | separate Model/View/Controller | organizes the presentation layer's interior |
Microservices | split into independently deployable services | each service is often layered inside |
Event-driven | components communicate via async events | replaces the call-down chain with messages |
Hexagonal | core defines interfaces; HTTP and DB are adapters | rotates the diagram: DB becomes a plugin beside HTTP |
Clean | domain at center; dependencies point inward | layered with the dependency rule radicalized |
The hexagonal row deserves one more sentence, because our mini-project quietly took a step toward it: the moment TaskService depended on a TaskRepository interface (with SQLite and the fake as interchangeable implementations), the database stopped being “the foundation” and became “a replaceable adapter.” Hexagonal and clean architecture take that idea to its logical end. Layered is where everyone starts; knowing what the others change is how you’ll recognize when to move.
10.9 GitHub-Ready Mini-Project: terptasks-layered
See GitHub
10.10 Practice Exercises
10.10.1 Basic
E1. Take mudball.py and highlight each line with P (presentation), S (service), R (repository), or D (database). Which lines carry two letters at once? Those are the coupling points.
E2. For each snippet, name the layer it belongs in—
and the layer it’s probably in when found in the wild: (a) raise HTTPException(404); (b) SELECT COUNT(*) ...; (c) if user.role != "admin": raise NotAllowed; (d) CHECK (length(title) > 0); (e) converting a Task to JSON. E3. Draw the request trace for PATCH /v1/tasks/7/completion sent by bob for a task owned by alice, showing exactly which layer stops the request and what crosses each boundary on the way back up.
E4. State the dependency rule. Then explain which rule is violated (and why it matters) if: (a) services.py imports HTTPException; (b) repositories.py imports MAX_OPEN_TASKS; (c) api.py opens a sqlite3.connect(...).
E5. Why does test_twenty_first_open_task_is_rejected need no database, and what specifically about mudball.py makes the equivalent test there require one?
10.10.2 Intermediate
E6. Finish the refactor: mudball.py also had a GET /tasks?owner=... listing endpoint (write it if missing). Move it through all four layers—
repository method, service method (rule 3: only your own tasks), route— with one unit test and one contract test. E7. Add rule 4: a task may not be completed before its due date minus 30 days (no completing December tasks in July—
this is a procrastination app with standards). Implement it touching exactly one file, and write down which file. Then add the test. What does the one-file answer tell you about where the boundaries are? E8. Write PostgresTaskRepository (or reuse the database lecture’s SQLAlchemy crud.py behind an adapter) satisfying the TaskRepository protocol. The graded criterion: both test files pass unchanged with the new repository wired into main.py.
E9. Add a second front door: cli.py, a command-line interface (python -m app.cli add "study" 2099-01-01 –user alice) that calls TaskService directly. Demonstrate that the 20-task cap holds from the CLI without writing any new rule code—
then write one paragraph on what this proves about the service layer. E10. Introduce a deliberate pass-through: add services.get_open_count(owner) that just calls the repo. Now argue both sides in a short design memo: keep it (reserved seat) or let the API call repo.count_open directly (skip a layer)? Cite the trade-offs section, pick a side, and state the rule your team would adopt.
10.10.3 Advanced
E11. Rotate to hexagonal. Invert the remaining arrow: move TaskRepository (the Protocol) into domain.py, make repositories.py import from the domain, and add a second adapter—
JsonFileTaskRepository. Draw before/after dependency diagrams and explain, in terms of the comparison table, what just became possible that wasn’t before. E12. Layer-violation linter. Write a script (or Claude Code slash command + CI job, per the (part "Claude Code Files") lecture) that fails the build if forbidden vocabulary crosses layers: HTTPException|status_code in services.py/repositories.py, sqlite3|SELECT|INSERT outside repositories.py, import app.api anywhere below the presentation layer. Run it on mudball.py and count the violations.
E13. The architecture review, EGM-style. Write the four-section design doc (The Elephant-Goldfish Model lecture) for migrating terptasks-layered to hexagonal architecture, including the Alternatives section arguing why you did not choose microservices or event-driven for a campus task app. Goldfish-test the doc; deliver the doc plus the critic’s findings.
10.11 Summary
10.11.1 Key takeaways
Layered architecture = one responsibility per layer + the dependency rule (talk only downward, one layer at a time). The folders are cosmetic; the rule is the pattern.
The four layers answer “where does this code go?”—
HTTP at the top, rules in the middle, SQL below, storage at the bottom. Vocabulary is the violation detector: a word from another layer’s language is misplaced code. Boundaries are replaceability contracts. Swap SQLite for PostgreSQL, add a CLI beside the API, hand tests a fake—
each swap exercises a boundary, and each is impossible in the mud-ball. The testing pyramid falls out of the layers: many microsecond unit tests against fakes for the rules; fewer full-stack contract tests for the translation. Testability is a property of where the boundaries are.
The costs are real: per-request overhead, four-files-per-feature rigidity, and the pass-through disease. Layers are a tool, not a liturgy—
collapse one when it provably adds nothing. Policies vs. invariants: rules that could change live in the service; truths that make data valid live in the schema. Both are “business logic”; they have different homes.
Know the neighborhood: MVC organizes presentation’s interior; microservices distribute; hexagonal/clean invert the data-layer arrow. A repository interface is your first step on that road.
10.11.2 Terminology
Term | Meaning |
layered / n-tier architecture | system organized into single-responsibility layers |
presentation layer | HTTP/UI: parse, validate, delegate, translate, serialize |
service layer | policies, workflows, authorization, transactions |
data access / repository | all SQL/ORM; rows and domain objects translated |
dependency rule | layers depend only downward, one layer at a time |
domain exception | rule violation in domain vocabulary (TooManyOpenTasks) |
translation | boundary duty: rows to objects, exceptions to status codes |
composition root | the one module that wires all layers together |
fake / test double | in-memory stand-in satisfying a layer's interface |
pass-through code | a layer method that only forwards, adding nothing |
big ball of mud | the pattern-less alternative: everything couples to everything |
layers vs. tiers | logical separation vs. physical (machine) separation |
10.11.3 Common mistakes
SQL in route handlers; HTTPException in services (vocabulary leaks).
Mistaking folders for architecture while dependencies still tangle.
Testing business rules only through HTTP + database (slow, flaky, and it means the rules have no callable name).
Letting every service method become pass-through and defending the layer as ritual.
Duplicating rules in each front door instead of sharing the service.
Putting changeable policy in schema constraints, or data invariants only in Python.
“Fixing” a code-organization problem with microservices (tiers ≠ layers).
10.11.4 Connections
Backward: this lecture names what the FastAPI (presentation), (part "REST APIs") (the contract), and Relational Database Design & MySQL (data access + storage) lectures built; the E8 wiring exercise was the boundary payoff in advance; fakes-over-interfaces is the testing lecture’s dependency-injection theme grown up.
Forward: the OWASP notes’ access-control flaws are usually misplaced-layer bugs (authorization in the UI instead of the service); hexagonal/clean architecture radicalize today’s dependency rule; and for AI-assisted work, layered systems are what make the The Elephant-Goldfish Model “every single file” implementation lists short, predictable, and safe to hand to an agent.
10.12 Instructor Notes
Where students struggle.
“It’s just folders.” The E4 violations drill and the E12 linter make the dependency rule mechanical instead of aesthetic.
Which layer does validation go in? The honest answer is “each layer validates its own concern” (Pydantic shape-checks in presentation, policy in service, invariants in schema)—
teach it with E2(d) vs. rule 2. Where do transactions live? Service owns the boundary (“this workflow is atomic”), repository owns the mechanics. Mention it before the lab or it derails E6.
Pass-through guilt or pass-through blindness. Students either add ceremony everywhere or collapse everything. The E10 memo forces an actual position.
Layers vs. tiers vs. MVC. Ten minutes of terminology now saves a semester of confusion; the misconceptions in The Pattern are quiz gold.
Live demonstrations.
The one-file test race. Time pytest tests/test_service_unit.py (milliseconds), then write the equivalent cap test against mudball.py live—
watch the setup ceremony grow. The visceral speed difference sells the whole lecture. The E7 rule: add it, show one file changed, run both suites green.
Vocabulary grep as architecture review: grep -rn "HTTPException" app/services.py on a seeded violation.
Course tie-in: ask Claude Code to classify each mudball.py line by layer (E1), then compare with the class’s answers—
and note how much better the agent does with the layer names in context than without.
Quiz seeds. (1) Name the four layers and each one’s single responsibility; state the dependency rule. (2) raise HTTPException(403) appears in services.py. What’s wrong, what breaks (name a concrete consumer), and where does that logic belong? (3) Why can the 20-task cap be tested without a database in the layered version but not in the mud-ball? What property of the design makes it so? (4) Give one honest disadvantage of layering and the degenerate form it can take; when is collapsing a layer the right call? (5) In one sentence each: what do hexagonal architecture and microservices change relative to layered? (6) The team adds a Discord bot front door. What must be written, and what must not be duplicated? Why?
Homework ideas. E6+E7+E9 as the standard set (rounding out
the refactor, one-file rule change, second front door—
This lecture expands Layered_architecture.md (the course’s original outline, with Java/Spring examples) into the TerpTasks stack. The outline’s university-registration example survives as the house rules of the running example; its Spring controller is our FastAPI router. For more: Fowler’s Patterns of Enterprise Application Architecture (the repository and service layer patterns are chapters 10 and 9), and the C4 model for drawing these diagrams at every zoom level.