Layered Architecture — Separation of Concerns
On the Start Here page we mapped a web app to a restaurant: customer → waiter → kitchen → cookbook → pantry. That mapping wasn’t just a cute story. It’s the single most important idea in backend design, and it has a name: layering. This page turns that intuition into a precise, professional skill — and we map every layer to a real file in the Assure PAT backend you work on.
⏱ ~18 min · 🟡 Intermediate · Prerequisites: Start Here + Foundations
🎯 What you’ll be able to do after this page
- Explain “separation of concerns” to anyone, with an analogy that sticks
- Name the four classic layers and say exactly what each one may and may not do
- Open the Assure PAT backend and point at the route, middleware, controller, service, and model for any feature
- Spot the “fat controller” smell and know where business logic actually belongs
- Trace one real request down through every layer and back up
1. Why layering exists: the company analogy
Imagine a company where anyone can do anything. The receptionist signs legal contracts. An engineer wanders into the filing room and rewrites payroll records by hand. A customer walks straight into the server room. Chaos. Nobody knows who’s responsible for what, and one wrong move corrupts everything.
Real companies don’t work like that. They have layers of responsibility:
flowchart TD
Reception["Reception<br/>(greets, checks you in)"] --> Managers["Managers<br/>(decide what to do)"]
Managers --> Workers["Workers<br/>(do the actual job)"]
Workers --> Filing["Filing Room<br/>(stores records)"]
- Reception talks to the outside world. It does no real work — it just routes you to the right person and turns visitors away if they’re not allowed in.
- Managers decide what should happen. They know the rules. They don’t file paperwork themselves.
- Workers carry out the actual task.
- The filing room only stores and retrieves records. It has no opinion about the business.
Each layer has one job and only talks to the layer next to it. The customer never touches the filing room directly. That’s separation of concerns: every part of the system has a single, clear responsibility, and changes in one layer don’t ripple through the others.
The one-sentence definition
Layering = stacking your code so each layer has exactly one responsibility and only talks to the layer directly below it. Get this right and your system stays understandable no matter how big it grows.
The cost of not layering: spaghetti
When you skip layering, you get spaghetti code — logic tangled together so tightly you can’t change one thing without breaking five others:
flowchart LR
A["Route handler"] <--> B["SQL queries"]
A <--> C["Business rules"]
B <--> C
C <--> D["Response shaping"]
D <--> A
B <--> D
Everything touches everything. Want to change how you talk to the database? You now have to edit fifty route handlers, because the SQL is inside each one. Want to reuse the “approve a card request” logic in two places? You can’t — it’s welded to a single HTTP handler. Want to write a test? Impossible — you can’t run the business rule without spinning up a fake web request and a real database. Spaghetti is slow to change, scary to touch, and where bugs go to hide.
2. The classic n-tier pattern
The industry-standard cure is the n-tier (layered) architecture. The four layers, top to bottom:
flowchart TD
P["Presentation Layer<br/>(handles HTTP / UI)"] --> B["Business Logic Layer<br/>(the rules & decisions)"]
B --> DA["Data Access Layer<br/>(talks to the DB)"]
DA --> DB[("Database")]
The golden rule of layering: a layer may only call the layer directly below it, and never reach back up. Data flows down as a request and back up as a response — like the company, you don’t let the filing room phone the customer directly.
| Layer | Its ONE job | What it must NOT do |
|---|---|---|
| Presentation | Receive the request, validate shape, send a response | Contain business rules; run SQL |
| Business Logic | Apply the rules, make decisions, coordinate work | Know about HTTP (no req/res); write raw SQL |
| Data Access | Read/write rows; hide how data is stored | Make business decisions |
| Database | Store data durably and safely | Anything application-specific |
Notice each “must NOT” is just another layer’s job. Business rules don’t belong in Presentation — they belong in Business Logic. SQL doesn’t belong in the controller — it belongs in Data Access. Every time you feel the urge to put code somewhere, ask: “is this this layer’s one job?” If not, it belongs in a neighbour.
3. Assure PAT’s real backend layers
Now the payoff. Assure PAT is Node + Express 5 + Knex + MySQL 8 (schema assurepat), deployed serverless on AWS Lambda. Here’s how the textbook layers map onto the actual folders you work in every day:
flowchart TD
Client(["Client<br/>(React dashboard / mobile app)"]) -->|"HTTPS request"| R["routes/<br/>(URL → handler)"]
R --> MW["middleware chain<br/>(the gates)"]
MW --> C["controllers/<br/>(HTTP orchestration)"]
C --> S["service/<br/>(business logic)"]
S --> M["models/<br/>(DB access via Knex)"]
M --> K["config/knex<br/>(connection pool)"]
K --> DB[("MySQL 8<br/>assurepat")]
Read it top to bottom: the request enters at routes/, passes the middleware gates, hits a controller, which calls a service for the actual logic, which asks a model to touch the data, which goes through Knex to MySQL. The response retraces those steps back up.
The middleware chain — the gates before the kitchen
In Express, middleware are functions that run in order before your controller. Think of them as security gates a request must pass through — fail any one and the request is rejected before it ever reaches your logic. Assure PAT’s standard chain:
flowchart LR
Req(["Request"]) --> G1{"verifyToken<br/>(authenticated?)"}
G1 -->|"no"| X1["401 Unauthorized"]
G1 -->|"yes"| G2{"extractTenant<br/>(which org_id?)"}
G2 --> G3{"validateRequest<br/>(zod: body valid?)"}
G3 -->|"no"| X2["400 Bad Request"]
G3 -->|"yes"| G4{"checkRoleAccess<br/>(allowed for role?)"}
G4 -->|"no"| X3["403 Forbidden"]
G4 -->|"yes"| H["Controller handler"]
Each gate has one concern:
authMiddleware.verifyToken— Who are you? Verifies the JWT. Assure PAT has three token types, each with its own verifier:verifyToken(org web users),verifyMobileUserToken(the mobile NFC app), andverifySystemToken(Ayris global admins). No valid token → 401.extractTenant— Which organization’s data? Pulls theorg_idoff the verified token. This is the heart of multi-tenancy: every customer’s data lives in shared tables, separated byorg_id. Downstream, queries use ascopedQuery(table, orgId)helper so one org can never see another’s rows.validateRequest— Is the input well-formed? Checks the request body against a zod schema (zod = a TypeScript-friendly validation library). Garbage in → 400, before a single line of business logic runs.checkRoleAccess— Are you allowed to do this? This is RBAC (Role-Based Access Control). Assure PAT has 6 org roles —1SME/Admin,2Approver,3SME Partner,4Manager,5Mobile User,6Profile Editor — and roughly 186 role→action mappings. If your role can’t perform this action → 403.
Why gates, not one big check?
Each gate is independent and reusable. The same
verifyTokenprotects hundreds of routes. The samecheckRoleAccessenforces all 186 mappings. Change the auth rule once, every route benefits. That’s separation of concerns applied to security.
The full layer-to-file map
This is the table to memorise. Open the backend repo alongside it.
| Layer | Responsibility | Must NOT do | Assure PAT file |
|---|---|---|---|
| Routes | Map a URL + verb to a handler; attach the middleware chain | Contain logic of any kind | routes/cardRequestRoutes.js |
| Middleware | Gate the request: auth, tenant, validation, role | Make business decisions | authMiddleware.verifyToken, extractTenant, validateRequest, checkRoleAccess |
| Controller | Orchestrate HTTP: read req, call a service, shape the res | Hold business rules; run SQL | controllers/CardRequestController.js |
| Service | The business logic — rules, decisions, coordinating models | Know about HTTP (req/res); write raw SQL | service/CardRequestService.js |
| Model | Data access — build queries via Knex, hide table shape | Make business decisions | models/CardRequestSelectedProfiles.js |
| DB layer | Connection pool + the database itself | Application logic | config/knex → MySQL assurepat |
The mental test for “where does this code go?”
- Does it touch
reqorres? → Controller (or middleware).- Is it a rule or a decision (“approvers can’t approve their own request”)? → Service.
- Does it build a SQL/Knex query? → Model.
- Is it “who are you / can you do this”? → Middleware.
4. The “fat controller” anti-pattern
Here’s the most common mistake beginners make, and it’s worth burning into memory.
A fat controller is a controller that has swallowed the business logic and the database queries that should live below it. It “got fat” by eating its neighbours’ jobs:
flowchart TD
subgraph Bad["❌ Fat controller (anti-pattern)"]
FC["Controller<br/>parses req +<br/>applies all rules +<br/>runs Knex queries +<br/>shapes response"]
end
subgraph Good["✅ Thin controller (correct)"]
TC["Controller<br/>(parse req,<br/>shape res)"] --> SV["Service<br/>(rules)"] --> MD["Model<br/>(Knex)"]
end
Why fat controllers hurt:
- Not reusable. The “validate and approve a card request” rule is trapped inside one HTTP handler. Need the same rule from a scheduled job or another endpoint? You can’t reach it.
- Not testable. To test one business rule you’d have to fake an entire HTTP request and a live database. With logic in a service, you test the service directly — fast and isolated.
- Hard to change. Swap MySQL for something else? In a fat controller the SQL is scattered through every handler. With models, you change one layer.
The rule, stated bluntly
Business logic belongs in services — not in controllers, not in models.
- Controllers are thin: parse the request, call a service, shape the response. If a controller is more than a handful of lines, ask what it’s doing that a service should own.
- Models are dumb about the business: they fetch and store rows. They don’t decide whether something is allowed — that’s a rule, and rules live in services.
Now test your gut. For each snippet below, decide which layer it belongs in before you reveal the answer.
5. The frontend has layers too (briefly)
Layering isn’t a backend-only idea. The Assure PAT frontend (React 18 + Vite) stacks the same way:
flowchart TD
UI["pages/ + components/<br/>(what the user sees)"] --> SVC["services/<br/>(axios apiService)"]
SVC -->|"HTTP"| API["Backend API"]
Ctx["AuthContext<br/>(shared user/token state)"] -.->|"reads/provides"| UI
Ctx -.-> SVC
pages/andcomponents/are the presentation layer — purely what the user sees and clicks. They should not know how to call the server.services/(the axios-basedapiService) is the data-access layer of the frontend. All HTTP calls funnel through here, so headers, base URLs, and error handling live in one place — not sprinkled across every button.AuthContextholds shared state (the logged-in user, token, role) so any component can read it without “prop-drilling” it down through ten layers of components.
Same principle, different stack: each layer has one job, and the UI never reaches past services/ to talk to the network directly.
6. Recap: trace one real request through the layers
Let’s put it all together with a real Assure PAT endpoint. When a user sets which card profiles a card request should test:
PUT /api/v2/card-requests/:id/selected-profiles
Watch it travel down the layers and back up:
sequenceDiagram
participant Client as Client (browser)
participant Route as routes/
participant MW as middleware
participant Ctrl as CardRequestController
participant Svc as CardRequestService
participant Model as CardRequestSelectedProfiles
participant DB as MySQL (assurepat)
Client->>Route: PUT /card-requests/42/selected-profiles
Route->>MW: run the gate chain
MW->>MW: verifyToken, extractTenant, validateRequest, checkRoleAccess
MW->>Ctrl: setSelectedProfilesForRequest(req, res)
Ctrl->>Svc: replace profiles for request 42
Svc->>Model: replaceSet(requestId, profileIds, orgId)
Model->>DB: scoped Knex INSERT / DELETE
DB-->>Model: rows changed
Model-->>Svc: result
Svc-->>Ctrl: outcome
Ctrl-->>Client: 200 { selectedProfiles: [...] }
Narrated in one breath:
- Route (
routes/cardRequestRoutes.js) matches the URL +PUTand fires the middleware chain. - Middleware gates confirm a valid token, pin the
org_id, validate the body with zod, and check the role can do this. - Controller (
CardRequestController.setSelectedProfilesForRequest) readsreq, then delegates — it doesn’t decide anything itself. - Service (
CardRequestService) holds the rule for what “replace the selected set” means and calls the model. - Model (
CardRequestSelectedProfiles.replaceSet) builds the Knex query — scoped byorgIdso it can only touch this org’s rows — and hits MySQL. - The response retraces the path upward: DB → model → service → controller → a clean
200JSON for the client.
The skill this whole page builds
Every request in any backend follows this shape: enter at a route → pass gates → controller delegates → service decides → model reads/writes → response flows back up. Once you can trace it, you can read any codebase on earth. We’ll do exactly this, in forensic detail, on the Request Lifecycle page.
7. Practice (do this, don’t just read)
- Re-draw the layer stack (Routes → Middleware → Controller → Service → Model → MySQL) from memory on paper. Label each layer’s one job.
- Open the real backend and find
routes/cardRequestRoutes.js,controllers/CardRequestController.js,service/CardRequestService.js, andmodels/CardRequestSelectedProfiles.js. See the layers in the flesh. - Audit one controller method. Does it touch the database directly, or apply a business rule? If so, you’ve found a fat controller — note what should move to the service.
- Explain it out loud to an imaginary beginner: “A request comes in at routes, passes four gates, the controller delegates to a service, the service asks a model…” Where you stumble is where you don’t yet understand.
Check Yourself
Close the page and answer out loud first. Only then reveal.
1. State the golden rule of layering in one sentence.
A layer may only call the layer directly below it and never reach back up. Requests flow down, responses flow back up.
2. Name the four classic n-tier layers, top to bottom.
Presentation → Business Logic → Data Access → Database.
3. In Assure PAT, what are the four middleware gates a request passes, in order, and what does each ask?
verifyToken (who are you?), extractTenant (which org_id?), validateRequest (is the body valid via zod?), checkRoleAccess (is your role allowed to do this?). Failures return 401, then 400 at validation, then 403 at role check.
4. What is a fat controller, and what are the two things it wrongly swallowed?
A controller that took on jobs belonging below it: the business rules (which belong in the service) and the database queries (which belong in the model). A thin controller only parses req, calls a service, and shapes res.
5. Give the four-question mental test for deciding where a piece of code goes.
Does it touch req or res? Controller. Is it a rule or decision? Service. Does it build a Knex/SQL query? Model. Is it ‘who are you / can you do this’? Middleware.
6. Why is business logic in a service easier to test than the same logic in a controller?
A service has no HTTP dependency, so you call it directly with plain arguments — fast and isolated. Logic stuck in a controller forces you to fake a whole HTTP request (and often a live database) just to test one rule.
7. What does the frontend services/ (axios apiService) layer correspond to on the backend, and why funnel all calls through it?
It is the frontend’s data-access layer. Funnelling every HTTP call through it keeps headers, base URLs, and error handling in one place instead of sprinkled across every button, and keeps pages/components ignorant of how to reach the network.
8. Trace PUT /api/v2/card-requests/:id/selected-profiles down through every layer in one breath.
Route matches the URL and fires the middleware chain → gates verify token, pin org_id, validate body, check role → controller reads req and delegates → service holds the ‘replace the selected set’ rule and calls the model → model builds a scoped Knex query and hits MySQL → the response retraces upward to a clean 200 JSON.
Still Unclear?
Paste any of these into Claude to go deeper:
- “Here is one controller method from my Express app: [paste it]. Is it a fat controller? Show me exactly which lines should move to a service and which to a model, and why.”
- “Explain the difference between business logic and data access using a real example, like ‘an approver cannot approve their own request’. Where does each part live in a layered architecture?”
- “Give me a 5-question drill where you show a code snippet and I have to name the layer (controller / service / model / middleware). Mark me and explain my mistakes.”
Why AI Can’t Do This For You
AI can generate a controller, a service, and a model in seconds — but it will happily cram all three into one fat handler if your prompt doesn’t insist otherwise, because it pattern-matches the tutorials it was trained on. Deciding where a given rule belongs is a judgment call that depends on how your system will grow, what you will need to reuse, and what you will need to test — context only you hold. The skill that survives is being able to look at a tangled handler and feel, instantly, “this rule has no business being here.” You earn that feel by tracing real requests through real layers, not by reading the answer.
Next up: Data Modeling — we open the pantry (MySQL) and learn how Assure PAT shapes its data, what org_id really buys you, and how to read those crow’s-foot relationships for real. 👉
Module done? Add it to today’s tracker