Without notes, state yesterday’s main idea and one unresolved question.
Route, service, and repository boundaries
Backend Architecture, Authentication, and Security
Objective
Separate transport, business rules, and persistence without creating needless layers.
Authentication identifies the operator; authorization checks which controls that operator is permitted to activate.
- route/controller responsibility
- service rules
- repository queries
Why this matters
Your API works. On Day 49 you connected it to PostgreSQL and the data survived a restart. But open one route handler and you will find four unrelated jobs crammed into one function: reading HTTP, checking rules, writing SQL, and choosing a status code. That code works right up until you need to change it — and this week you are about to change it a lot, adding users, passwords, and permissions.
Today you split one endpoint into three layers with one direction of dependency. By the end you can say, for any line of code, which layer it belongs to and why.
The three responsibilities hiding in one handler
Every endpoint does three separable things.
Transport — everything that is true only because the request arrived over HTTP: reading
req.body, choosing 201 versus 409, setting headers. This is the route (also called a
controller).
Rules — everything that would still be true if the request arrived by email: a serial number
must be unique, a retired machine cannot be scheduled for maintenance, a new machine defaults to
operational. This is the service.
Persistence — everything about how rows are stored and fetched: SQL text, table and column names, connection pools. This is the repository.
The point is not tidiness. It is that each layer has one reason to change. Switching from
res.json to a different response envelope touches routes only. Changing the uniqueness rule
touches the service only. Renaming a column touches the repository only. When a change touches
all three, that is a signal the split is wrong, not that layering failed.
Signal, logic, and storage stages
A measurement instrument has an input stage that conditions whatever arrives on the connector, a logic stage that decides what the reading means, and a storage stage that writes it to memory. They are separate blocks with defined interfaces, so you can swap the connector without redesigning the logic. Routes are the input stage, services are the logic, repositories are the storage stage. Wiring the connector straight to the memory chip works on the bench and is impossible to modify.
Dependency direction
Layers must point one way only:
route ──▶ service ──▶ repository ──▶ database
A route imports a service. A service imports a repository. Nothing points back. That single rule carries the weight:
- A service must never touch
reqorres. If it does, it can only be called from HTTP, and it can never be tested without inventing a fake request object. - A repository must never decide status codes, and must never contain a business rule. It answers "give me the row with this serial number", not "is this serial number allowed".
- A route must never contain SQL. If SQL appears in a route, the database schema is now part of your HTTP layer.
Front desk, manager, filing room
The front desk takes what walks in the door and speaks the language of visitors. The manager decides what is permitted. The filing room fetches and stores paper. The manager can decide without knowing whether the request arrived by phone or in person — and that independence is exactly what makes the manager's decision reusable and testable.
How a service reports failure without HTTP
Here is the problem that makes people give up on layering: the service knows a duplicate serial
number should produce 409, but it is not allowed to know about 409.
The fix is that the service throws an error carrying a meaning, and the route translates that meaning into HTTP. One small class does it:
// src/errors.ts
export class AppError extends Error {
constructor(
readonly code: 'not_found' | 'conflict' | 'invalid',
message: string,
) {
super(message);
}
}
code is domain vocabulary. The route owns the table that turns conflict into 409. Add a
second transport later — a CLI, a queue worker — and it brings its own table without the service
changing at all.
Layers you should not add
Layering earns its cost by absorbing change. A layer that only forwards its arguments absorbs nothing.
// A pass-through. It has no reason to change that its caller doesn't already have.
export function getEquipment(id: string) {
return equipmentRepository.findById(id);
}
Keep that function only if you expect a rule to land in it soon — permission checks, this week, are a real reason. Otherwise a route calling the repository directly for a plain read is honest code. Three layers everywhere, mechanically, is ceremony. Three layers where responsibilities genuinely differ is engineering.
Walkthrough: one endpoint, before and after
Here is POST /equipment as it probably looks now — everything in one place.
// src/routes/equipment.ts — BEFORE
router.post('/equipment', async (req, res) => {
const { name, serialNumber, status } = req.body;
if (!name || !serialNumber) {
return res.status(400).json({ error: 'name and serialNumber are required' });
}
const finalStatus = status ?? 'operational';
const existing = await pool.query(
'SELECT id FROM equipment WHERE serial_number = $1',
[serialNumber],
);
if (existing.rows.length > 0) {
return res.status(409).json({ error: 'serial number already registered' });
}
const result = await pool.query(
`INSERT INTO equipment (name, serial_number, status)
VALUES ($1, $2, $3) RETURNING *`,
[name, serialNumber, finalStatus],
);
res.status(201).json(result.rows[0]);
});
Now split it. The repository speaks SQL and nothing else:
// src/repositories/equipment.repository.ts
import { pool } from '../db.js';
export type Equipment = {
id: string;
name: string;
serial_number: string;
status: string;
};
export async function findBySerialNumber(serial: string): Promise<Equipment | null> {
const result = await pool.query<Equipment>(
'SELECT * FROM equipment WHERE serial_number = $1',
[serial],
);
return result.rows[0] ?? null;
}
export async function insert(
name: string,
serial: string,
status: string,
): Promise<Equipment> {
const result = await pool.query<Equipment>(
`INSERT INTO equipment (name, serial_number, status)
VALUES ($1, $2, $3) RETURNING *`,
[name, serial, status],
);
return result.rows[0];
}
Note the $1 placeholders you learned on Day 46 stayed here, where they belong.
The service holds the rules and knows no HTTP:
// src/services/equipment.service.ts
import * as repo from '../repositories/equipment.repository.js';
import { AppError } from '../errors.js';
const STATUSES = ['operational', 'maintenance', 'retired'];
export async function createEquipment(input: {
name?: string;
serialNumber?: string;
status?: string;
}) {
if (!input.name || !input.serialNumber) {
throw new AppError('invalid', 'name and serialNumber are required');
}
const status = input.status ?? 'operational';
if (!STATUSES.includes(status)) {
throw new AppError('invalid', `status must be one of ${STATUSES.join(', ')}`);
}
if (await repo.findBySerialNumber(input.serialNumber)) {
throw new AppError('conflict', 'serial number already registered');
}
return repo.insert(input.name, input.serialNumber, status);
}
The route becomes short, and is now only about HTTP:
// src/routes/equipment.ts — AFTER
import { Router } from 'express';
import * as service from '../services/equipment.service.js';
import { AppError } from '../errors.js';
const STATUS_FOR = { invalid: 400, not_found: 404, conflict: 409 } as const;
export const router = Router();
router.post('/equipment', async (req, res) => {
try {
const created = await service.createEquipment(req.body);
res.status(201).json(created);
} catch (error) {
if (error instanceof AppError) {
return res.status(STATUS_FOR[error.code]).json({ error: error.message });
}
throw error;
}
});
Restart the server and repeat a request you already know:
curl -i -X POST http://localhost:3000/equipment \
-H 'Content-Type: application/json' \
-d '{"name":"Pump 3","serialNumber":"PMP-003"}'
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Send it twice and the second returns 409. The observable behaviour is identical — that is
the definition of a refactor.
Checkpoint
Point at any line in the three files and name its layer, then say what change would force you to edit it. If two layers would change for the same reason, the split is in the wrong place.
Your turn
Refactor one equipment endpoint end to end — one vertical slice, meaning a single feature
carried through every layer it touches, with a clear dependency direction. Pick POST /equipment
if it has rules; otherwise pick GET /equipment/:id.
- Create the folders
src/routes,src/services,src/repositories, and the filesrc/errors.tswith theAppErrorclass above. - Before touching anything, run the endpoint with
curl -iand save the exact status and body. This is your proof the refactor changed nothing. - Move every SQL string for that endpoint into a repository function. Each function takes plain
values and returns rows or
null— neverreq, neverres. - Move validation, defaults, and uniqueness checks into a service function that throws
AppError. Search the file forreq.andres.afterwards: zero matches allowed. - Reduce the route to: read input, call the service, map
AppError.codeto a status, respond. - Re-run the exact
curlfrom step 2. Same status, same body, or you broke something. - Force the failure path too — post the same serial number twice and confirm
409. - Draw the dependency arrows in your notes and confirm none point backwards.
Reviewer mode — after your refactor runs and passes step 6
"Review whether each layer has one reason to change. Flag pass-through functions that add no value." Paste your three files and ask for specific findings with evidence — file, line, and the concrete change that would break the boundary — not general praise and not a rewrite. Then decide each finding yourself: some pass-throughs are worth keeping because a permission check lands there on Day 52. Accept nothing you cannot justify out loud.
You are done when
One endpoint runs through route → service → repository with identical observable behaviour, and you can state each layer's single reason to change.
Common pitfalls
- Passing
reqinto the service. The commonest failure. It drags HTTP into your rules and makes the service untestable without a fake request. Pass plain values. - SQL that leaked into the service. Easy to miss when a service "just needs one more query".
Grep your services folder for
pool.query— it should find nothing. - Splitting for the sake of it. A repository function wrapped by a service function wrapped by a route, with no rule anywhere, is three files doing one file's work. Add the layer when the reason exists.
- Refactoring and adding a feature in one go. If behaviour changes, you cannot tell whether the refactor broke it. Refactor, verify identical output, commit, then add the feature.
Verify it yourself
Open today's reference, the Express Getting started guide, and look at how its examples are organised.
- The Express examples put handler logic straight inside
app.get(...). Does the guide claim that is how production apps should be structured, or is it showing the smallest runnable thing? Find the sentence that tells you which. - Express provides
Router. Read what the docs say a router is for, and decide: is a router a layer in today's sense, or just a way to group routes? Write your answer in one sentence.
Record both answers in your notes. Framework docs optimise for a short first example; deciding where that stops being appropriate for your project is your judgement, not theirs.
The hour
- 0–5 min Recall
- 5–20 min Learn
Read only the listed concept notes and official reference sections needed today.
- 20–48 min Build
Refactor one equipment endpoint through route, service, and repository modules.
- 48–55 min Explain and verify
Run the result, inspect evidence, and explain the data/control flow in your own words.
- 55–60 min Quiz and commit
Complete the quiz, record one lesson, and commit the verified change when applicable.
What to hand in
One vertical slice with clear dependency direction.
Working with AI today
Provide existing work and ask for concrete defects, risks, missing tests, and unsupported assumptions—not praise.
Review whether each layer has one reason to change. Flag pass-through functions that add no value.
References
End-of-day quiz
Explain-back gate
Pass the quiz above to unlock completion.
Quiz + explain-back checks required.