handbookНаписать

architecture/clean-architecture/README.md · тема · 4 языка · 26 тестов

Чистая архитектура (Clean Architecture) ​

Делает каждый сценарий приложения отдельным объектом с явным входом и выходом, а все зависимости в коде направляет внутрь, к бизнес-правилам.

Проблема ​

Даже когда ядро отделено от базы портами, сценарии обычно живут методами одного сервиса и отвечают тем, что удобно вызывающему:

php
final readonly class OrderService
{
    public function placeOrder(Request $request): JsonResponse
    {
        $order = /* ... check stock, save, charge ... */;

        return new JsonResponse(['orderId' => $order->id, 'total' => $order->total()->kopecks], 201);
    }

    public function cancelOrder(Request $request): JsonResponse { /* ... */ }
    public function repeatOrder(Request $request): JsonResponse { /* ... */ }
}

Сценарий принимает Request фреймворка и возвращает JsonResponse: правила снова знают про HTTP, а вызвать их из консоли или очереди можно только подделав запрос. Если вместо этого вернуть Order, контроллер начнёт сам решать, какие поля сущности показать, и сущность утечёт в представление. Сервис со временем копит десяток сценариев с общими зависимостями, хотя каждому нужна только часть.

Решение ​

Разделить приложение на концентрические круги и держать одно правило зависимостей: код внутреннего круга ничего не знает о внешних.

  • Сущности (Order, OrderLine, Money, OrderStatus) - бизнес-правила, которые не зависят от конкретного приложения.
  • Сценарии (use cases). Один сценарий - один объект-интерактор PlaceOrder. Он принимает модель запроса PlaceOrderRequest (только простые данные: id клиента, позиции), а результат отдаёт не возвратом, а в выходную границу PlaceOrderOutput: placed(PlaceOrderResponse) или rejected(Rejection, subject). Модель ответа тоже из простых данных: номер и сумма в копейках, без сущности. Здесь же объявлены интерфейсы шлюзов, которые нужны сценарию: OrderRepository, Inventory, PaymentGateway, Notifier. Входная граница PlaceOrderInput - интерфейс, через который сценарий вызывают.
  • Адаптеры интерфейсов - контроллер PlaceOrderController превращает тело HTTP-запроса в PlaceOrderRequest и вызывает PlaceOrderInput; презентер JsonPlaceOrderPresenter реализует PlaceOrderOutput и превращает ответ сценария в модель представления - статус и JSON.
  • Фреймворки и драйверы - реализации шлюзов: InMemoryOrderRepository, InMemoryInventory, FakePaymentGateway, RecordingNotifier. В проекте это SQL, клиент платёжной системы, почта, а ещё веб-фреймворк.

Контроллер вызывает сценарий, сценарий вызывает презентер, но в исходном коде сценарий зависит только от интерфейса PlaceOrderOutput, объявленного в его же круге. Поток управления идёт наружу, зависимость - внутрь.

Чем отличается от гексагональной. Направление зависимостей то же: ядро объявляет интерфейсы, инфраструктура их реализует, и шлюзы здесь - те же порты, что в гексагональной архитектуре. Разница в том, насколько подробно описано ядро. Гексагональная архитектура делит мир на "внутри" и "снаружи" и не говорит, как устроено внутри: там может быть один OrderService с десятком методов, которые возвращают сущности. Чистая архитектура делит ядро на два круга (сущности и сценарии), требует объект на каждый сценарий, отдельные модели запроса и ответа из простых данных и презентер вместо возврата значения. За это платишь числом типов, а получаешь сценарий, который не знает даже формы своего ответа: JSON, HTML или строка в консоли - решает презентер.

Структура ​

Сущности

Сценарии

Адаптеры интерфейсов

Фреймворки и драйверы

PlaceOrderRequest

implements

PlaceOrderResponse

implements

implements

implements

InMemoryOrderRepository

FakePaymentGateway

PlaceOrderController

JsonPlaceOrderPresenter

PlaceOrderInput

PlaceOrder

PlaceOrderOutput

OrderRepository

PaymentGateway

Order

Money

Все стрелки пересекают границы кругов только в сторону центра: адаптеры и драйверы зависят от интерфейсов сценария, сценарий - от сущностей. Inventory и Notifier устроены как OrderRepository и опущены на схеме.

Как это собирается ​

В composition root два уровня жизни объектов. Шлюзы создаются один раз на процесс: InMemoryOrderRepository, InMemoryInventory с товарами, FakePaymentGateway с лимитом, RecordingNotifier (в DI-контейнере это синглтоны). А презентер, интерактор и контроллер собираются на каждый запрос: новый JsonPlaceOrderPresenter, PlaceOrder со шлюзами и этим презентером, PlaceOrderController над интерактором. Контроллер ничего не возвращает, поэтому ответ HTTP-обработчик забирает у презентера: view() отдаёт статус и тело.

Первый запрос проходит весь путь: контроллер собирает PlaceOrderRequest, PlaceOrder проверяет остатки, сохраняет заказ, списывает оплату, уведомляет и вызывает placed с PlaceOrderResponse, презентер делает из него 201. Во втором ручек (pen) не хватает: сценарий вызывает rejected(OutOfStock, "pen") до любых побочных эффектов, презентер отвечает 409. В третьем шлюз отказывает в оплате: заказ сохранён как cancelled, сценарий вызывает rejected(PaymentDeclined, "order-2"), ответ 402. Текст ошибок пишет презентер, сценарий передаёт только причину и о чём она.

Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.

Реализации ​

Код каждого языка - один файл, круги идут в нём от центра наружу. В проекте это отдельные пакеты, а сценарии лежат по файлу на сценарий: place_order/, cancel_order/, у каждого свои запрос, ответ и границы.

ЯзыкОсобенность
PHPграницы и шлюзы - interface, причины отказа - enum Rejection, презентер выбирает ответ через match
Goинтерактор - структура с полями-интерфейсами, презентер хранит Status и Body; ошибка разбора тела - обычный error контроллера
Pythonграницы - Protocol, модели запроса и ответа - frozen dataclass, презентер разбирает причину через match
TypeScriptзапрос и ответ - интерфейсы-литералы, причина отказа - объединение строк, презентер берёт статус и текст из Record<Rejection, ...>

Запуск: make test T=architecture/clean-architecture

Плюсы и минусы ​

Тесты сценария подставляют в выходную границу шпиона и проверяют, что сценарий сообщил, а не какой JSON получился: успешный заказ пришёл в placed с номером и суммой; нехватка товара - rejected до записи и списания; отказ оплаты - rejected с номером заказа, который остался cancelled. Презентер проверяется отдельно и без сценария: причина отказа превращается в нужный статус и текст.

  • ✅ Сценарий не знает ни фреймворка, ни формата ответа: тот же PlaceOrder отдаёт JSON, HTML или текст в консоль, меняется только презентер.
  • ✅ Один сценарий - один класс с ровно теми зависимостями, которые ему нужны; список сценариев читается по файлам.
  • ✅ Сущности не утекают наружу: представление видит только PlaceOrderResponse.
  • ❌ Много типов на сценарий: запрос, ответ, две границы, интерактор, контроллер, презентер.
  • ❌ Презентер с состоянием непривычен: ответ надо забирать из него после вызова, а интерактор собирать на каждый запрос.
  • ❌ Модели запроса и ответа часто почти повторяют сущность, и маппинг между ними становится рутиной.

Когда не применять ​

  • Сценариев мало и все они - CRUD: интерактор будет пересказывать репозиторий, хватит слоистой архитектуры.
  • Ответ всегда один (только JSON API): презентер можно заменить возвратом PlaceOrderResponse из execute, большинство проектов так и делает, сохраняя остальное.
  • Команда не готова держать правило зависимостей проверкой импортов: без неё круги быстро размываются, а типов остаётся много.

Отличия между языками ​

  • PHP - enum Rejection и match дают исчерпывающий разбор причин: добавишь причину и забудешь её в презентере - match бросит UnhandledMatchError, а PHPStan предупредит заранее.
  • Go - выходная граница с методами Placed и Rejected ложится на интерфейс естественно, но идиоматичнее было бы вернуть (PlaceOrderResponse, error) и разобрать ошибку в адаптере, как в гексагональной версии. Презентер здесь, чтобы показать приём. Разбор JSON - дело контроллера, его ошибка возвращается как error и до сценария не доходит.
  • Python - границы как Protocol: шпион в тесте и презентер не наследуют PlaceOrderOutput, а совпадают по форме. match по StrEnum читается как таблица, но не проверяет полноту без анализатора типов.
  • TypeScript - Rejection - объединение строк, а таблица Record<Rejection, ...> в презентере заставляет компилятор требовать запись для каждой причины. Модели запроса и ответа - просто интерфейсы, объектный литерал подходит без конструктора.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS