Backend-код раскладывают на три слоя: контроллер переводит HTTP-запрос в вызов и результат обратно в HTTP-ответ, сервис выполняет бизнес-правила и управляет транзакциями, репозиторий читает и пишет данные. Проверка формы входных данных живёт в контроллере, правила предметной области — в сервисе, SQL — только в репозитории. Такое деление позволяет тестировать правила без HTTP и базы, подставив репозиторий в памяти, и менять хранилище, не трогая логику.
После материала вы сможете
- разделить «толстый» обработчик Express на контроллер, сервис и репозиторий и сохранить HTTP-контракт
- объяснить, какая проверка относится к контроллеру, какая к сервису, а какая к ограничениям базы данных
- перенести бизнес-правило из контроллера в сервис и покрыть его тестом с подменой репозитория
- провести несколько записей одной транзакцией на уровне сервиса и объяснить, почему уникальность гарантирует база, а не проверка в коде
Что знать заранее
Проверено на
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 (фрагмент сравнения)
Сверено с документацией: 11 октября 2026 г.
Содержание
Симптом: «толстый» контроллер
Начнём с кода, который пишут почти все, — и который отлично работает, пока проект маленький. API заявок на Express: создать заявку и сменить её статус. Всё лежит прямо в обработчиках маршрутов.
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);
});В каждом обработчике смешаны четыре разные задачи: разбор HTTP (тело, параметры, коды ответа), проверка формы данных (имя не пустое, телефон в формате), правила предметной области (одна открытая заявка на телефон, из done назад нельзя) и хранение (Map). Пока обработчиков два, это незаметно. Проблемы начинаются с изменениями:
- Заявки начали приходить из Telegram-бота и импорта CSV. Правило «одна открытая заявка на телефон» нужно и там, а оно спрятано в HTTP-обработчике — его копируют, и копии расходятся.
- Чтобы проверить правило перехода статусов, приходится поднимать HTTP и слать запросы: отдельной функции, которую можно вызвать в тесте, нет.
- Переезд с
Mapна PostgreSQL затрагивает каждый обработчик: доступ к данным размазан по всему коду. - Смена статуса и запись в историю изменений должны происходить вместе. В обработчике негде объявить «эти две записи — одна операция».
Три слоя и кто за что отвечает
- 01КонтроллерHTTP: маршрут, разбор и проверка формы запроса, коды ответов, DTO ответа
- 02СервисСценарии и бизнес-правила, границы транзакций, доменные ошибки
- 03РепозиторийЧтение и запись данных, SQL, маппинг строк таблицы в объекты
- 04База данныхХранение, ограничения целостности: NOT NULL, CHECK, уникальные индексы
Мартин Фаулер в каталоге паттернов корпоративных приложений описывает сервисный слой (Service Layer) как границу приложения: набор доступных операций, каждая из которых координирует ответ приложения. Репозиторий (Repository) у него — посредник между предметной областью и слоем отображения данных, работающий как коллекция объектов в памяти. Эти два паттерна и дают нам сервис и репозиторий; контроллер — это адаптер между HTTP и сервисом.
Поле phone есть и похоже на телефон?
- Контроллер
- Да — схема Zod
- Сервис
- —
- Репозиторий / БД
- —
Лишнее поле role в теле?
- Контроллер
- Да — отказ 400
- Сервис
- —
- Репозиторий / БД
- —
У телефона уже есть открытая заявка?
- Контроллер
- —
- Сервис
- Да — правило, понятная ошибка
- Репозиторий / БД
- Уникальный индекс — гарантия при гонке
Можно ли перейти из done в new?
- Контроллер
- —
- Сервис
- Да — таблица переходов
- Репозиторий / БД
- —
Статус и история должны записаться вместе
- Контроллер
- —
- Сервис
- Да — граница транзакции
- Репозиторий / БД
BEGIN/COMMIT/ROLLBACK
Как называется колонка в таблице?
- Контроллер
- —
- Сервис
- —
- Репозиторий / БД
- Да —
created_at→createdAt
Какой HTTP-код у «не найдено»?
- Контроллер
- Да — обработчик ошибок
- Сервис
- Бросает
LeadNotFoundError - Репозиторий / БД
- Возвращает
null
Главное различие — между проверкой формы и бизнес-правилом. Форма отвечает на вопрос «можно ли вообще разобрать этот запрос»: типы, обязательные поля, длина, формат. Ответ не зависит от состояния системы, поэтому проверка живёт на входе, в контроллере. Бизнес-правило отвечает на вопрос «допустима ли эта операция сейчас»: для ответа нужно прочитать данные, а значит, оно принадлежит сервису. Ограничения в базе — последняя линия: они срабатывают, даже если два запроса проверили правило одновременно.
Данные между слоями тоже имеют разную форму. Входной DTO — то, что прислал клиент после проверки схемой. Доменный объект Lead — то, с чем работают правила (дата — это Date). Строка таблицы — то, что вернул SQL (created_at). DTO ответа — то, что API обещает клиентам (дата — строка ISO, телефон не возвращается). Маппинг на границах позволяет переименовать колонку или скрыть поле, не ломая остальные слои.
Контроллер: HTTP и только HTTP
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"]
}Сначала модель и доменные ошибки. Они не знают ни об HTTP, ни о базе, поэтому их импортируют все слои.
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}`);
}
}Контроллер делает три вещи: проверяет форму запроса схемой Zod, вызывает сервис и превращает результат в DTO ответа. z.strictObject отклоняет лишние поля, z.flattenError превращает ошибки в объект «поле → сообщения». Обработчики асинхронные: в Express 5 отклонённый промис из обработчика автоматически передаётся в обработчик ошибок, поэтому try/catch в каждом маршруте не нужен.
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;
}Перевод доменных ошибок в HTTP-коды собран в одном месте — middleware обработки ошибок с четырьмя аргументами. Сервис не знает, что «не найдено» — это 404: та же ошибка из Telegram-бота станет текстом сообщения. Неизвестные ошибки логируются, а клиент получает 500 без подробностей. Формат тела ошибки здесь упрощён; единый формат ответов разобран в уроке про REST API на Express.
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;
}Сервис: бизнес-правила и транзакции
Сервис получает зависимости через конструктор: репозиторий и функцию текущего времени. Это то же внедрение зависимостей, что делает контейнер NestJS, только вручную. Подмена времени делает тесты детерминированными: дата в событии всегда одна и та же.
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 };
});
}
}Почему транзакция начинается в сервисе, а не в контроллере или репозитории. Транзакция — это граница бизнес-операции: «создать заявку и записать событие» — одна операция, «сменить статус и записать событие» — другая. Знает об этом только сервис. Контроллер не должен знать, сколько записей делает операция, а отдельный метод репозитория видит лишь свою одну запись. Фаулер прямо относит управление транзакциями к задачам сервисного слоя; так же устроен Spring, где @Transactional ставят на методы сервиса.
Внутри transaction сервис работает с переданным tx, а не с this.repo: так все чтения и записи идут через одно соединение с базой. Если любой шаг бросит ошибку — нарушено правило, упала запись события, — откатится всё, и в базе не останется заявки без истории.
Репозиторий: доступ к данным
Сервис зависит не от конкретного класса, а от интерфейса. В нём только то, что нужно сценариям, в терминах предметной области: «найти открытую заявку по телефону», а не «выполнить 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>;
}Первая реализация — в памяти. Она нужна для тестов и для запуска без базы. Её «транзакция» — снимок данных с откатом при ошибке; это учебная замена, которая не защищает от параллельных запросов.
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;
}
}
}Вторая — PostgreSQL через node-postgres. Документация библиотеки требует проводить транзакцию через один клиент из пула (pool.connect()), а не через pool.query, и возвращать клиент в пул в finally. Метод transaction создаёт репозиторий поверх этого клиента и передаёт его в работу сервиса. Маппинг toLead — единственное место, где известны имена колонок.
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
);Частичный уникальный индекс WHERE status <> 'done' — та самая гарантия. Второй из конкурентных запросов ждёт, пока первый зафиксирует транзакцию, получает ошибку уникальности с кодом 23505, а репозиторий переводит её в ту же DuplicateLeadError. Клиент видит 409 в обоих случаях — и когда правило сработало в сервисе, и когда его поймала база.
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 без индекса leads_open_phone_uq
❯ 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
+ 5Последний кусок — точка сборки (composition root): единственное место, которое знает, какой репозиторий используется. Остальной код от этого выбора не зависит.
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)Проверка: тест сервиса с подменой репозитория
Теперь правила проверяются без HTTP и без базы: в сервис передаётся репозиторий в памяти и фиксированное время. Тест отката подменяет репозиторий наследником, у которого запись события всегда падает, и проверяет, что заявка тоже не сохранилась.
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);
});
});Рефакторинг не должен менять поведение API. Контрактный тест прогоняет один и тот же сценарий против старого приложения и нового: коды ответов должны совпасть. Это страховка на время переезда — когда старый код удалён, тест остаётся проверкой HTTP-слоя.
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)Такая раскладка тестов — следствие слоёв: много быстрых тестов сервиса с подделкой, несколько HTTP-тестов контроллера и несколько интеграционных тестов репозитория с настоящей базой. Подробно о тестовых базах, фикстурах и очистке данных — в уроке о тестировании backend.
Как это выглядит в NestJS и Spring и когда слоёв слишком много
Слои одинаковые во всех популярных backend-фреймворках, отличается только то, кто связывает их между собой.
Контроллер
- Express (этот урок)
Router+ схема Zod- NestJS
@Controller+ DTO иValidationPipe- Spring Boot
@RestController+ Bean Validation (@Valid)
Сервис
- Express (этот урок)
- Класс, зависимости через конструктор вручную
- NestJS
@Injectable()-провайдер, внедряет контейнер- Spring Boot
@Service, внедряет контейнер Spring
Транзакция
- Express (этот урок)
- Метод
transactionрепозитория - NestJS
- Нет встроенной — через ORM или свою обёртку
- Spring Boot
@Transactionalна методе сервиса
Репозиторий
- Express (этот урок)
- Интерфейс + классы для Map и PostgreSQL
- NestJS
- Провайдер по токену:
@Inject(LEADS_REPOSITORY) - Spring Boot
- Интерфейс
CrudRepository/JpaRepositoryиз Spring Data
Сборка
- Express (этот урок)
- Точка сборки
server.ts - NestJS
- Модуль:
providers - Spring Boot
- Автоконфигурация и сканирование компонентов
В NestJS интерфейс не может быть токеном внедрения — интерфейсы TypeScript стираются при компиляции. Поэтому репозиторий регистрируют под символом и внедряют через @Inject. В тесте его подменяют через 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 {}В Spring документация рекомендует ставить @Transactional на методы конкретных классов, а по умолчанию откат вызывают RuntimeException и Error, но не проверяемые исключения. Подробно — в уроке о REST API на Spring Boot.
Слои окупаются
- Есть правила, которые нужно проверять тестами: переходы статусов, лимиты, расчёты
- Тот же сценарий вызывается из HTTP, очереди, бота или cron
- Операция пишет в несколько таблиц и требует транзакции
- Хранилище может смениться или уже есть две реализации
Слоёв слишком много
- Сервис только пересылает вызов в репозиторий без единого правила — у CRUD-справочника это нормально, можно звать репозиторий напрямую
- На каждую сущность — интерфейс, реализация, фабрика и маппер, хотя реализация одна и меняться не будет
- Для одного поля нужно поправить пять файлов
- Слой ради слоя: «так положено», а не «вот что это нам даёт»
По нашему опыту, хороший ориентир — начинать с контроллера и сервиса, а репозиторий как отдельный интерфейс вводить, когда появляется вторая реализация или тест сервиса без базы. Следующий шаг после слоёв — чистая архитектура: там правило зависимостей доводится до конца, и ядро не знает даже о фреймворке.
Упражнения
Дальше: урок о NestJS показывает те же контроллер и сервис с внедрением зависимостей от фреймворка, урок о тестировании backend — интеграционные тесты с базой подробно, а урок о чистой архитектуре — как отделить ядро приложения от фреймворка полностью.
Как проверено6 · пройденосверено 11 октября 2026 г.
- запущено, работает
Проект leads-layers: src/before/app.ts, lead.ts, leads.repository.ts, leads.service.ts, leads.controller.ts, http/error-handler.ts, app.ts, in-memory и pg репозитории, server.ts, schema.sql
tsc (strict, nodenext, noEmit) без ошибок на TypeScript 7.0.2, 6.0.3 и 5.9.3; Express 5.3.0, Zod 4.6.6, pg 8.23.1, Node.js 24.18.0
- запущено, работает
Тесты: leads.service.test.ts (4, подмена репозитория), test/contract.test.ts (3, «до» и «после» через supertest), test/pg.integration.test.ts (3)
vitest 5.0.3 — 10/10 с DATABASE_URL на postgres:17-alpine (PostgreSQL 17.11) в Docker (--rm, контейнер kb-pg-sloi-backend, остановлен); без DATABASE_URL — 7 passed, 3 skipped
- запущено, работает
Гонка без уникального индекса
индекс leads_open_phone_uq убран из schema.sql: тест пяти конкурентных запросов падает (expected 5 to be 1) 3 прогона из 3; с индексом — проходит 3 из 3
- запущено, работает
Ответы curl и история lead_events
npx tsx src/server.ts с DATABASE_URL, реальные ответы 201/409/200/409 и SELECT из lead_events скопированы; в статье порт 3000, 55432 и имя контейнера leads-pg вместо тестовых 3931/56743/kb-pg-sloi-backend
- запущено, работает
Фрагмент NestJS (LEADS_REPOSITORY, @Inject, useClass)
в копии проекта NestJS 12.1.2 из урока nestjs-vvedenie: tsc --noEmit + 2 теста Vitest (внедрение по токену и overrideProvider)
- запущено, работает
Решение упражнения modify (new → done запрещён)
копия проекта: новый тест сервиса проходит; контрактный и интеграционный тест падают до добавления шага in_progress, после — 11/11 с PostgreSQL 17
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.