handbookНаписать

architecture/layered/README.md · тема · 4 языка · 24 теста

Слоистая архитектура (Layered Architecture) ​

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

Проблема ​

Оформление заказа: клиент присылает позиции (sku и количество), система проверяет склад, считает сумму, сохраняет заказ, списывает оплату и шлёт письмо. Если всё это написать в одном обработчике запроса, получается так:

php
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. Переименовали колонку или сменили платёжную систему - правится бизнес-слой, хотя правила не менялись. Тест сервиса без слоя данных не соберёшь: здесь слой данных в памяти, в проекте это обычно тестовая база. Эту зависимость разворачивают гексагональная и чистая архитектура.

Структура ​

Слой данных

Бизнес-слой

Представление

OrderController

OrderService

Order

OrderLine

Money

ProductTable

OrderTable

PaymentClient

Mailer

Все стрелки идут вниз. Ошибки бизнес-слоя (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).

ЯзыкОсобенность
PHPfinal readonly классы, строки таблиц описаны array shape для PHPStan, ошибки - исключения от DomainException
GoMoney - именованный int, ошибки - сентинелы с errors.Is в контроллере, тело запроса разбирает encoding/json
Pythondataclass для домена, 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 и подобные).

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

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