Чистая архитектура (Clean Architecture)
Делает каждый сценарий приложения отдельным объектом с явным входом и выходом, а все зависимости в коде направляет внутрь, к бизнес-правилам.
Проблема
Даже когда ядро отделено от базы портами, сценарии обычно живут методами одного сервиса и отвечают тем, что удобно вызывающему:
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 или строка в консоли - решает презентер.
Структура
Все стрелки пересекают границы кругов только в сторону центра: адаптеры и драйверы зависят от интерфейсов сценария, сценарий - от сущностей. 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, ...>в презентере заставляет компилятор требовать запись для каждой причины. Модели запроса и ответа - просто интерфейсы, объектный литерал подходит без конструктора.
Связанные темы
- Гексагональная архитектура (Hexagonal Architecture) - те же порты без деления ядра на круги и без презентера
- Слоистая архитектура (Layered Architecture) - тот же заказ с зависимостью бизнес-слоя от слоя данных
- Сервисный слой (Service Layer) - сценарии методами одного сервиса вместо объекта на сценарий
- Репозиторий (Repository) - шлюз хранилища, который объявляет сценарий
- Тактический DDD (Domain-Driven Design) - как устроить круг сущностей: агрегаты, объекты-значения, доменные события
- Разделение команд и запросов (CQRS) - сценарии-команды и сценарии-запросы как отдельные объекты