Чистая архитектура — способ организовать код так, чтобы бизнес-правила не зависели от фреймворка, базы данных и внешних сервисов. Её главное требование — правило зависимостей: импорты в исходном коде направлены только внутрь, от адаптеров к сценариям (use case) и сущностям, а ядро общается с внешним миром через интерфейсы-порты, которые само и объявляет. Компилятор TypeScript нарушение этого правила не замечает, поэтому его проверяют инструментом в CI — например, dependency-cruiser или ESLint.
После материала вы сможете
- описать четыре круга чистой архитектуры и сформулировать правило зависимостей: импорты указывают только внутрь, ядро владеет портами
- написать use case на TypeScript с портами репозитория и уведомлений и подключить к нему адаптеры in-memory и PostgreSQL в точке сборки
- проверить оба адаптера одним контрактным тестом и настроить
dependency-cruiserилиimport-x/no-restricted-paths, чтобы нарушение границы роняло CI - объяснить, в каких модулях такая строгость окупается, а где это оверинжиниринг
Что знать заранее
Проверено на
Node.js 24 LTS (24.18)TypeScript 5.9.3 (tsc --strict), tsc 7.0.2Vitest 5.0.3, tsx 4.23pg (node-postgres) 8.23, PostgreSQL 17.11 (Docker, postgres:17-alpine)dependency-cruiser 18.6.0ESLint 10.12, typescript-eslint 8.71, eslint-plugin-import-x 4.17.1, eslint-import-resolver-typescript 4.4.5
Сверено с документацией: 11 октября 2026 г.
Содержание
- Чем это отличается от слоёв «контроллер — сервис — репозиторий»
- Правило зависимостей и четыре круга
- Ядро: сущность, порты и use case
- Порты и адаптеры: in-memory, PostgreSQL, webhook и HTTP
- Тесты: ядро без базы и HTTP, адаптеры — по одному контракту
- Правило зависимостей как проверка в CI
- Цена: когда это оверинжиниринг
Чем это отличается от слоёв «контроллер — сервис — репозиторий»
В уроке о слоях backend-приложения толстый обработчик разделён на контроллер, сервис и репозиторий, а сервис тестируется с репозиторием в памяти. Это уже половина чистой архитектуры. Вторая половина — одно правило и ответ на вопрос, кто владеет интерфейсами. Здесь мы не повторяем разбор слоёв, а показываем, что меняется, когда это правило доводят до конца.
Классическая слоистая схема, как её описывает Мартин Фаулер, направляет зависимости сверху вниз: представление зависит от предметной области, предметная область — от источника данных. Фаулер там же упоминает распространённый вариант, в котором домен не зависит от источника данных благодаря промежуточному слою, — его часто называют гексагональной архитектурой. Роберт Мартин в статье «The Clean Architecture» (2012) свёл гексагональную, луковую архитектуру и ещё несколько похожих подходов к одному требованию: зависимости в исходном коде указывают только внутрь, и ничто во внутреннем круге не знает имён из внешнего — ни функций, ни классов, ни переменных.
Куда направлены зависимости
- Слои (урок о слоях)
- Сверху вниз, к хранилищу
- Чистая архитектура
- Внутрь, к бизнес-правилам
Кто объявляет интерфейс хранилища
- Слои (урок о слоях)
- Обычно слой данных, рядом с реализацией
- Чистая архитектура
- Слой сценариев — как порт, который ему нужен
Может ли сервис знать тип ошибки драйвера БД, Request фреймворка, строку таблицы
- Слои (урок о слоях)
- На практике часто знает
- Чистая архитектура
- Нет: через границу идут простые структуры данных
Где выбираются реализации
- Слои (урок о слоях)
- Где удобно
- Чистая архитектура
- В одной точке сборки (composition root)
Чем защищена граница
- Слои (урок о слоях)
- Код-ревью
- Чистая архитектура
- Тесты ядра и линтер зависимостей в CI
Правило зависимостей и четыре круга
Мартин рисует систему концентрическими кругами. Чем ближе к центру, тем выше уровень абстракции: внешние круги — механизмы, внутренние — политики, то есть бизнес-правила.
- 01Сущности (entities)Правила предметной области, верные для любого приложения компании. Меняются реже всего
- 02Сценарии (use cases)Правила конкретного приложения: оркестрируют сущности и объявляют порты
- 03Адаптеры интерфейсовКонтроллеры, презентеры, репозитории с SQL, клиенты внешних API — переводят данные между форматами
- 04Фреймворки и драйверыВеб-сервер, драйвер БД, конфигурация — «детали», здесь в основном связующий код
Сущности
- Папка в примере
src/domain- Что там лежит
Lead,createLead,normalizePhone,DomainError- Что можно импортировать
- Только
domain
Сценарии
- Папка в примере
src/application- Что там лежит
- Use case
makeCreateLead, портыLeadRepository,LeadNotifier,Clock - Что можно импортировать
applicationиdomain
Адаптеры
- Папка в примере
src/adapters- Что там лежит
- Репозитории in-memory и PostgreSQL, webhook-уведомления, HTTP-контроллер
- Что можно импортировать
- Внутренние круги и npm-пакеты
Фреймворки и драйверы
- Папка в примере
src/main- Что там лежит
- Сервер
node:http, пулpg, выбор реализаций - Что можно импортировать
- Всё
Как сценарий сохраняет заявку в PostgreSQL, если ему нельзя знать о PostgreSQL? Поток управления действительно идёт наружу: сценарий → репозиторий → база. Зависимость в исходном коде разворачивают принципом инверсии зависимостей (буква D в SOLID): сценарий объявляет интерфейс, который ему нужен, а внешний круг его реализует. Мартин описывает это так: полиморфизм создаёт зависимости в коде, противоположные потоку управления, ровно в тех местах, где поток пересекает границу.
Через границу передают простые структуры данных: аргументы функций, DTO, обычные объекты. Не строки таблицы из драйвера и не объект запроса фреймворка — иначе внутренний круг начнёт зависеть от их формата. Поэтому PostgreSQL-адаптер ниже превращает колонку created_at в поле createdAt до того, как данные попадут в сценарий.
Ядро: сущность, порты и use case
Пример — заявка с формы сайта, но сценарий другой, чем в уроке о слоях. Правила: имя от 1 до 100 символов; телефон приводится к виду +7XXXXXXXXXX; повторная отправка формы с того же номера в течение 10 минут не создаёт вторую заявку; после сохранения команда получает уведомление, а сбой уведомления не должен терять заявку.
npm init -y && npm pkg set type=module
npm install pg@8.23.1
npm install -D typescript@5.9.3 @types/node@24 @types/pg@8 vitest@5.0.3 tsx@4
npm install -D dependency-cruiser@18.6.0 eslint@10.12.0 typescript-eslint@8.71.1 \
eslint-plugin-import-x@4.17.1 eslint-import-resolver-typescript@4.4.5
docker run -d --rm --name kb-pg-chistaya-arhitektura -e POSTGRES_PASSWORD=test -p 127.0.0.1:55471:5432 postgres:17-alpinemodule: nodenext, как в уроке о слояхСущность хранит правила, верные для любого приложения: формат телефона не зависит от того, пришла заявка с сайта, из бота или из импорта таблицы. В файле нет ни одного импорта.
export class DomainError extends Error {
constructor(
readonly code: string,
message: string,
) {
super(message);
}
}
export type Lead = {
readonly id: string;
readonly name: string;
readonly phone: string;
readonly createdAt: Date;
};
// Kazakhstan mobile numbers: "8 701 123 45 67", "+7 (701) 123-45-67" → "+77011234567"
export function normalizePhone(raw: string): string {
const digits = raw.replace(/\D/g, "");
const national = digits.length === 11 && /^[78]/.test(digits) ? digits.slice(1) : digits;
if (!/^7\d{9}$/.test(national)) {
throw new DomainError("LEAD_INVALID_PHONE", "Phone must be a +7 mobile number");
}
return `+7${national}`;
}
export function createLead(props: { id: string; name: string; phone: string; now: Date }): Lead {
const name = props.name.trim();
if (name.length === 0 || name.length > 100) {
throw new DomainError("LEAD_INVALID_NAME", "Name must be 1–100 characters");
}
return { id: props.id, name, phone: normalizePhone(props.phone), createdAt: props.now };
}Порты — интерфейсы, которые объявляет слой сценариев. В терминах Алистера Кокберна это вторичные (driven) порты: разговор ведёт само приложение — запрашивает данные или отправляет уведомление. Время и генератор идентификаторов — тоже порты: randomUUID из node:crypto привязал бы ядро к Node.js, а new Date() внутри сценария сделал бы тесты зависимыми от часов.
import type { Lead } from "../domain/lead.js";
// Driven (secondary) ports: the use case owns these interfaces,
// the outer layer implements them.
export interface LeadRepository {
save(lead: Lead): Promise<void>;
findLatestByPhone(phone: string): Promise<Lead | null>;
}
export type NewLeadNotice = { leadId: string; name: string; phone: string };
export interface LeadNotifier {
newLead(notice: NewLeadNotice): Promise<void>;
}
export type Clock = () => Date;
export type IdGenerator = () => string;Use case — единица поведения: один сценарий, одна функция с явным входом и выходом. Здесь он сделан фабрикой, которая принимает зависимости и возвращает функцию; класс с конструктором работал бы так же.
import { createLead, normalizePhone } from "../domain/lead.js";
import type { Clock, IdGenerator, LeadNotifier, LeadRepository } from "./ports.js";
export type CreateLeadInput = { name: string; phone: string };
export type CreateLeadResult = { leadId: string; duplicate: boolean; notified: boolean };
const REPEAT_WINDOW_MS = 10 * 60 * 1000;
export type CreateLeadDeps = {
leads: LeadRepository;
notifier: LeadNotifier;
clock: Clock;
newId: IdGenerator;
onNotifyError?: (error: unknown) => void;
};
// Use case: one application-specific scenario. It orchestrates the entity
// and the ports and knows nothing about HTTP, SQL or messengers.
export function makeCreateLead(deps: CreateLeadDeps) {
return async function createLeadUseCase(input: CreateLeadInput): Promise<CreateLeadResult> {
const now = deps.clock();
// A double-submitted form must not create a second lead
const latest = await deps.leads.findLatestByPhone(normalizePhone(input.phone));
if (latest && now.getTime() - latest.createdAt.getTime() < REPEAT_WINDOW_MS) {
return { leadId: latest.id, duplicate: true, notified: false };
}
const lead = createLead({ id: deps.newId(), name: input.name, phone: input.phone, now });
await deps.leads.save(lead);
// The lead is already saved: a failed notification must not lose it
try {
await deps.notifier.newLead({ leadId: lead.id, name: lead.name, phone: lead.phone });
return { leadId: lead.id, duplicate: false, notified: true };
} catch (error) {
deps.onNotifyError?.(error);
return { leadId: lead.id, duplicate: false, notified: false };
}
};
}
export type CreateLead = ReturnType<typeof makeCreateLead>;Порты и адаптеры: in-memory, PostgreSQL, webhook и HTTP
Кокберн описал гексагональную архитектуру (другое название — «порты и адаптеры») в 2005 году. Её цель: приложение одинаково управляется людьми, программами, автотестами и пакетными скриптами и разрабатывается и тестируется в изоляции от устройств и баз данных. Порт — это «осмысленный разговор» с внешним миром, и у одного порта обычно несколько адаптеров: SQL-база, файл, база в памяти для тестов. Кокберн делит их на первичные (driving) — те, кто запускает приложение, — и вторичные (driven) — те, кого приложение вызывает само.
Первичная: кто запускает сценарий
- Порт
- Функция
CreateLead - Адаптеры в примере
- HTTP-контроллер, тест Vitest; позже — бот или импорт CSV
Вторичная: кого вызывает сценарий
- Порт
LeadRepository- Адаптеры в примере
InMemoryLeadRepository,PgLeadRepository
Вторичная
- Порт
LeadNotifier- Адаптеры в примере
InMemoryNotifier,WebhookNotifier
Вторичная
- Порт
Clock,IdGenerator- Адаптеры в примере
() => new Date()иrandomUUID; в тестах — фиксированные значения
Адаптер в памяти — не «заглушка на время», а полноценная реализация порта: Кокберн прямо называет базу в памяти одним из главных адаптеров стороны данных. Она позволяет запускать приложение и тесты без PostgreSQL.
import type { LeadRepository } from "../../application/ports.js";
import type { Lead } from "../../domain/lead.js";
export class InMemoryLeadRepository implements LeadRepository {
readonly rows: Lead[] = [];
async save(lead: Lead): Promise<void> {
this.rows.push({ ...lead });
}
async findLatestByPhone(phone: string): Promise<Lead | null> {
const found = this.rows
.filter((lead) => lead.phone === phone)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0];
return found ? { ...found } : null;
}
}import type pg from "pg";
import type { LeadRepository } from "../../application/ports.js";
import type { Lead } from "../../domain/lead.js";
type LeadRow = { id: string; name: string; phone: string; created_at: Date };
// The row format stays here: the use case only ever sees a Lead
const toLead = (row: LeadRow): Lead => ({
id: row.id,
name: row.name,
phone: row.phone,
createdAt: row.created_at,
});
export class PgLeadRepository implements LeadRepository {
constructor(private readonly db: pg.Pool) {}
async save(lead: Lead): Promise<void> {
await this.db.query(
"INSERT INTO leads (id, name, phone, created_at) VALUES ($1, $2, $3, $4)",
[lead.id, lead.name, lead.phone, lead.createdAt],
);
}
async findLatestByPhone(phone: string): Promise<Lead | null> {
const { rows } = await this.db.query<LeadRow>(
`SELECT id, name, phone, created_at FROM leads
WHERE phone = $1 ORDER BY created_at DESC LIMIT 1`,
[phone],
);
return rows[0] ? toLead(rows[0]) : null;
}
}CREATE TABLE leads (
id uuid PRIMARY KEY,
name text NOT NULL,
phone text NOT NULL,
created_at timestamptz NOT NULL
);
CREATE INDEX leads_phone_created_idx ON leads (phone, created_at DESC);Порт уведомлений тоже получает два адаптера. Подделка для тестов записывает отправленное и умеет имитировать сбой. Боевой адаптер отправляет JSON на webhook — это может быть CRM, бот или сервис автоматизации; адрес приходит из переменной окружения, тайм-аут ограничивает ожидание. Повторы и очередь при сбое — следующий уровень, он разобран в уроке о событиях и очередях; здесь сценарий просто возвращает notified: false.
import type { LeadNotifier, NewLeadNotice } from "../../application/ports.js";
export class InMemoryNotifier implements LeadNotifier {
readonly sent: NewLeadNotice[] = [];
failNext = false;
async newLead(notice: NewLeadNotice): Promise<void> {
if (this.failNext) {
this.failNext = false;
throw new Error("notifier is down");
}
this.sent.push(notice);
}
}failNext имитирует сбойimport type { LeadNotifier, NewLeadNotice } from "../../application/ports.js";
// Sends the notice as JSON to a webhook: a CRM, a chat bot, an automation tool
export class WebhookNotifier implements LeadNotifier {
constructor(
private readonly url: string,
private readonly timeoutMs = 3000,
) {}
async newLead(notice: NewLeadNotice): Promise<void> {
const response = await fetch(this.url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event: "lead.created", ...notice }),
signal: AbortSignal.timeout(this.timeoutMs),
});
if (!response.ok) throw new Error(`Webhook answered ${response.status}`);
}
}fetch и AbortSignal.timeout из Node.jsHTTP-контроллер — первичный адаптер: он переводит тело запроса во вход сценария, а результат и доменные ошибки — в коды ответа. Он не зависит от веб-фреймворка, поэтому его легко переставить с node:http на Express или Fastify. Проверка формы здесь минимальная; схемы Zod и единый формат ошибок — в уроке о REST API на Express.
import type { CreateLead } from "../../application/create-lead.js";
import { DomainError } from "../../domain/lead.js";
export type HttpResponse = { status: number; body: unknown };
// Driving (primary) adapter: turns an HTTP body into a use case call
// and the result into a status code. It does not depend on a web framework.
export function makeCreateLeadController(createLead: CreateLead) {
return async function handle(body: unknown): Promise<HttpResponse> {
const { name, phone } = (body ?? {}) as Record<string, unknown>;
if (typeof name !== "string" || typeof phone !== "string") {
return { status: 400, body: { code: "BAD_REQUEST", message: "name and phone are required" } };
}
try {
const result = await createLead({ name, phone });
return { status: result.duplicate ? 200 : 201, body: result };
} catch (error) {
if (error instanceof DomainError) {
return { status: 422, body: { code: error.code, message: error.message } };
}
throw error;
}
};
}Последний файл — точка сборки (composition root). Это самый внешний круг: только он знает все конкретные адаптеры и выбирает их по переменным окружения. Остальной код от этого выбора не зависит.
import { randomUUID } from "node:crypto";
import { createServer } from "node:http";
import pg from "pg";
import { makeCreateLeadController } from "../adapters/http/create-lead-controller.js";
import { InMemoryNotifier } from "../adapters/notifications/in-memory-notifier.js";
import { WebhookNotifier } from "../adapters/notifications/webhook-notifier.js";
import { InMemoryLeadRepository } from "../adapters/persistence/in-memory-lead-repository.js";
import { PgLeadRepository } from "../adapters/persistence/pg-lead-repository.js";
import { makeCreateLead } from "../application/create-lead.js";
// Composition root: the only module that knows every concrete adapter
const leads = process.env.DATABASE_URL
? new PgLeadRepository(new pg.Pool({ connectionString: process.env.DATABASE_URL }))
: new InMemoryLeadRepository();
const notifier = process.env.LEAD_WEBHOOK_URL
? new WebhookNotifier(process.env.LEAD_WEBHOOK_URL)
: new InMemoryNotifier();
const createLead = makeCreateLead({
leads,
notifier,
clock: () => new Date(),
newId: randomUUID,
onNotifyError: (error) => console.error("lead notification failed", error),
});
const handleCreateLead = makeCreateLeadController(createLead);
// Frameworks and drivers: node:http here; Express or Fastify would change only this part
const server = createServer(async (req, res) => {
if (req.method !== "POST" || req.url !== "/leads") {
res.writeHead(404).end();
return;
}
try {
let raw = "";
for await (const chunk of req) raw += chunk;
const { status, body } = await handleCreateLead(raw ? JSON.parse(raw) : {});
res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify(body));
} catch (error) {
console.error(error);
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ code: "INTERNAL" }));
}
});
const port = Number(process.env.PORT ?? 3000);
server.listen(port, () => console.log(`Listening on http://localhost:${port}`));npx tsx src/main/server.ts. С DATABASE_URL — PostgreSQL, без неё — память; с LEAD_WEBHOOK_URL — webhook$ DATABASE_URL=postgres://postgres:test@127.0.0.1:55471/postgres PORT=3471 npx tsx src/main/server.ts
Listening on http://localhost:3471
$ curl -i -X POST localhost:3471/leads -H 'Content-Type: application/json' \
-d '{"name":"Aida","phone":"8 701 123 45 67"}'
HTTP/1.1 201 Created
{"leadId":"ff34b10e-571a-4d35-92b1-f60349164c2a","duplicate":false,"notified":true}
$ curl -i -X POST localhost:3471/leads -H 'Content-Type: application/json' \
-d '{"name":"Aida","phone":"+77011234567"}'
HTTP/1.1 200 OK
{"leadId":"ff34b10e-571a-4d35-92b1-f60349164c2a","duplicate":true,"notified":false}
$ curl -i -X POST localhost:3471/leads -H 'Content-Type: application/json' \
-d '{"name":"Aida","phone":"12345"}'
HTTP/1.1 422 Unprocessable Entity
{"code":"LEAD_INVALID_PHONE","message":"Phone must be a +7 mobile number"}
$ docker exec kb-pg-chistaya-arhitektura psql -U postgres -c "SELECT name, phone FROM leads"
name | phone
------+--------------
Aida | +77011234567
(1 row)Тесты: ядро без базы и HTTP, адаптеры — по одному контракту
Сценарий проверяется с адаптерами в памяти и фиксированными часами. Тест не поднимает сервер и базу, поэтому каждая политика — нормализация телефона, окно повтора, сбой уведомления — проверяется отдельно и за миллисекунды.
import { beforeEach, describe, expect, it } from "vitest";
import { InMemoryNotifier } from "../src/adapters/notifications/in-memory-notifier.js";
import { InMemoryLeadRepository } from "../src/adapters/persistence/in-memory-lead-repository.js";
import { type CreateLead, makeCreateLead } from "../src/application/create-lead.js";
import { DomainError } from "../src/domain/lead.js";
describe("create lead use case", () => {
let now: Date;
let ids: number;
let leads: InMemoryLeadRepository;
let notifier: InMemoryNotifier;
let errors: unknown[];
let createLead: CreateLead;
beforeEach(() => {
now = new Date("2026-10-11T09:00:00Z");
ids = 0;
leads = new InMemoryLeadRepository();
notifier = new InMemoryNotifier();
errors = [];
createLead = makeCreateLead({
leads,
notifier,
clock: () => now,
newId: () => `lead-${++ids}`,
onNotifyError: (error) => errors.push(error),
});
});
it("saves a lead with a normalized phone and notifies the team", async () => {
const result = await createLead({ name: " Aida ", phone: "8 701 123 45 67" });
expect(result).toEqual({ leadId: "lead-1", duplicate: false, notified: true });
expect(leads.rows).toEqual([
{ id: "lead-1", name: "Aida", phone: "+77011234567", createdAt: now },
]);
expect(notifier.sent).toEqual([{ leadId: "lead-1", name: "Aida", phone: "+77011234567" }]);
});
it("treats a repeat within 10 minutes as the same lead", async () => {
await createLead({ name: "Aida", phone: "+7 701 123 45 67" });
now = new Date("2026-10-11T09:05:00Z");
const repeat = await createLead({ name: "Aida", phone: "87011234567" });
expect(repeat).toEqual({ leadId: "lead-1", duplicate: true, notified: false });
expect(leads.rows).toHaveLength(1);
expect(notifier.sent).toHaveLength(1);
});
it("creates a new lead after the repeat window", async () => {
await createLead({ name: "Aida", phone: "+77011234567" });
now = new Date("2026-10-11T09:11:00Z");
const later = await createLead({ name: "Aida", phone: "+77011234567" });
expect(later.duplicate).toBe(false);
expect(leads.rows).toHaveLength(2);
});
it("keeps the lead when the notifier fails", async () => {
notifier.failNext = true;
const result = await createLead({ name: "Aida", phone: "+77011234567" });
expect(result).toEqual({ leadId: "lead-1", duplicate: false, notified: false });
expect(leads.rows).toHaveLength(1);
expect(errors).toHaveLength(1);
});
it("rejects invalid data with a domain error and saves nothing", async () => {
await expect(createLead({ name: " ", phone: "+77011234567" })).rejects.toMatchObject({
code: "LEAD_INVALID_NAME",
});
await expect(createLead({ name: "Aida", phone: "12345" })).rejects.toBeInstanceOf(DomainError);
expect(leads.rows).toHaveLength(0);
});
});У адаптеров в памяти есть риск: подделка ведёт себя не так, как настоящая база, и тесты сценария зелёные, а продакшен — нет. Защита — контрактный тест порта: один набор проверок, через который проходят все его реализации. Если PostgreSQL-адаптер вернёт не самую свежую заявку, упадёт его тест, а не сценарий у клиента.
import { describe, expect, it } from "vitest";
import type { LeadRepository } from "../src/application/ports.js";
// One behaviour spec for every adapter of the LeadRepository port
export function leadRepositoryContract(name: string, make: () => Promise<LeadRepository>) {
describe(`LeadRepository contract: ${name}`, () => {
it("returns null when the phone is unknown", async () => {
const repo = await make();
expect(await repo.findLatestByPhone("+77000000000")).toBeNull();
});
it("returns the most recent lead for a phone", async () => {
const repo = await make();
const phone = "+77011234567";
await repo.save({
id: "6f1d1c3e-8a7b-4a51-9a59-0c6e8f0a0001",
name: "Old",
phone,
createdAt: new Date("2026-10-01T10:00:00Z"),
});
await repo.save({
id: "6f1d1c3e-8a7b-4a51-9a59-0c6e8f0a0002",
name: "New",
phone,
createdAt: new Date("2026-10-11T10:00:00Z"),
});
expect(await repo.findLatestByPhone(phone)).toEqual({
id: "6f1d1c3e-8a7b-4a51-9a59-0c6e8f0a0002",
name: "New",
phone,
createdAt: new Date("2026-10-11T10:00:00Z"),
});
});
});
}LeadRepository: одна спецификация для всех адаптеровimport { readFileSync } from "node:fs";
import pg from "pg";
import { afterAll, beforeAll, describe } from "vitest";
import { InMemoryLeadRepository } from "../src/adapters/persistence/in-memory-lead-repository.js";
import { PgLeadRepository } from "../src/adapters/persistence/pg-lead-repository.js";
import { leadRepositoryContract } from "./lead-repository.contract.js";
leadRepositoryContract("in-memory", async () => new InMemoryLeadRepository());
const url = process.env.DATABASE_URL;
describe.runIf(url)("PostgreSQL adapter", () => {
const pool = new pg.Pool({ connectionString: url });
beforeAll(async () => {
await pool.query("DROP TABLE IF EXISTS leads");
await pool.query(readFileSync(new URL("../schema.sql", import.meta.url), "utf8"));
});
afterAll(() => pool.end());
leadRepositoryContract("postgres", async () => {
await pool.query("TRUNCATE leads");
return new PgLeadRepository(pool);
});
});DATABASE_URL$ DATABASE_URL=postgres://postgres:test@127.0.0.1:55471/postgres npx vitest run --reporter=verbose
RUN v5.0.3 …
✓ test/create-lead.test.ts > create lead use case > saves a lead with a normalized phone and notifies the team 2ms
✓ test/create-lead.test.ts > create lead use case > treats a repeat within 10 minutes as the same lead 1ms
✓ test/create-lead.test.ts > create lead use case > creates a new lead after the repeat window 0ms
✓ test/create-lead.test.ts > create lead use case > keeps the lead when the notifier fails 0ms
✓ test/create-lead.test.ts > create lead use case > rejects invalid data with a domain error and saves nothing 1ms
✓ test/adapters.test.ts > HTTP controller > maps results and domain errors to status codes 2ms
✓ test/adapters.test.ts > WebhookNotifier > posts the notice as JSON and fails on a non-2xx answer 40ms
✓ test/repositories.test.ts > LeadRepository contract: in-memory > returns null when the phone is unknown 1ms
✓ test/repositories.test.ts > LeadRepository contract: in-memory > returns the most recent lead for a phone 1ms
✓ test/repositories.test.ts > PostgreSQL adapter > LeadRepository contract: postgres > returns null when the phone is unknown 6ms
✓ test/repositories.test.ts > PostgreSQL adapter > LeadRepository contract: postgres > returns the most recent lead for a phone 10ms
Test Files 3 passed (3)
Tests 11 passed (11)DATABASE_URL выполняются 9, два пропускаютсяВ проекте есть ещё тест HTTP-контроллера и тест webhook-адаптера с локальным HTTP-сервером — они видны в выводе. Раскладка получается такой: много быстрых тестов ядра, по несколько тестов на каждый адаптер и минимум сквозных. Интеграционные тесты с базой, фикстуры и очистка данных подробно разобраны в уроке о тестировании backend.
Правило зависимостей как проверка в CI
Кокберн описывает типичный провал слоистых систем: новый слой обещает «никакой бизнес-логики», но механизма обнаружить нарушение нет, и через несколько лет слой снова зарос логикой. Чистая архитектура без автоматической проверки повторяет эту историю. Компилятор не поможет: импорт драйвера базы в сценарий — корректный TypeScript.
Вот два типичных нарушения: «удобный» генератор идентификаторов в сущности и обработка ошибки PostgreSQL прямо в сценарии.
--- a/src/domain/lead.ts
+++ b/src/domain/lead.ts
@@ -1,3 +1,7 @@
+import { randomUUID } from "node:crypto";
+
+export const newLeadId = (): string => randomUUID();
+
export class DomainError extends Error {
constructor(
readonly code: string,
--- a/src/application/create-lead.ts
+++ b/src/application/create-lead.ts
@@ -1,3 +1,4 @@
+import { DatabaseError } from "pg";
import { createLead, normalizePhone } from "../domain/lead.js";
import type { Clock, IdGenerator, LeadNotifier, LeadRepository } from "./ports.js";
@@ -28,7 +29,15 @@
}
const lead = createLead({ id: deps.newId(), name: input.name, phone: input.phone, now });
- await deps.leads.save(lead);
+ try {
+ await deps.leads.save(lead);
+ } catch (error) {
+ // 23505 = unique_violation: a PostgreSQL detail inside the use case
+ if (error instanceof DatabaseError && error.code === "23505") {
+ return { leadId: lead.id, duplicate: true, notified: false };
+ }
+ throw error;
+ }
// The lead is already saved: a failed notification must not lose it
try {dependency-cruiser проверяет граф импортов по правилам из конфигурации. Правила — регулярные выражения для путей «откуда» и «куда»; группа $1 позволяет сказать «адаптер может импортировать только файлы своей папки».
/** @type {import('dependency-cruiser').IConfiguration} */
module.exports = {
forbidden: [
{
name: "domain-is-pure",
comment: "Entities know nothing outside the domain: no app code, no npm, no node: modules",
severity: "error",
from: { path: "^src/domain/" },
to: { pathNot: "^src/domain/" },
},
{
name: "application-depends-only-on-domain",
comment: "Use cases see entities and their own ports, never adapters, frameworks or drivers",
severity: "error",
from: { path: "^src/application/" },
to: { pathNot: "^src/(application|domain)/" },
},
{
name: "adapters-do-not-know-each-other",
comment: "The SQL adapter must not call the webhook adapter: they meet only in main",
severity: "error",
from: { path: "^src/adapters/([^/]+)/" },
to: { path: "^src/adapters/", pathNot: "^src/adapters/$1/" },
},
{
name: "nobody-imports-main",
comment: "The composition root is the outermost circle",
severity: "error",
from: { pathNot: "^src/main/" },
to: { path: "^src/main/" },
},
{
name: "no-circular",
severity: "error",
from: {},
to: { circular: true },
},
{
name: "not-to-unresolvable",
severity: "error",
from: {},
to: { couldNotResolve: true },
},
],
options: {
doNotFollow: { path: "node_modules" },
// Count `import type` too: a type from an outer circle is still a dependency
tsPreCompilationDeps: true,
tsConfig: { fileName: "tsconfig.json" },
},
};main никто не импортирует$ npx tsc --noEmit && echo "tsc: ok"
tsc: ok
$ npx depcruise src --config .dependency-cruiser.cjs
error domain-is-pure: src/domain/lead.ts → crypto
error application-depends-only-on-domain: src/application/create-lead.ts → node_modules/pg/lib/index.js
x 2 dependency violations (2 errors, 0 warnings). 12 modules, 23 dependencies cruised.
$ npx eslint src
…\src\application\create-lead.ts
1:1 error 'pg' import is restricted from being used by a pattern. The core imports only relative paths no-restricted-imports
…\src\domain\lead.ts
1:1 error 'node:crypto' import is restricted from being used by a pattern. The core imports only relative paths no-restricted-imports
✖ 2 problems (2 errors, 0 warnings)tsc и все тесты проходят, оба линтера — нет. Путь к проекту сокращёнESLint даёт ту же защиту прямо в редакторе. Правило import-x/no-restricted-paths запрещает импорты между папками-зонами, а встроенное no-restricted-imports с регулярным выражением запрещает ядру любые нерелятивные импорты — npm-пакеты и модули node:. Чтобы ESLint понимал импорты с расширением .js, нужен TypeScript-резолвер.
import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript";
import { importX } from "eslint-plugin-import-x";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
files: ["src/**/*.ts"],
languageOptions: { parser: tseslint.parser },
plugins: { "import-x": importX },
// Resolves "./ports.js" to ports.ts, as TypeScript does
settings: { "import-x/resolver-next": [createTypeScriptImportResolver()] },
rules: {
// Local files: inner circles must not reach outward
"import-x/no-restricted-paths": [
"error",
{
zones: [
{ target: "./src/domain", from: "./src", except: ["./domain"] },
{ target: "./src/application", from: "./src/adapters" },
{ target: "./src/application", from: "./src/main" },
{ target: "./src/adapters", from: "./src/main" },
],
},
],
},
},
{
// Packages: the core imports nothing from npm or node:
files: ["src/domain/**/*.ts", "src/application/**/*.ts"],
rules: {
"no-restricted-imports": [
"error",
{ patterns: [{ regex: "^[^.]", message: "The core imports only relative paths." }] },
],
},
},
);Импорты между кругами
- dependency-cruiser
- Да, регулярные выражения путей
- ESLint (import-x + no-restricted-imports)
- Да, зоны
target/from
npm-пакеты и node: в ядре
- dependency-cruiser
- Да, тем же правилом
- ESLint (import-x + no-restricted-imports)
- Отдельным правилом
no-restricted-imports
Адаптеры не знают друг о друге
- dependency-cruiser
- Да, одно правило с группой
$1 - ESLint (import-x + no-restricted-imports)
- Нужна отдельная зона на каждую пару папок
Циклы
- dependency-cruiser
circular: true— проверено- ESLint (import-x + no-restricted-imports)
- Есть правило
import-x/no-cycle, в примере не настроено
import type
- dependency-cruiser
- Только с
tsPreCompilationDeps: true - ESLint (import-x + no-restricted-imports)
- Учитывается — проверено на примере
Подсказка в редакторе
- dependency-cruiser
- Нет, запуск командой
- ESLint (import-x + no-restricted-imports)
- Да
{
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit",
"lint": "eslint src",
"lint:arch": "depcruise src --config .dependency-cruiser.cjs",
"check": "npm run typecheck && npm run lint && npm run lint:arch && npm test"
}
}npm run check — то, что запускает CI на каждый pull requestПо нашему опыту, достаточно одного инструмента в CI; ESLint удобнее, если линтер уже настроен, dependency-cruiser — если правил много или нужны правила между модулями, а не только между кругами.
Цена: когда это оверинжиниринг
Чистая архитектура добавляет файлы и косвенность: интерфейс там, где можно было вызвать функцию; маппинг там, где можно было передать строку таблицы; точку сборки там, где хватило бы импорта. Это окупается не везде. Сам Мартин подчёркивает, что четыре круга — схема, а не закон; обязательным остаётся только правило зависимостей.
Окупается
- Правила сложные и важные: расчёты, статусы, лимиты, права — их нужно проверять тестами без инфраструктуры
- Один сценарий запускают из нескольких мест: HTTP, бот, очередь, cron, импорт
- Внешние сервисы могут смениться или их несколько: платёжные системы, мессенджеры, CRM
- Систему развивают годами и несколько команд — граница должна держаться без устных договорённостей
Оверинжиниринг
- CRUD-справочник: сценарий только пересылает данные в базу, правил нет
- Прототип или MVP, который проверяет гипотезу и может быть выброшен
- Порт с единственной реализацией, которая не сменится и не мешает тестам
- Чтобы добавить поле в форму, нужно править пять файлов в четырёх папках
Фаулер добавляет важную оговорку про масштаб: деление на слои стоит применять на небольшой гранулярности. Когда приложение растёт, верхний уровень лучше делить на модули по предметной области, а слои держать внутри каждого модуля. То есть не src/domain на всю систему, а leads/domain, billing/domain и так далее — с этого начинается модульный монолит.
По нашему опыту, рабочая последовательность такая: начать со слоёв из урока о слоях; выделить порт, как только внешний сервис тормозит тесты или может смениться; переходить к полной схеме с проверкой в CI для модулей с богатыми правилами. Один и тот же проект может сочетать подходы: строгое ядро для заказов и платежей и простой CRUD для справочников.
Дальше: урок о модульном монолите и микросервисах показывает, как те же границы проводят между модулями системы и когда их стоит превращать в отдельные сервисы, а рецепт адаптера интеграции — как оформить внешний API за портом с повторами и тайм-аутами.
Как проверено6 · пройденосверено 11 октября 2026 г.
- запущено, работает
src/domain/lead.ts, src/application/{ports,create-lead}.ts, src/adapters/** (in-memory и PostgreSQL репозитории, InMemoryNotifier, WebhookNotifier, HTTP-контроллер), src/main/server.ts, schema.sql
временный проект scratchpad/kb/verify/chistaya-arhitektura: tsc --noEmit (strict, nodenext) на TypeScript 5.9.3 и 7.0.2; Node.js 24.18
- запущено, работает
test/create-lead.test.ts, test/lead-repository.contract.ts, test/repositories.test.ts (+ test/adapters.test.ts, не в статье)
vitest 5.0.3: 11/11 с DATABASE_URL на PostgreSQL 17.11 (docker postgres:17-alpine, kb-pg-chistaya-arhitektura, остановлен), 9 passed + 2 skipped без DATABASE_URL; вывод в статье — реальный
- запущено, работает
src/main/server.ts + curl
npx tsx src/main/server.ts с DATABASE_URL: 201 / 200 duplicate / 422, строка в таблице leads — вывод реальный (заголовки ответов отфильтрованы)
- запущено, работает
.dependency-cruiser.cjs
dependency-cruiser 18.6.0: чистый код — 0 нарушений; с патчем violation.diff (node:crypto в domain, pg в use case) — 2 ошибки при зелёных tsc и тестах; отдельно проверены adapters-do-not-know-each-other, nobody-imports-main, no-circular; tsPreCompilationDeps false/true: import type пропускается/ловится
- запущено, работает
eslint.config.js
ESLint 10.12.0 + typescript-eslint 8.71.1 + eslint-plugin-import-x 4.17.1 + eslint-import-resolver-typescript 4.4.5: чистый код — 0 ошибок; ловит pg и node:crypto в ядре, import type адаптера в application (без резолвера zones не срабатывали — резолвер добавлен), импорт main из адаптера
- запущено, работает
package.json scripts / npm run check
npm run check: typecheck → lint → lint:arch → test, всё зелёное
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.
Источники
- Robert C. Martin — The Clean Architecture (Clean Coder Blog, 2012)
- Alistair Cockburn — Hexagonal (Ports & Adapters) Architecture
- Martin Fowler — PresentationDomainDataLayering
- dependency-cruiser — Rules reference
- dependency-cruiser — Options reference (tsPreCompilationDeps)
- eslint-plugin-import-x — no-restricted-paths