Глава 58 · Практика
Структура веб-приложения: контроллер, сервис, репозиторий
Разделяем обязанности на примере расчёта доставки и заказа, не превращая небольшой сайт в фабрику абстракций.
Разделение обязанностей начинается с двух понятных функций
Нужно посчитать заказ и показать итог. Если вычисление сразу печатает HTML, его трудно использовать для API или письма. Начнём с разделения: одна функция считает, другая решает, как представить результат.
Что уже пригодится. переменная и присваивание, массив, объявление и вызов функции
- Проверенные входные значения
- Функция расчёта возвращает число
- Представление превращает число в текст
- HTTP-обработчик отправляет ответ
Архитектура — договор о том, какая часть отвечает за действие и с кем общается. Для двух строк не нужен набор классов. Сначала добиваемся понятных границ функций; затем переносим тот же принцип на файлы и объекты.
<?php
declare(strict_types=1);
function total(int $price, int $quantity): int
{
return $price * $quantity;
}
$sum = total(150, 2);
echo 'Итого: ' . $sum . "\n";
echo json_encode(['total' => $sum], JSON_THROW_ON_ERROR), "\n";
Ожидаемый результат
Итого: 300
{"total":300}
Функция total ничего не знает про HTML и JSON. Один результат использован дважды, без копирования расчёта. Цена учебная целочисленная; для денег заранее определите единицу, например копейки, и допустимые границы.
Следующая ступень. Дальше разделяем обработчик запроса, сценарий, данные и внешние API; сравниваем оправданную структуру с лишними слоями.
Разделение помогает изменять одну причину за раз
Кто что делает при оформлении заказа
Одни правила заказа нужны форме, API и CLI. Разделение обязанностей позволяет использовать их повторно.
- Контроллер: HTTP → входные значения
Читает запрос, проверяет его форму и передаёт данные сценарию. Не прячет расчёт скидки в echo.
- Сервис: выполнить сценарий
Применяет правила заказа и координирует сохранение. Получает нужные зависимости явно.
- Репозиторий: работа с базой
Выполняет SQL и возвращает данные сервису. Правило доставки не обязано знать о PDO.
- Ответ: результат → HTML или JSON
Контроллер выбирает статус, представление показывает результат, учитывая контекст вывода.
Это обязанности, а не обязательные четыре класса. Результат репозитория возвращается сервису, затем контроллеру и пользователю.
Если index.php одновременно читает POST, считает скидку, делает SQL и печатает HTML, первое изменение кажется быстрым. Затем ту же скидку нужно применить в CLI-импорте и API. Копирование куска контроллера переносит туда зависимость от браузера, сессии и echo. Разделение обязанностей нужно прежде всего для повторного использования правил и понятного тестирования, а не ради количества папок.
Контроллер переводит HTTP во входные значения приложения и результат приложения в HTTP. Сервис выполняет сценарий: оформить заказ, отменить бронь, обновить каталог. Репозиторий читает и сохраняет данные, скрывая SQL. Представление показывает результат. Эти роли могут быть небольшими функциями или классами; ООП полезно, когда нужно хранить зависимости и выразить контракт.
Не каждая таблица требует репозитория, а каждое действие — интерфейса. Начинайте с реальных границ: внешний API, база, часы, очередь. Интерфейс особенно полезен там, где есть замена реализации или тестовый дубль. Пустая обёртка над каждой функцией PHP затрудняет чтение и не добавляет надёжности. Архитектура должна объяснять поток данных, а не прятать его за десятью слоями.
- Контроллер: проверить HTTP-вход
- Сервис: правила и транзакция
- Репозиторий: SQL и данные
- Сервис: результат сценария
- Представление / JSON-ответ
Правило доставки не знает о $_POST
<?php
declare(strict_types=1);
final class ShippingPolicy {
public function priceFor(int $subtotalMinor, bool $pickup): int {
if ($subtotalMinor < 0) throw new InvalidArgumentException('Negative subtotal');
if ($pickup || $subtotalMinor >= 500000) return 0;
return 39000;
}
}
$policy = new ShippingPolicy();
echo $policy->priceFor(100000,false)."
";
echo $policy->priceFor(100000,true)."
";
echo $policy->priceFor(500000,false)."
";
Ожидаемый результат
39000
0
0
Политика получает уже проверенные значения. Её можно вызвать из HTTP, CLI и теста. Решение о бесплатной доставке не зависит от скрытого поля free_shipping, которое пользователь способен изменить.
Зависимости передают явно
<?php
declare(strict_types=1);
interface Clock { public function now(): int; }
final class FixedClock implements Clock {
public function __construct(private int $timestamp) {}
public function now(): int { return $this->timestamp; }
}
final class ReservationPolicy {
public function __construct(private Clock $clock) {}
public function expired(int $createdAt): bool {
return $this->clock->now() - $createdAt >= 900;
}
}
$policy = new ReservationPolicy(new FixedClock(2000));
echo $policy->expired(1100) ? "expired
" : "active
";
echo $policy->expired(1101) ? "expired
" : "active
";
Ожидаемый результат
expired
active
Интерфейс часов оправдан реальной потребностью проверять границу 15 минут. В рабочей реализации now возвращает time. В тесте не надо ждать 15 минут или менять системные часы.
Точка сборки приложения создаёт PDO, репозитории, клиентов и сервисы. Конструктор сервиса принимает их явно. Так становится видно, почему сценарий обращается к сети и какие зависимости надо подменить в тесте. Глобальный контейнер, из которого любой метод достаёт что угодно, возвращает скрытые зависимости под другим названием.
В доменных правилах предпочитайте значения и понятные результаты. Недостаток товара — ожидаемая ситуация сценария; недоступная база — инфраструктурный сбой. Можно использовать доменное исключение или объект результата, но HTTP-статус не должен случайно определяться текстом PDOException. Граница приложения сопоставляет тип результата с сообщением и кодом ответа.
Практичная структура небольшого сервиса
| Каталог | Ответственность | Чего там избегать |
|---|---|---|
| public/ | index.php и публичные ресурсы | Секретов, дампов базы, исходных архивов |
| src/Http/ | Маршруты, контроллеры, преобразование входа | Расчёта скидки и SQL в шаблоне |
| src/Application/ | Сценарии и координация | Прямого echo и чтения $_POST |
| src/Domain/ | Правила и значения предметной области | Зависимости от браузерной сессии |
| src/Infrastructure/ | PDO, HTTP-клиенты, файловое хранилище | Поглощения ошибок без диагностики |
| templates/ и tests/ | Представления и проверки | Записи в production из тестов |
Для маленького проекта несколько файлов с этими ролями лучше, чем преждевременно сложная иерархия. Рост структуры должен следовать за ростом поведения. Когда появляется второй вход — консольная команда или webhook — уже готовый сервис предотвращает копирование правил.
Отдельно определите владельца транзакции. Если каждый репозиторий сам вызывает commit, сервис не сможет атомарно объединить заказ и остаток. Если каждый вложенный сервис начинает транзакцию, появится конфликт вложенности. Обычно прикладной сценарий задаёт границу, а репозитории работают на переданном соединении.
Событие — факт, обработчик — реакция
OrderPlaced означает уже произошедший факт, а PlaceOrder — намерение/команда. Синхронный обработчик события выполняется в текущем процессе и может задержать ответ или бросить исключение. Асинхронный обработчик работает позже, требует надёжной доставки и идемпотентности. Само имя EventDispatcher не гарантирует очередь или транзакционность.
Сначала сохраните факт надёжно, затем запускайте внешние реакции через outbox, если потеря сообщения существенна. Не прячьте обязательное правило вроде списания остатка в необязательном слушателе, который можно забыть зарегистрировать. Граф потока событий должен показывать, где синхронный вызов, где граница процесса и где возможен повтор. Это делает отладку понятнее даже опытному разработчику.
Команда, DTO и значение денег
DTO передаёт проверенные данные между слоями, но сам по себе не гарантирует бизнес-правило. OrderInput может содержать quantity как int, а допустимый диапазон и наличие товара проверяются сценарием. Не называйте любую структуру DomainObject только ради солидности: важнее, где гарантируется корректность и кто имеет право менять состояние.
Объект Money полезен, когда нужно совместно хранить сумму и валюту, запретить сложение RUB с USD и централизовать округление. Для маленького расчёта достаточно целых минимальных единиц с явными именами amountMinor. Ошибка возникает, когда один метод ожидает рубли, другой копейки, а оба принимают безымянный int. Тип или ясное имя должны делать единицу измерения видимой.
Результат сценария также лучше выразить явно: created, already_processed, rejected или temporarily_unavailable. Возвращать false на все случаи легко, но контроллеру потом приходится угадывать причину. Хорошая граница позволяет изменить HTML на JSON, не переписывая правила заказа и не теряя различие ожидаемой ошибки пользователя и сбоя инфраструктуры.
Практика: проверьте решение, а не только успешный путь
Нужно запускать оформление заказа из формы и из ночного импорта. Какие части можно разделить, а какие переиспользовать?
Разбор решения
HTTP-валидация формата, CSRF и ответ остаются контроллеру; CLI разбирает свои аргументы. Оба передают проверенные команды одному сервису правил заказа. Репозитории, транзакция, идемпотентность и расчёт цены общие. Контекст полномочий передаётся явно, а импорт не получает неограниченные права случайно.