Backend code is split into three layers: the controller turns an HTTP request into a call and the result back into an HTTP response, the service applies business rules and controls transactions, and the repository reads and writes data. Input shape validation lives in the controller, domain rules in the service, and SQL only in the repository. This split lets you test rules without HTTP or a database by passing an in-memory repository, and change storage without touching the logic.
After this you will be able to
- Split a fat Express handler into a controller, a service and a repository while keeping the HTTP contract
- Explain which checks belong to the controller, which to the service and which to database constraints
- Move a business rule from the controller into the service and cover it with a test that swaps the repository
- Wrap several writes in one service-level transaction and explain why uniqueness is guaranteed by the database, not by a check in code
Know beforehand
Verified with
Node.js 24 LTS (24.18)TypeScript 7.0.2 / 6.0.3 / 5.9.3 (tsc --strict)Express 5.3.0Zod 4.6pg (node-postgres) 8.23PostgreSQL 17.11 (Docker, postgres:17-alpine)Vitest 5.0, supertest 7.3, tsx 4.23NestJS 12.1 (фрагмент сравнения)
Checked against docs: October 11, 2026
Contents
The symptom: a fat controller
Start with code almost everyone writes — and that works fine while the project is small. A leads API on Express: create a lead and change its status. Everything sits right inside the route handlers.
import { randomUUID } from "node:crypto";
import express from "express";
type Lead = {
id: string;
name: string;
phone: string;
status: "new" | "in_progress" | "done";
createdAt: string;
};
const leads = new Map<string, Lead>();
export const app = express();
app.use(express.json());
app.post("/leads", (req, res) => {
const { name, phone } = req.body ?? {};
if (typeof name !== "string" || name.trim() === "") {
res.status(400).json({ error: "name is required" });
return;
}
if (typeof phone !== "string" || !/^\+7\d{10}$/.test(phone)) {
res.status(400).json({ error: "phone must be +7XXXXXXXXXX" });
return;
}
for (const lead of leads.values()) {
if (lead.phone === phone && lead.status !== "done") {
res.status(409).json({ error: "duplicate lead" });
return;
}
}
const lead: Lead = {
id: randomUUID(),
name: name.trim(),
phone,
status: "new",
createdAt: new Date().toISOString(),
};
leads.set(lead.id, lead);
res.status(201).json(lead);
});
app.patch("/leads/:id/status", (req, res) => {
const lead = leads.get(req.params.id);
if (!lead) {
res.status(404).json({ error: "not found" });
return;
}
const next = req.body?.status;
if (lead.status === "done" || !["in_progress", "done"].includes(next)) {
res.status(409).json({ error: "transition not allowed" });
return;
}
lead.status = next;
res.json(lead);
});Each handler mixes four different jobs: HTTP parsing (body, params, status codes), input shape validation (name not empty, phone format), domain rules (one open lead per phone, no way back from done) and storage (Map). With two handlers nobody notices. Problems start with change:
- Leads start arriving from a Telegram bot and a CSV import. The one-open-lead-per-phone rule is needed there too, but it is buried in an HTTP handler — it gets copied, and the copies drift apart.
- To test the status transition rule you have to boot HTTP and send requests: there is no separate function a test can call.
- Moving from
Mapto PostgreSQL touches every handler, because data access is spread across the code. - Changing the status and writing to the change history must happen together. A handler has no place to declare that these two writes are one operation.
Three layers and who does what
- 01ControllerHTTP: route, parsing and shape validation, status codes, response DTO
- 02ServiceUse cases and business rules, transaction boundaries, domain errors
- 03RepositoryReading and writing data, SQL, mapping table rows to objects
- 04DatabaseStorage and integrity constraints: NOT NULL, CHECK, unique indexes
In his catalog of enterprise application patterns, Martin Fowler describes the Service Layer as the application boundary: a set of available operations, each coordinating the application's response. His Repository mediates between the domain and the data mapping layers and acts like an in-memory collection of objects. These two patterns give us the service and the repository; the controller is an adapter between HTTP and the service.
Is phone present and does it look like a phone?
- Controller
- Yes — Zod schema
- Service
- —
- Repository / DB
- —
An extra role field in the body?
- Controller
- Yes — reject with 400
- Service
- —
- Repository / DB
- —
Does this phone already have an open lead?
- Controller
- —
- Service
- Yes — a rule with a clear error
- Repository / DB
- Unique index — the guarantee under a race
Can the status go from done to new?
- Controller
- —
- Service
- Yes — the transition table
- Repository / DB
- —
Status and history must be written together
- Controller
- —
- Service
- Yes — the transaction boundary
- Repository / DB
BEGIN/COMMIT/ROLLBACK
What is the column called?
- Controller
- —
- Service
- —
- Repository / DB
- Yes —
created_at→createdAt
Which HTTP code means «not found»?
- Controller
- Yes — the error handler
- Service
- Throws
LeadNotFoundError - Repository / DB
- Returns
null
The key distinction is between shape validation and a business rule. Shape answers «can this request be understood at all»: types, required fields, length, format. The answer does not depend on system state, so the check lives at the entry point, in the controller. A business rule answers «is this operation allowed right now»: answering it requires reading data, so it belongs to the service. Database constraints are the last line: they hold even when two requests check the rule at the same moment.
Data also changes shape between layers. The input DTO is what the client sent, after schema validation. The domain Lead is what rules work with (the date is a Date). A table row is what SQL returns (created_at). The response DTO is what the API promises clients (the date is an ISO string, the phone is not returned). Mapping at the boundaries lets you rename a column or hide a field without breaking the other layers.
Controller: HTTP and nothing else
npm init -y && npm pkg set type=module
npm install express@5 zod@4 pg
npm install -D typescript @types/node@24 @types/express @types/pg vitest supertest @types/supertest tsx
docker run -d --rm --name leads-pg -e POSTGRES_PASSWORD=test -p 127.0.0.1:55432:5432 postgres:17-alpine{
"compilerOptions": {
"target": "ES2023",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src", "test"]
}First the model and the domain errors. They know nothing about HTTP or the database, so every layer can import them.
export const LEAD_STATUSES = ["new", "in_progress", "done"] as const;
export type LeadStatus = (typeof LEAD_STATUSES)[number];
export type Lead = {
id: string;
name: string;
phone: string;
message: string | null;
status: LeadStatus;
createdAt: Date;
};
export type LeadEvent = {
leadId: string;
from: LeadStatus | null;
to: LeadStatus;
at: Date;
};
const TRANSITIONS: Record<LeadStatus, readonly LeadStatus[]> = {
new: ["in_progress", "done"],
in_progress: ["done"],
done: [],
};
export function canTransition(from: LeadStatus, to: LeadStatus): boolean {
return TRANSITIONS[from].includes(to);
}
export class DomainError extends Error {
constructor(
readonly code: string,
message: string,
) {
super(message);
}
}
export class LeadNotFoundError extends DomainError {
constructor(id: string) {
super("LEAD_NOT_FOUND", `Lead ${id} not found`);
}
}
export class DuplicateLeadError extends DomainError {
constructor() {
super("LEAD_DUPLICATE", "An open lead with this phone already exists");
}
}
export class InvalidTransitionError extends DomainError {
constructor(from: LeadStatus, to: LeadStatus) {
super("LEAD_INVALID_TRANSITION", `Cannot change status from ${from} to ${to}`);
}
}The controller does three things: validates the request shape with a Zod schema, calls the service and turns the result into a response DTO. z.strictObject rejects unknown fields, and z.flattenError turns errors into a field → messages object. Handlers are async: in Express 5 a rejected promise from a handler is passed to the error handler automatically, so no try/catch is needed in each route.
import { Router } from "express";
import { z } from "zod";
import { type Lead, LEAD_STATUSES, LeadNotFoundError } from "./lead.js";
import type { LeadsService } from "./leads.service.js";
const createLeadSchema = z.strictObject({
name: z.string().trim().min(1).max(100),
phone: z.string().regex(/^\+7\d{10}$/, "phone must be +7XXXXXXXXXX"),
message: z.string().max(1000).optional(),
});
const changeStatusSchema = z.strictObject({
status: z.enum(LEAD_STATUSES),
});
const idSchema = z.uuid();
// A malformed id cannot exist in storage: answer 404 without a database query
function parseId(raw: string): string {
if (!idSchema.safeParse(raw).success) throw new LeadNotFoundError(raw);
return raw;
}
// Response DTO: what the API promises to clients, independent of storage
const toResponse = (lead: Lead) => ({
id: lead.id,
name: lead.name,
status: lead.status,
createdAt: lead.createdAt.toISOString(),
});
export function leadsRouter(service: LeadsService): Router {
const router = Router();
router.post("/", async (req, res) => {
const parsed = createLeadSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json(z.flattenError(parsed.error));
return;
}
const lead = await service.create(parsed.data);
res.status(201).json(toResponse(lead));
});
router.get("/:id", async (req, res) => {
res.json(toResponse(await service.get(parseId(req.params.id))));
});
router.patch("/:id/status", async (req, res) => {
const parsed = changeStatusSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json(z.flattenError(parsed.error));
return;
}
const lead = await service.changeStatus(parseId(req.params.id), parsed.data.status);
res.json(toResponse(lead));
});
return router;
}Mapping domain errors to HTTP codes happens in one place — an error-handling middleware with four arguments. The service does not know that «not found» means 404: the same error in a Telegram bot becomes a message text. Unknown errors are logged, and the client gets a 500 without details. The error body format is simplified here; a consistent response format is covered in the Express REST API lesson.
import type { ErrorRequestHandler } from "express";
import {
DomainError,
DuplicateLeadError,
InvalidTransitionError,
LeadNotFoundError,
} from "../leads/lead.js";
function statusFor(error: DomainError): number {
if (error instanceof LeadNotFoundError) return 404;
if (error instanceof DuplicateLeadError) return 409;
if (error instanceof InvalidTransitionError) return 409;
return 400;
}
export const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {
if (err instanceof DomainError) {
res.status(statusFor(err)).json({ code: err.code, message: err.message });
return;
}
console.error(err);
res.status(500).json({ code: "INTERNAL", message: "Internal server error" });
};import express from "express";
import { errorHandler } from "./http/error-handler.js";
import { leadsRouter } from "./leads/leads.controller.js";
import type { LeadsService } from "./leads/leads.service.js";
export function createApp(leads: LeadsService) {
const app = express();
app.use(express.json());
app.use("/leads", leadsRouter(leads));
app.use(errorHandler);
return app;
}Service: business rules and transactions
The service receives its dependencies through the constructor: the repository and a clock function. This is the same dependency injection the NestJS container performs, done by hand. Injecting the clock makes tests deterministic: the event date is always the same.
import { randomUUID } from "node:crypto";
import {
canTransition,
DuplicateLeadError,
InvalidTransitionError,
type Lead,
LeadNotFoundError,
type LeadStatus,
} from "./lead.js";
import type { LeadsRepository } from "./leads.repository.js";
export type CreateLeadInput = { name: string; phone: string; message?: string };
export class LeadsService {
constructor(
private readonly repo: LeadsRepository,
private readonly now: () => Date = () => new Date(),
) {}
create(input: CreateLeadInput): Promise<Lead> {
return this.repo.transaction(async (tx) => {
if (await tx.findOpenByPhone(input.phone)) {
throw new DuplicateLeadError();
}
const lead: Lead = {
id: randomUUID(),
name: input.name,
phone: input.phone,
message: input.message ?? null,
status: "new",
createdAt: this.now(),
};
await tx.insert(lead);
await tx.addEvent({ leadId: lead.id, from: null, to: "new", at: lead.createdAt });
return lead;
});
}
async get(id: string): Promise<Lead> {
const lead = await this.repo.findById(id);
if (!lead) throw new LeadNotFoundError(id);
return lead;
}
changeStatus(id: string, to: LeadStatus): Promise<Lead> {
return this.repo.transaction(async (tx) => {
const lead = await tx.findById(id);
if (!lead) throw new LeadNotFoundError(id);
if (!canTransition(lead.status, to)) {
throw new InvalidTransitionError(lead.status, to);
}
await tx.updateStatus(id, to);
await tx.addEvent({ leadId: id, from: lead.status, to, at: this.now() });
return { ...lead, status: to };
});
}
}Why the transaction starts in the service rather than in the controller or the repository. A transaction is the boundary of a business operation: «create a lead and record an event» is one operation, «change the status and record an event» is another. Only the service knows this. The controller should not know how many writes an operation makes, and a single repository method sees only its own write. Fowler explicitly assigns transaction control to the service layer; Spring works the same way, with @Transactional on service methods.
Inside transaction the service uses the provided tx, not this.repo, so every read and write goes through the same database connection. If any step throws — a rule is violated, the event write fails — everything rolls back, and the database never holds a lead without its history.
Repository: data access
The service depends on an interface, not a concrete class. It contains only what the use cases need, in domain terms: «find an open lead by phone», not «run a SELECT».
import type { Lead, LeadEvent, LeadStatus } from "./lead.js";
export interface LeadsRepository {
findById(id: string): Promise<Lead | null>;
findOpenByPhone(phone: string): Promise<Lead | null>;
insert(lead: Lead): Promise<void>;
updateStatus(id: string, status: LeadStatus): Promise<void>;
addEvent(event: LeadEvent): Promise<void>;
/** Runs work atomically: either every write inside it is saved, or none is. */
transaction<T>(work: (tx: LeadsRepository) => Promise<T>): Promise<T>;
}The first implementation is in memory. It serves tests and running without a database. Its «transaction» is a snapshot restored on error; a teaching stand-in that does not protect against concurrent requests.
import type { Lead, LeadEvent, LeadStatus } from "./lead.js";
import type { LeadsRepository } from "./leads.repository.js";
export class InMemoryLeadsRepository implements LeadsRepository {
leads = new Map<string, Lead>();
events: LeadEvent[] = [];
async findById(id: string): Promise<Lead | null> {
const lead = this.leads.get(id);
return lead ? { ...lead } : null;
}
async findOpenByPhone(phone: string): Promise<Lead | null> {
for (const lead of this.leads.values()) {
if (lead.phone === phone && lead.status !== "done") return { ...lead };
}
return null;
}
async insert(lead: Lead): Promise<void> {
this.leads.set(lead.id, { ...lead });
}
async updateStatus(id: string, status: LeadStatus): Promise<void> {
const lead = this.leads.get(id);
if (lead) lead.status = status;
}
async addEvent(event: LeadEvent): Promise<void> {
this.events.push(event);
}
// Teaching stand-in for a real transaction: restores a snapshot on error.
// Not safe for concurrent requests — that is the database's job.
async transaction<T>(work: (tx: LeadsRepository) => Promise<T>): Promise<T> {
const leads = new Map([...this.leads].map(([id, lead]) => [id, { ...lead }]));
const events = [...this.events];
try {
return await work(this);
} catch (error) {
this.leads = leads;
this.events = events;
throw error;
}
}
}The second is PostgreSQL via node-postgres. The library docs require a transaction to run on a single client from the pool (pool.connect()), not via pool.query, and the client to be released in finally. The transaction method builds a repository on top of that client and hands it to the service's work. The toLead mapper is the only place that knows column names.
import pg from "pg";
import { DuplicateLeadError, type Lead, type LeadEvent, type LeadStatus } from "./lead.js";
import type { LeadsRepository } from "./leads.repository.js";
type LeadRow = {
id: string;
name: string;
phone: string;
message: string | null;
status: LeadStatus;
created_at: Date;
};
const toLead = (row: LeadRow): Lead => ({
id: row.id,
name: row.name,
phone: row.phone,
message: row.message,
status: row.status,
createdAt: row.created_at,
});
export class PgLeadsRepository implements LeadsRepository {
constructor(private readonly db: pg.Pool | pg.PoolClient) {}
async findById(id: string): Promise<Lead | null> {
const { rows } = await this.db.query<LeadRow>("SELECT * FROM leads WHERE id = $1", [id]);
return rows[0] ? toLead(rows[0]) : null;
}
async findOpenByPhone(phone: string): Promise<Lead | null> {
const { rows } = await this.db.query<LeadRow>(
"SELECT * FROM leads WHERE phone = $1 AND status <> 'done'",
[phone],
);
return rows[0] ? toLead(rows[0]) : null;
}
async insert(lead: Lead): Promise<void> {
try {
await this.db.query(
`INSERT INTO leads (id, name, phone, message, status, created_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
[lead.id, lead.name, lead.phone, lead.message, lead.status, lead.createdAt],
);
} catch (error) {
// 23505 = unique_violation: another request created an open lead first
if (error instanceof Error && "code" in error && error.code === "23505") {
throw new DuplicateLeadError();
}
throw error;
}
}
async updateStatus(id: string, status: LeadStatus): Promise<void> {
await this.db.query("UPDATE leads SET status = $2 WHERE id = $1", [id, status]);
}
async addEvent(event: LeadEvent): Promise<void> {
await this.db.query(
"INSERT INTO lead_events (lead_id, from_status, to_status, at) VALUES ($1, $2, $3, $4)",
[event.leadId, event.from, event.to, event.at],
);
}
async transaction<T>(work: (tx: LeadsRepository) => Promise<T>): Promise<T> {
if (!(this.db instanceof pg.Pool)) return work(this); // already inside a transaction
const client = await this.db.connect();
try {
await client.query("BEGIN");
const result = await work(new PgLeadsRepository(client));
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
}CREATE TABLE leads (
id uuid PRIMARY KEY,
name text NOT NULL,
phone text NOT NULL,
message text,
status text NOT NULL CHECK (status IN ('new', 'in_progress', 'done')),
created_at timestamptz NOT NULL
);
-- one open lead per phone, enforced by the database
CREATE UNIQUE INDEX leads_open_phone_uq ON leads (phone) WHERE status <> 'done';
CREATE TABLE lead_events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
lead_id uuid NOT NULL REFERENCES leads (id),
from_status text,
to_status text NOT NULL,
at timestamptz NOT NULL
);The partial unique index WHERE status <> 'done' is the guarantee. The second of two concurrent requests waits for the first to commit, gets a unique violation with code 23505, and the repository turns it into the same DuplicateLeadError. The client sees a 409 either way — whether the rule fired in the service or the database caught it.
it("lets only one of five concurrent requests create an open lead", async () => {
// open five connections up front so the requests really overlap
const clients = await Promise.all(Array.from({ length: 5 }, () => pool.connect()));
clients.forEach((client) => client.release());
const input = { name: "Aida", phone: "+77011234567" };
const results = await Promise.allSettled(
Array.from({ length: 5 }, () => service.create(input)),
);
const rejected = results.filter((r) => r.status === "rejected");
expect(results.length - rejected.length).toBe(1);
for (const r of rejected) expect(r.reason).toBeInstanceOf(DuplicateLeadError);
});$ npx vitest run test/pg.integration.test.ts # schema.sql without the leads_open_phone_uq index
❯ test/pg.integration.test.ts (3 tests | 1 failed) 103ms
× lets only one of five concurrent requests create an open lead 29ms
AssertionError: expected 5 to be 1 // Object.is equality
- Expected
+ Received
- 1
+ 5The last piece is the composition root: the only place that knows which repository is used. The rest of the code does not depend on that choice.
import pg from "pg";
import { createApp } from "./app.js";
import { InMemoryLeadsRepository } from "./leads/in-memory-leads.repository.js";
import type { LeadsRepository } from "./leads/leads.repository.js";
import { LeadsService } from "./leads/leads.service.js";
import { PgLeadsRepository } from "./leads/pg-leads.repository.js";
// Composition root: the only place that knows which storage is used
const repo: LeadsRepository = process.env.DATABASE_URL
? new PgLeadsRepository(new pg.Pool({ connectionString: process.env.DATABASE_URL }))
: new InMemoryLeadsRepository();
const app = createApp(new LeadsService(repo));
const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => console.log(`Listening on http://localhost:${port}`));$ curl -i -X POST localhost:3000/leads -H 'Content-Type: application/json' \
-d '{"name":"Aida","phone":"+77011234567"}'
HTTP/1.1 201 Created
{"id":"6a5c7758-43da-4539-bb6f-05b392aa36bf","name":"Aida","status":"new","createdAt":"2026-10-11T23:25:50.185Z"}
$ curl -i -X POST localhost:3000/leads -H 'Content-Type: application/json' \
-d '{"name":"Aida","phone":"+77011234567"}'
HTTP/1.1 409 Conflict
{"code":"LEAD_DUPLICATE","message":"An open lead with this phone already exists"}
$ curl -i -X PATCH localhost:3000/leads/6a5c7758-43da-4539-bb6f-05b392aa36bf/status \
-H 'Content-Type: application/json' -d '{"status":"done"}'
HTTP/1.1 200 OK
{"id":"6a5c7758-43da-4539-bb6f-05b392aa36bf","name":"Aida","status":"done","createdAt":"2026-10-11T23:25:50.185Z"}
$ curl -i -X PATCH localhost:3000/leads/6a5c7758-43da-4539-bb6f-05b392aa36bf/status \
-H 'Content-Type: application/json' -d '{"status":"new"}'
HTTP/1.1 409 Conflict
{"code":"LEAD_INVALID_TRANSITION","message":"Cannot change status from done to new"}
$ docker exec leads-pg psql -U postgres -c "SELECT from_status, to_status FROM lead_events ORDER BY id"
from_status | to_status
-------------+-----------
| new
new | done
(2 rows)Verification: a service test with a swapped repository
Rules are now tested without HTTP and without a database: the service gets an in-memory repository and a fixed clock. The rollback test swaps in a subclass whose event write always fails and checks that the lead was not saved either.
import { beforeEach, describe, expect, it } from "vitest";
import { InMemoryLeadsRepository } from "./in-memory-leads.repository.js";
import { DuplicateLeadError, InvalidTransitionError, type LeadEvent } from "./lead.js";
import { LeadsService } from "./leads.service.js";
const NOW = new Date("2026-10-11T09:00:00Z");
describe("LeadsService", () => {
let repo: InMemoryLeadsRepository;
let service: LeadsService;
beforeEach(() => {
repo = new InMemoryLeadsRepository();
service = new LeadsService(repo, () => NOW);
});
it("creates a lead and records a 'created' event", async () => {
const lead = await service.create({ name: "Aida", phone: "+77011234567" });
expect(lead).toMatchObject({ status: "new", message: null, createdAt: NOW });
expect(repo.events).toEqual([{ leadId: lead.id, from: null, to: "new", at: NOW }]);
});
it("rejects a second open lead with the same phone", async () => {
await service.create({ name: "Aida", phone: "+77011234567" });
await expect(service.create({ name: "Aida", phone: "+77011234567" })).rejects.toThrow(
DuplicateLeadError,
);
});
it("allows new → in_progress → done and forbids leaving done", async () => {
const lead = await service.create({ name: "Aida", phone: "+77011234567" });
await service.changeStatus(lead.id, "in_progress");
await service.changeStatus(lead.id, "done");
await expect(service.changeStatus(lead.id, "new")).rejects.toThrow(InvalidTransitionError);
expect(repo.events.map((e) => e.to)).toEqual(["new", "in_progress", "done"]);
});
it("rolls back the lead when writing the event fails", async () => {
class FailingEvents extends InMemoryLeadsRepository {
override async addEvent(_event: LeadEvent): Promise<void> {
throw new Error("event store is down");
}
}
const failing = new FailingEvents();
const svc = new LeadsService(failing, () => NOW);
await expect(svc.create({ name: "Aida", phone: "+77011234567" })).rejects.toThrow(
"event store is down",
);
expect(failing.leads.size).toBe(0);
});
});A refactoring must not change API behaviour. The contract test runs the same scenario against the old app and the new one: status codes must match. It is insurance during the move — once the old code is deleted, the test stays as a check of the HTTP layer.
import type { Express } from "express";
import request from "supertest";
import { describe, expect, it } from "vitest";
import { createApp } from "../src/app.js";
import { app as before } from "../src/before/app.js";
import { InMemoryLeadsRepository } from "../src/leads/in-memory-leads.repository.js";
import { LeadsService } from "../src/leads/leads.service.js";
const after = createApp(new LeadsService(new InMemoryLeadsRepository()));
// The same HTTP contract must hold before and after the refactoring
describe.each([
["before: fat handlers", before as Express],
["after: three layers", after],
])("%s", (_name, app) => {
it("keeps status codes for the main scenarios", async () => {
const phone = "+77019998877";
const created = await request(app).post("/leads").send({ name: "Aida", phone }).expect(201);
expect(created.body).toMatchObject({ name: "Aida", status: "new" });
await request(app).post("/leads").send({ name: "", phone }).expect(400);
await request(app).post("/leads").send({ name: "Aida", phone }).expect(409);
const id = created.body.id as string;
await request(app).patch(`/leads/${id}/status`).send({ status: "done" }).expect(200);
await request(app).patch(`/leads/${id}/status`).send({ status: "new" }).expect(409);
await request(app).patch("/leads/missing/status").send({ status: "done" }).expect(404);
});
});
describe("after: error bodies", () => {
it("returns field errors and domain error codes", async () => {
const bad = await request(after)
.post("/leads")
.send({ name: "", phone: "8701", role: "admin" })
.expect(400);
expect(bad.body).toEqual({
formErrors: ['Unrecognized key: "role"'],
fieldErrors: {
name: ["Too small: expected string to have >=1 characters"],
phone: ["phone must be +7XXXXXXXXXX"],
},
});
const missing = await request(after).get("/leads/missing").expect(404);
expect(missing.body).toEqual({ code: "LEAD_NOT_FOUND", message: "Lead missing not found" });
});
});$ DATABASE_URL=postgres://postgres:test@localhost:55432/postgres npx vitest run --reporter=verbose
RUN v5.0.3 …/leads-layers
✓ src/leads/leads.service.test.ts > LeadsService > creates a lead and records a 'created' event 2ms
✓ src/leads/leads.service.test.ts > LeadsService > rejects a second open lead with the same phone 1ms
✓ src/leads/leads.service.test.ts > LeadsService > allows new → in_progress → done and forbids leaving done 0ms
✓ src/leads/leads.service.test.ts > LeadsService > rolls back the lead when writing the event fails 1ms
✓ test/pg.integration.test.ts > PgLeadsRepository + LeadsService (PostgreSQL) > writes the lead and its event in one transaction 25ms
✓ test/pg.integration.test.ts > PgLeadsRepository + LeadsService (PostgreSQL) > rolls back every write when the work throws 9ms
✓ test/pg.integration.test.ts > PgLeadsRepository + LeadsService (PostgreSQL) > lets only one of five concurrent requests create an open lead 65ms
✓ test/contract.test.ts > before: fat handlers > keeps status codes for the main scenarios 42ms
✓ test/contract.test.ts > after: three layers > keeps status codes for the main scenarios 14ms
✓ test/contract.test.ts > after: error bodies > returns field errors and domain error codes 4ms
Test Files 3 passed (3)
Tests 10 passed (10)This test layout follows from the layers: many fast service tests with a fake, a few HTTP tests of the controller and a few repository integration tests against a real database. Test databases, fixtures and data cleanup are covered in detail in the backend testing lesson.
How this looks in NestJS and Spring, and when there are too many layers
The layers are the same in all popular backend frameworks; what differs is who wires them together.
Controller
- Express (this lesson)
Router+ Zod schema- NestJS
@Controller+ DTO andValidationPipe- Spring Boot
@RestController+ Bean Validation (@Valid)
Service
- Express (this lesson)
- A class, constructor dependencies wired by hand
- NestJS
- An
@Injectable()provider injected by the container - Spring Boot
@Service, injected by the Spring container
Transaction
- Express (this lesson)
- The repository
transactionmethod - NestJS
- Nothing built in — via an ORM or your own wrapper
- Spring Boot
@Transactionalon a service method
Repository
- Express (this lesson)
- Interface + classes for Map and PostgreSQL
- NestJS
- A provider by token:
@Inject(LEADS_REPOSITORY) - Spring Boot
- A
CrudRepository/JpaRepositoryinterface from Spring Data
Wiring
- Express (this lesson)
- The
server.tscomposition root - NestJS
- Module
providers - Spring Boot
- Auto-configuration and component scanning
In NestJS an interface cannot be an injection token — TypeScript interfaces are erased at compile time. So the repository is registered under a symbol and injected with @Inject. In a test it is replaced via overrideProvider(LEADS_REPOSITORY).
import { Inject, Injectable, Module } from '@nestjs/common';
export const LEADS_REPOSITORY = Symbol('LEADS_REPOSITORY');
@Injectable()
export class LeadsService {
constructor(
@Inject(LEADS_REPOSITORY) private readonly repo: LeadsRepository,
) {}
async isDuplicate(phone: string): Promise<boolean> {
return (await this.repo.findOpenByPhone(phone)) !== null;
}
}
@Module({
providers: [
LeadsService,
{ provide: LEADS_REPOSITORY, useClass: InMemoryLeadsRepository },
],
})
export class LeadsModule {}In Spring, the docs recommend putting @Transactional on methods of concrete classes; by default RuntimeException and Error trigger a rollback, checked exceptions do not. More in the Spring Boot REST API lesson.
Layers pay off
- There are rules worth testing: status transitions, limits, calculations
- The same use case is called from HTTP, a queue, a bot or cron
- An operation writes to several tables and needs a transaction
- Storage may change, or there are already two implementations
Too many layers
- The service just forwards to the repository without a single rule — fine for a CRUD lookup table; call the repository directly
- Every entity gets an interface, an implementation, a factory and a mapper although there is one implementation that will not change
- One new field means editing five files
- Layers for their own sake: «that's how it's done» instead of «here is what it gives us»
In our experience a good rule of thumb is to start with a controller and a service and introduce the repository as a separate interface when a second implementation or a database-free service test appears. The next step after layers is clean architecture, where the dependency rule is taken all the way and the core does not even know about the framework.
Exercises
Next: the NestJS lesson shows the same controller and service with framework-provided dependency injection, the backend testing lesson covers integration tests with a database in depth, and the clean architecture lesson shows how to separate the application core from the framework completely.
How this was verified6 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.