Как делать11 октября 2026 г.10 мин чтения
Архитектура ПОШаг 4 из 9

Чистая архитектура: зависимости внутрь и границы модулей

Коротко

Чистая архитектура — способ организовать код так, чтобы бизнес-правила не зависели от фреймворка, базы данных и внешних сервисов. Её главное требование — правило зависимостей: импорты в исходном коде направлены только внутрь, от адаптеров к сценариям (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 г.

Содержание
  1. 01Чем это отличается от слоёв «контроллер — сервис — репозиторий»
  2. 02Правило зависимостей и четыре круга
  3. 03Ядро: сущность, порты и use case
  4. 04Порты и адаптеры: in-memory, PostgreSQL, webhook и HTTP
  5. 05Тесты: ядро без базы и HTTP, адаптеры — по одному контракту
  6. 06Правило зависимостей как проверка в CI
  7. 07Цена: когда это оверинжиниринг

Чем это отличается от слоёв «контроллер — сервис — репозиторий»

В уроке о слоях backend-приложения толстый обработчик разделён на контроллер, сервис и репозиторий, а сервис тестируется с репозиторием в памяти. Это уже половина чистой архитектуры. Вторая половина — одно правило и ответ на вопрос, кто владеет интерфейсами. Здесь мы не повторяем разбор слоёв, а показываем, что меняется, когда это правило доводят до конца.

Классическая слоистая схема, как её описывает Мартин Фаулер, направляет зависимости сверху вниз: представление зависит от предметной области, предметная область — от источника данных. Фаулер там же упоминает распространённый вариант, в котором домен не зависит от источника данных благодаря промежуточному слою, — его часто называют гексагональной архитектурой. Роберт Мартин в статье «The Clean Architecture» (2012) свёл гексагональную, луковую архитектуру и ещё несколько похожих подходов к одному требованию: зависимости в исходном коде указывают только внутрь, и ничто во внутреннем круге не знает имён из внешнего — ни функций, ни классов, ни переменных.

Куда направлены зависимости

Слои (урок о слоях)
Сверху вниз, к хранилищу
Чистая архитектура
Внутрь, к бизнес-правилам

Кто объявляет интерфейс хранилища

Слои (урок о слоях)
Обычно слой данных, рядом с реализацией
Чистая архитектура
Слой сценариев — как порт, который ему нужен

Может ли сервис знать тип ошибки драйвера БД, Request фреймворка, строку таблицы

Слои (урок о слоях)
На практике часто знает
Чистая архитектура
Нет: через границу идут простые структуры данных

Где выбираются реализации

Слои (урок о слоях)
Где удобно
Чистая архитектура
В одной точке сборки (composition root)

Чем защищена граница

Слои (урок о слоях)
Код-ревью
Чистая архитектура
Тесты ядра и линтер зависимостей в CI

Правило зависимостей и четыре круга

Мартин рисует систему концентрическими кругами. Чем ближе к центру, тем выше уровень абстракции: внешние круги — механизмы, внутренние — политики, то есть бизнес-правила.

  1. 01Сущности (entities)Правила предметной области, верные для любого приложения компании. Меняются реже всего
  2. 02Сценарии (use cases)Правила конкретного приложения: оркестрируют сущности и объявляют порты
  3. 03Адаптеры интерфейсовКонтроллеры, презентеры, репозитории с SQL, клиенты внешних API — переводят данные между форматами
  4. 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 минут не создаёт вторую заявку; после сохранения команда получает уведомление, а сбой уведомления не должен терять заявку.

bash
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-alpine
Проект примера: зависимости и PostgreSQL 17 в Docker. tsconfig — строгий режим и module: nodenext, как в уроке о слоях

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

src/domain/lead.ts
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() внутри сценария сделал бы тесты зависимыми от часов.

src/application/ports.ts
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 — единица поведения: один сценарий, одна функция с явным входом и выходом. Здесь он сделан фабрикой, которая принимает зависимости и возвращает функцию; класс с конструктором работал бы так же.

src/application/create-lead.ts
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>;
Сценарий «создать заявку»: ни HTTP, ни SQL, ни названия мессенджера

Порты и адаптеры: 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.

src/adapters/persistence/in-memory-lead-repository.ts
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;
  }
}
Репозиторий в памяти: реализует тот же порт, что и PostgreSQL
src/adapters/persistence/pg-lead-repository.ts
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;
  }
}
Репозиторий PostgreSQL: SQL и формат строк таблицы не выходят за пределы файла
schema.sql
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.

src/adapters/notifications/in-memory-notifier.ts
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 имитирует сбой
src/adapters/notifications/webhook-notifier.ts
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}`);
  }
}
Уведомления через webhook: global fetch и AbortSignal.timeout из Node.js

HTTP-контроллер — первичный адаптер: он переводит тело запроса во вход сценария, а результат и доменные ошибки — в коды ответа. Он не зависит от веб-фреймворка, поэтому его легко переставить с node:http на Express или Fastify. Проверка формы здесь минимальная; схемы Zod и единый формат ошибок — в уроке о REST API на Express.

src/adapters/http/create-lead-controller.ts
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;
    }
  };
}
Первичный адаптер: HTTP-тело → вход сценария, результат → код ответа

Последний файл — точка сборки (composition root). Это самый внешний круг: только он знает все конкретные адаптеры и выбирает их по переменным окружения. Остальной код от этого выбора не зависит.

src/main/server.ts
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
terminal
$ 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)
Реальные ответы приложения на PostgreSQL 17: создание, повтор в окне 10 минут и ошибка сущности (заголовки ответов сокращены)

Тесты: ядро без базы и HTTP, адаптеры — по одному контракту

Сценарий проверяется с адаптерами в памяти и фиксированными часами. Тест не поднимает сервер и базу, поэтому каждая политика — нормализация телефона, окно повтора, сбой уведомления — проверяется отдельно и за миллисекунды.

test/create-lead.test.ts
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);
  });
});
Тесты сценария: правила проверяются без HTTP, базы и реального времени

У адаптеров в памяти есть риск: подделка ведёт себя не так, как настоящая база, и тесты сценария зелёные, а продакшен — нет. Защита — контрактный тест порта: один набор проверок, через который проходят все его реализации. Если PostgreSQL-адаптер вернёт не самую свежую заявку, упадёт его тест, а не сценарий у клиента.

test/lead-repository.contract.ts
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: одна спецификация для всех адаптеров
test/repositories.test.ts
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);
  });
});
Контракт против двух адаптеров; PostgreSQL — только если задан DATABASE_URL
terminal
$ 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)
Реальный вывод: 11 тестов, включая контракт на PostgreSQL 17. Без DATABASE_URL выполняются 9, два пропускаются

В проекте есть ещё тест HTTP-контроллера и тест webhook-адаптера с локальным HTTP-сервером — они видны в выводе. Раскладка получается такой: много быстрых тестов ядра, по несколько тестов на каждый адаптер и минимум сквозных. Интеграционные тесты с базой, фикстуры и очистка данных подробно разобраны в уроке о тестировании backend.

Правило зависимостей как проверка в CI

Кокберн описывает типичный провал слоистых систем: новый слой обещает «никакой бизнес-логики», но механизма обнаружить нарушение нет, и через несколько лет слой снова зарос логикой. Чистая архитектура без автоматической проверки повторяет эту историю. Компилятор не поможет: импорт драйвера базы в сценарий — корректный TypeScript.

Вот два типичных нарушения: «удобный» генератор идентификаторов в сущности и обработка ошибки PostgreSQL прямо в сценарии.

diff
--- 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 позволяет сказать «адаптер может импортировать только файлы своей папки».

.dependency-cruiser.cjs
/** @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 никто не импортирует
terminal
$ 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-резолвер.

eslint.config.js
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." }] },
      ],
    },
  },
);
ESLint 10 + eslint-plugin-import-x: зоны для своих папок и запрет пакетов в ядре

Импорты между кругами

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)
Да
package.json
{
  "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.

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

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

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

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

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

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