Как делать11 октября 2026 г.8 мин чтения
Spring BootШаг 4 из 7

Слои backend-приложения: контроллер, сервис, репозиторий

Коротко

Backend-код раскладывают на три слоя: контроллер переводит HTTP-запрос в вызов и результат обратно в HTTP-ответ, сервис выполняет бизнес-правила и управляет транзакциями, репозиторий читает и пишет данные. Проверка формы входных данных живёт в контроллере, правила предметной области — в сервисе, SQL — только в репозитории. Такое деление позволяет тестировать правила без HTTP и базы, подставив репозиторий в памяти, и менять хранилище, не трогая логику.

ОсновыУровень: СреднийАрхитектура backend

После материала вы сможете

  • разделить «толстый» обработчик 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 г.

Содержание
  1. 01Симптом: «толстый» контроллер
  2. 02Три слоя и кто за что отвечает
  3. 03Контроллер: HTTP и только HTTP
  4. 04Сервис: бизнес-правила и транзакции
  5. 05Репозиторий: доступ к данным
  6. 06Проверка: тест сервиса с подменой репозитория
  7. 07Как это выглядит в NestJS и Spring и когда слоёв слишком много
  8. 08Упражнения

Симптом: «толстый» контроллер

Начнём с кода, который пишут почти все, — и который отлично работает, пока проект маленький. API заявок на Express: создать заявку и сменить её статус. Всё лежит прямо в обработчиках маршрутов.

src/before/app.ts
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 в одном месте. Код рабочий — его проверяет тот же контрактный тест, что и версию «после»

В каждом обработчике смешаны четыре разные задачи: разбор HTTP (тело, параметры, коды ответа), проверка формы данных (имя не пустое, телефон в формате), правила предметной области (одна открытая заявка на телефон, из done назад нельзя) и хранение (Map). Пока обработчиков два, это незаметно. Проблемы начинаются с изменениями:

  • Заявки начали приходить из Telegram-бота и импорта CSV. Правило «одна открытая заявка на телефон» нужно и там, а оно спрятано в HTTP-обработчике — его копируют, и копии расходятся.
  • Чтобы проверить правило перехода статусов, приходится поднимать HTTP и слать запросы: отдельной функции, которую можно вызвать в тесте, нет.
  • Переезд с Map на PostgreSQL затрагивает каждый обработчик: доступ к данным размазан по всему коду.
  • Смена статуса и запись в историю изменений должны происходить вместе. В обработчике негде объявить «эти две записи — одна операция».

Три слоя и кто за что отвечает

  1. 01КонтроллерHTTP: маршрут, разбор и проверка формы запроса, коды ответов, DTO ответа
  2. 02СервисСценарии и бизнес-правила, границы транзакций, доменные ошибки
  3. 03РепозиторийЧтение и запись данных, SQL, маппинг строк таблицы в объекты
  4. 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

bash
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
Проект урока: зависимости и PostgreSQL 17 в Docker
tsconfig.json
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src", "test"]
}
Строгий режим и ESM-разрешение модулей: относительные импорты пишутся с .js

Сначала модель и доменные ошибки. Они не знают ни об HTTP, ни о базе, поэтому их импортируют все слои.

src/leads/lead.ts
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 в каждом маршруте не нужен.

src/leads/leads.controller.ts
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;
}
Контроллер: схема на входе, вызов сервиса, DTO на выходе. Ни одного правила и ни одного SQL-запроса

Перевод доменных ошибок в HTTP-коды собран в одном месте — middleware обработки ошибок с четырьмя аргументами. Сервис не знает, что «не найдено» — это 404: та же ошибка из Telegram-бота станет текстом сообщения. Неизвестные ошибки логируются, а клиент получает 500 без подробностей. Формат тела ошибки здесь упрощён; единый формат ответов разобран в уроке про REST API на Express.

src/http/error-handler.ts
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" });
};
Единственное место, где доменная ошибка становится HTTP-статусом
src/app.ts
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, только вручную. Подмена времени делает тесты детерминированными: дата в событии всегда одна и та же.

src/leads/leads.service.ts
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 };
    });
  }
}
Сервис: правила и границы транзакций. Он работает с интерфейсом репозитория и не знает, Map это или PostgreSQL

Почему транзакция начинается в сервисе, а не в контроллере или репозитории. Транзакция — это граница бизнес-операции: «создать заявку и записать событие» — одна операция, «сменить статус и записать событие» — другая. Знает об этом только сервис. Контроллер не должен знать, сколько записей делает операция, а отдельный метод репозитория видит лишь свою одну запись. Фаулер прямо относит управление транзакциями к задачам сервисного слоя; так же устроен Spring, где @Transactional ставят на методы сервиса.

Внутри transaction сервис работает с переданным tx, а не с this.repo: так все чтения и записи идут через одно соединение с базой. Если любой шаг бросит ошибку — нарушено правило, упала запись события, — откатится всё, и в базе не останется заявки без истории.

Репозиторий: доступ к данным

Сервис зависит не от конкретного класса, а от интерфейса. В нём только то, что нужно сценариям, в терминах предметной области: «найти открытую заявку по телефону», а не «выполнить SELECT».

src/leads/leads.repository.ts
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>;
}
Контракт хранилища — его реализуют и Map, и PostgreSQL

Первая реализация — в памяти. Она нужна для тестов и для запуска без базы. Её «транзакция» — снимок данных с откатом при ошибке; это учебная замена, которая не защищает от параллельных запросов.

src/leads/in-memory-leads.repository.ts
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 — единственное место, где известны имена колонок.

src/leads/pg-leads.repository.ts
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();
    }
  }
}
Репозиторий PostgreSQL: SQL, маппинг строк, транзакция на одном клиенте, ошибка 23505 → доменная ошибка
schema.sql
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 в обоих случаях — и когда правило сработало в сервисе, и когда его поймала база.

TypeScript
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);
});
Фрагмент test/pg.integration.test.ts: пять одновременных запросов на один телефон
terminal
$ 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): единственное место, которое знает, какой репозиторий используется. Остальной код от этого выбора не зависит.

src/server.ts
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}`));
Запуск: npx tsx src/server.ts — с DATABASE_URL работает PostgreSQL, без неё хранилище в памяти
terminal
$ 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)
Реальные ответы приложения на PostgreSQL и история, записанная в тех же транзакциях

Проверка: тест сервиса с подменой репозитория

Теперь правила проверяются без HTTP и без базы: в сервис передаётся репозиторий в памяти и фиксированное время. Тест отката подменяет репозиторий наследником, у которого запись события всегда падает, и проверяет, что заявка тоже не сохранилась.

src/leads/leads.service.test.ts
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);
  });
});
Unit-тесты сервиса: правила, переходы и откат транзакции

Рефакторинг не должен менять поведение API. Контрактный тест прогоняет один и тот же сценарий против старого приложения и нового: коды ответов должны совпасть. Это страховка на время переезда — когда старый код удалён, тест остаётся проверкой HTTP-слоя.

test/contract.test.ts
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" });
  });
});
Контрактный тест: «до» и «после» отвечают одинаковыми кодами
terminal
$ 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)
Реальный вывод: 10 тестов, включая 3 интеграционных с PostgreSQL 17. Без DATABASE_URL интеграционные тесты пропускаются

Такая раскладка тестов — следствие слоёв: много быстрых тестов сервиса с подделкой, несколько 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).

TypeScript
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 {}
Фрагмент на NestJS 12 (токен, сервис и модуль; не самостоятельное приложение): проверен tsc и двумя тестами, включая подмену репозитория

В 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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.