Слоистая архитектура (Layered Architecture)
Делит приложение на слои представления, бизнес-логики и данных, где каждый слой вызывает только тот, что под ним.
Проблема
Оформление заказа: клиент присылает позиции (sku и количество), система проверяет склад, считает сумму, сохраняет заказ, списывает оплату и шлёт письмо. Если всё это написать в одном обработчике запроса, получается так:
public function store(Request $request): JsonResponse
{
$total = 0;
foreach ($request->input('items') as $item) {
$row = DB::selectOne('select price_kopecks, stock from products where sku = ?', [$item['sku']]);
if (!$row || $row->stock < $item['quantity']) {
return response()->json(['error' => "out of stock: {$item['sku']}"], 409);
}
$total += $row->price_kopecks * $item['quantity'];
}
$id = DB::table('orders')->insertGetId(['customer_id' => $request->input('customer'), 'total_kopecks' => $total]);
if (!Http::post('https://pay.example/charge', ['amount' => $total])->ok()) {
return response()->json(['error' => 'payment declined'], 402);
}
Mail::raw("order {$id} placed", fn ($m) => $m->to($request->input('customer')));
return response()->json(['orderId' => $id, 'total' => $total], 201);
}HTTP, SQL, правило "нет товара - нет заказа" и вызов платёжной системы перемешаны. Тот же сценарий из консольной команды или очереди придётся копировать. Проверить расчёт суммы можно только через HTTP-запрос с живой базой.
Решение
Разложить код по слоям с одним направлением зависимостей - сверху вниз:
- Представление (
OrderController) разбирает запрос, вызывает сервис и превращает результат или ошибку в HTTP-ответ. Бизнес-правил здесь нет. - Бизнес-слой (
OrderServiceи доменныеOrder,OrderLine,Money) держит сценарий и правила: хватает ли товара, сколько стоит заказ, что делать при отказе оплаты. - Слой данных (
ProductTable,OrderTable,PaymentClient,Mailer) знает, где лежат данные и как говорить с внешними системами.
Слой вызывает только соседа снизу и ничего не знает о тех, кто над ним. Контроллер не лезет в таблицы, таблицы не знают про HTTP. Это самая распространённая архитектура: её по умолчанию дают MVC-фреймворки, и большинство проектов начинаются с неё.
Где она ломается: зависимость направлена от бизнес-слоя к слою данных, поэтому домен знает про таблицы. В примере это видно прямо в OrderService::placeOrder: сервис читает $row['stock'] и $row['price_kopecks'], пишет строку с колонками customer_id и status и вызывает конкретный PaymentClient. Переименовали колонку или сменили платёжную систему - правится бизнес-слой, хотя правила не менялись. Тест сервиса без слоя данных не соберёшь: здесь слой данных в памяти, в проекте это обычно тестовая база. Эту зависимость разворачивают гексагональная и чистая архитектура.
Структура
Все стрелки идут вниз. Ошибки бизнес-слоя (OutOfStock, PaymentDeclined) поднимаются вверх исключениями, и только контроллер решает, что это 409 и 402. Суммы в копейках (int), чтобы не ловить ошибки округления float.
Как это собирается
Слои собираются снизу вверх в одном месте - в DI-контейнере или main: сначала таблицы и клиенты внешних систем (ProductTable с товарами, OrderTable, PaymentClient с лимитом, Mailer), из них OrderService, из него OrderController. Каждый объект получает соседа снизу в конструкторе, поэтому порядок создания и есть порядок слоёв.
Дальше приходят три запроса. Первый проходит весь путь: контроллер передаёт позиции сервису, тот читает товары из ProductTable, пишет заказ в OrderTable со статусом pending, списывает оплату, меняет статус на paid, отправляет письмо и возвращает Order, контроллер отвечает 201. Во втором ручек (pen) просят больше, чем есть на складе: сервис бросает OutOfStock до записи в таблицу, контроллер отвечает 409. В третьем сумма выше лимита платёжного клиента: заказ уже записан, сервис помечает его cancelled и бросает PaymentDeclined, ответ 402, письма нет.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Код каждого языка - один файл, слои идут в нём сверху вниз и отделены комментарием. В проекте это три пакета или папки, и правило "только вниз" проверяет линтер импортов (deptrac, depguard, import-linter, eslint-plugin-boundaries).
| Язык | Особенность |
|---|---|
| PHP | final readonly классы, строки таблиц описаны array shape для PHPStan, ошибки - исключения от DomainException |
| Go | Money - именованный int, ошибки - сентинелы с errors.Is в контроллере, тело запроса разбирает encoding/json |
| Python | dataclass для домена, TypedDict для строк таблиц, Money с __mul__ и __add__ |
| TypeScript | классы с readonly, строки таблиц - интерфейсы, контроллер ловит ошибки через instanceof |
Запуск: make test T=architecture/layered
Плюсы и минусы
Тесты идут через контроллер и проверяют, что осталось в слое данных: успешный заказ записан как paid и письмо ушло; при нехватке товара строки нет; при отказе оплаты строка cancelled и писем нет. Для этого тесту нужен весь стек, включая слой данных, - это и есть цена слоистой архитектуры.
- ✅ Просто объяснить и начать: три папки и одно правило.
- ✅ HTTP отделён от правил: тот же
OrderServiceможно вызвать из консоли или очереди. - ✅ Совпадает с тем, что дают фреймворки: контроллеры, сервисы, модели.
- ❌ Бизнес-слой зависит от слоя данных: схема таблиц и конкретные клиенты протекают в правила.
- ❌ Юнит-тест сервиса требует подмены классов слоя данных или тестовой базы.
- ❌ Со временем сервис превращается в "скрипт транзакции" над строками, а доменные объекты - в пустые структуры.
Когда не применять
- Правил много и они живут дольше, чем выбор базы и внешних систем, - тогда стоит развернуть зависимость к домену, см. гексагональную архитектуру.
- Приложение - тонкий CRUD над таблицей: три слоя ради
insertиselectбудут церемонией, хватит контроллера и модели фреймворка. - Слои начинают "проскакивать" (контроллер ходит в таблицу мимо сервиса ради скорости) и никто это не ловит - без проверки импортов правило быстро размывается.
Отличия между языками
- PHP - строки таблиц описаны array shape (
array{price_kopecks: int, stock: int}), это только подсказка для PHPStan: в рантайме это обычный массив, и опечатка в ключе всплывёт только при анализе. - Go -
Money- именованный тип надint, без класса и методов-обёрток: умножение и сложение работают как у чисел, а спутать копейки с количеством не даёт компилятор.encoding/jsonсопоставляет ключи с полями без учёта регистра, поэтомуItemв бизнес-слое обходится без тегов, теги нужны только телу запроса. - Python - строки таблиц как
TypedDict: это обычныеdict, слой данных не платит за обёртки, а анализатор типов видит колонки.Moneyперегружает*и+, поэтому сумма заказа пишется через встроенныйsum. - TypeScript - строки таблиц - интерфейсы, тело запроса приводится к
OrderBodyчерезas: в рантайме TypeScript ничего не проверяет, в проекте здесь стоит схема (zod и подобные).
Связанные темы
- Гексагональная архитектура (Hexagonal Architecture) - тот же заказ, но ядро объявляет порты, а хранилище и платёжная система их реализуют
- Чистая архитектура (Clean Architecture) - сценарий как отдельный объект с запросом, ответом и презентером
- Сервисный слой (Service Layer) - то, чем в слоистой архитектуре является бизнес-слой для входов
- Репозиторий (Repository) - первый шаг к тому, чтобы домен перестал знать про таблицы