Привет, Хабр!
Если вы когда‑нибудь пытались настроить бизнес‑логику в своём проекте так, чтобы она не выглядела как свалка if-else и работала хорошо, то этот материал для вас. Сегодня мы разберём один из самых приятных паттернов — Chain of Responsibility, или «Цепочка обязанностей».
Вместо кучи условий, которые как минимум трудно читать, а как максимум невозможно поддерживать, мы строим гибкую архитектуру. Каждый этап обработки запроса становится отдельным модулем. А благодаря возможности менять порядок этих модулей или добавлять новые, система легко масштабируется.
Используйте Chain of Responsibility, когда:
Логика обработки запроса должна быть модульной.
Нужно динамически менять последовательность обработки.
Вы хотите облегчить добавление новых обработчиков.
Сразу перейдем к коду
Реализация паттерна на примере магазина котиков
Архитектура магазина котиков
Вот как будет выглядеть наш процесс:
Проверка наличия товара.
Проверка возраста покупателя.
Проверка оплаты.
Упаковка заказа.
Каждое из этих действий — это отдельный обработчик в цепочке.
Интерфейс обработчика
Начнём с базового интерфейса, который будут реализовывать наши обработчики.
<?php
interface HandlerInterface
{
public function setNext(HandlerInterface $handler): HandlerInterface;
public function handle(array $request): ?array;
}
abstract class AbstractHandler implements HandlerInterface
{
private ?HandlerInterface $nextHandler = null;
public function setNext(HandlerInterface $handler): HandlerInterface
{
$this->nextHandler = $handler;
return $handler;
}
public function handle(array $request): ?array
{
if ($this->nextHandler) {
return $this->nextHandler->handle($request);
}
return $request;
}
}
Интерфейс HandlerInterface определяет контракт для всех обработчиков, а базовый класс AbstractHandler реализует передачу запроса следующему обработчику.
Обработчики
Теперь создадим обработчики для проверки заказа. Начнем с проверки наличия заказа:
<?php
class StockHandler extends AbstractHandler
{
public function handle(array $request): ?array
{
if ($request['stock'] <= 0) {
throw new RuntimeException('Товара нет в наличии.');
}
error_log("Товар в наличии: {$request['stock']} единиц.");
return parent::handle($request);
}
}
Теперь реализуем проверку возраста покупателя:
<?php
class AgeVerificationHandler extends AbstractHandler
{
public function handle(array $request): ?array
{
if ($request['age'] < 18) {
throw new RuntimeException('Покупатель слишком молод.');
}
error_log("Возраст покупателя ({$request['age']}) прошёл проверку.");
return parent::handle($request);
}
}
Проверка оплаты:
<?php
class PaymentHandler extends AbstractHandler
{
public function handle(array $request): ?array
{
if (empty($request['payment']) || !$request['payment']) {
throw new RuntimeException('Оплата не прошла.');
}
error_log("Оплата успешно завершена: {$request['payment_id']}.");
return parent::handle($request);
}
}
Упаковка и подготовка к доставке:
<?php
class PackagingHandler extends AbstractHandler
{
public function handle(array $request): ?array
{
error_log("Товар упакован и готов к доставке.");
$request['status'] = 'ready_for_delivery';
return parent::handle($request);
}
}
Сборка цепочки
Теперь объединим все обработчики в цепочку.
<?php
$request = [
'stock' => 5,
'age' => 25,
'payment' => true,
'payment_id' => 'PAY12345',
];
$stockHandler = new StockHandler();
$ageHandler = new AgeVerificationHandler();
$paymentHandler = new PaymentHandler();
$packagingHandler = new PackagingHandler();
$stockHandler->setNext($ageHandler)
->setNext($paymentHandler)
->setNext($packagingHandler);
try {
$result = $stockHandler->handle($request);
echo "Заказ успешно обработан: " . json_encode($result, JSON_PRETTY_PRINT);
} catch (RuntimeException $e) {
error_log("Ошибка обработки заказа: " . $e->getMessage());
echo "Ошибка: " . $e->getMessage();
}
Если что‑то идёт не так, выбрасываем RuntimeException, а все важные этапы логируются через error_log (или можно заменить на тот же Monolog).
Не забываем покрыть код тестами:
<?php
use PHPUnit\Framework\TestCase;
class ChainTest extends TestCase
{
public function testStockHandlerFailsWhenOutOfStock()
{
$handler = new StockHandler();
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Товара нет в наличии.');
$handler->handle(['stock' => 0]);
}
public function testChainProcessesRequestSuccessfully()
{
$request = [
'stock' => 5,
'age' => 25,
'payment' => true,
'payment_id' => 'PAY12345',
];
$stockHandler = new StockHandler();
$ageHandler = new AgeVerificationHandler();
$paymentHandler = new PaymentHandler();
$packagingHandler = new PackagingHandler();
$stockHandler->setNext($ageHandler)
->setNext($paymentHandler)
->setNext($packagingHandler);
$result = $stockHandler->handle($request);
$this->assertEquals('ready_for_delivery', $result['status']);
}
}
Что ещё можно улучшить?
Динамическая конфигурация цепочки.
Например, настраивать последовательность обработчиков через тот же JSON или YAML.Производительность.
Для больших цепочек можно добавить кэширование результатов, чтобы не проходить одни и те же проверки повторно.Логирование.
Подключаем Monolog для более подробного логирования.
Как вы заметили, паттерн упрощает сложные процессы, разбивая их на независимые шаги. А какое применение паттерну находили вы? Делитесь в комментариях!
Всем PHP-разработчикам рекомендую посетить открытый урок «Вебсокеты на PHP, или как написать свой чат», который пройдет 22 января в Otus. Записаться можно по ссылке.
Комментарии (6)

Ascard
15.01.2025 10:33Ни одного котика в статье не обнаружено. Только очередной магазин. Предлагаю заголовок изменить на "...на примере магазина котиков". Хотя какая связь между котиками, магазином, и проверкой возраста всё ещё не ясно. Я надеюсь, у вас котики это не 18+ товар? Хотя котики это вообще не товар.

MihaOo
15.01.2025 10:33Что бы я добавил:
Финализировал бы методы в
AbstractHandlerФинализировал бы всех потомков
AbstractHandlerЕстественно объявил бы тип для
$requestДобавил бы
Directorкласс который собирал бы цепочку что бы не тянуть все эти зависимости в нужный класс тем самым захламляя конструктор. Но тут конечно по обстоятельствамТак же объявил
abstract HandlerException extends Exceptionи его потомков для каждого хэндлера и, возможно, для каждого отдельного исключительного случая. Очень удобно, если класс исключения берёт на себя ответственность за создание сообщения, это разгружает бизнес логику.
ingrain
У вас эти ifы спрятаны за setNext, если верно понимаю. И тогда в чём принципиальная разница, непонятно. Можно же этот маленький элемент логики записать ифами, и будет то же самое. А то, что вы продемонстрировали пример декомпозиции, — тоже типичный шаблон как будто из учебника
MihaOo
Как мне кажется, в таком подходе плюсом является тестируемость.
Если будет один метод и в нём много условных операторов, придётся тестировать много комбинаций входных данных для одного этого метода. Так очень легко просмотреть какой-то "edge case". Тут классы достаточно простые, написать тесты для них - легчайшее занятие.
madison2201
Тестирование каждого обработчика по отдельности легко, но тестирование всей цепочки может стать трудоёмким из-за множества зависимостей между обработчиками. Так что тут как посмотреть!
MihaOo
Согласен, да и ситуации бывают разные, но в этом и подобных примерах мы можем предположить почти наверняка что если они работают правильно по отдельности, то будут работать правильно и в цепочке. В таком случае нам после тестирования всех хендлеров скорее важно что бы они вызывались в определённом порядке.