0 / 91
Week 8 · Day 50 of 91

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 req or res. 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.

  1. Create the folders src/routes, src/services, src/repositories, and the file src/errors.ts with the AppError class above.
  2. Before touching anything, run the endpoint with curl -i and save the exact status and body. This is your proof the refactor changed nothing.
  3. Move every SQL string for that endpoint into a repository function. Each function takes plain values and returns rows or null — never req, never res.
  4. Move validation, defaults, and uniqueness checks into a service function that throws AppError. Search the file for req. and res. afterwards: zero matches allowed.
  5. Reduce the route to: read input, call the service, map AppError.code to a status, respond.
  6. Re-run the exact curl from step 2. Same status, same body, or you broke something.
  7. Force the failure path too — post the same serial number twice and confirm 409.
  8. 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 req into 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.

  1. 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.
  2. 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

  1. 0–5 min Recall

    Without notes, state yesterday’s main idea and one unresolved question.

  2. 5–20 min Learn

    Read only the listed concept notes and official reference sections needed today.

  3. 20–48 min Build

    Refactor one equipment endpoint through route, service, and repository modules.

  4. 48–55 min Explain and verify

    Run the result, inspect evidence, and explain the data/control flow in your own words.

  5. 55–60 min Quiz and commit

    Complete the quiz, record one lesson, and commit the verified change when applicable.

What to hand in

Deliverable

One vertical slice with clear dependency direction.

Working with AI today

AI as skeptical reviewer

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

Q1 Where should business rules normally live?
Q2 Which result best proves today’s work is complete?
Q3 What should an AI code review primarily produce?

Explain-back gate

Pass the quiz above to unlock completion.

Quiz + explain-back checks required.