Привет, Хабр!

Если вы когда‑нибудь пытались настроить бизнес‑логику в своём проекте так, чтобы она не выглядела как свалка if-else и работала хорошо, то этот материал для вас. Сегодня мы разберём один из самых приятных паттернов — Chain of Responsibility, или «Цепочка обязанностей».

Вместо кучи условий, которые как минимум трудно читать, а как максимум невозможно поддерживать, мы строим гибкую архитектуру. Каждый этап обработки запроса становится отдельным модулем. А благодаря возможности менять порядок этих модулей или добавлять новые, система легко масштабируется.

Используйте Chain of Responsibility, когда:

  1. Логика обработки запроса должна быть модульной.

  2. Нужно динамически менять последовательность обработки.

  3. Вы хотите облегчить добавление новых обработчиков.

Сразу перейдем к коду

Реализация паттерна на примере магазина котиков

Архитектура магазина котиков

Вот как будет выглядеть наш процесс:

  1. Проверка наличия товара.

  2. Проверка возраста покупателя.

  3. Проверка оплаты.

  4. Упаковка заказа.

Каждое из этих действий — это отдельный обработчик в цепочке.

Интерфейс обработчика

Начнём с базового интерфейса, который будут реализовывать наши обработчики.

<?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']);
    }
}

Что ещё можно улучшить?

  1. Динамическая конфигурация цепочки.
    Например, настраивать последовательность обработчиков через тот же JSON или YAML.

  2. Производительность.
    Для больших цепочек можно добавить кэширование результатов, чтобы не проходить одни и те же проверки повторно.

  3. Логирование.
    Подключаем Monolog для более подробного логирования.


Как вы заметили, паттерн упрощает сложные процессы, разбивая их на независимые шаги. А какое применение паттерну находили вы? Делитесь в комментариях!

Всем PHP-разработчикам рекомендую посетить открытый урок «Вебсокеты на PHP, или как написать свой чат», который пройдет 22 января в Otus. Записаться можно по ссылке.

Комментарии (4)


  1. ingrain
    15.01.2025 10:33

    У вас эти ifы спрятаны за setNext, если верно понимаю. И тогда в чём принципиальная разница, непонятно. Можно же этот маленький элемент логики записать ифами, и будет то же самое. А то, что вы продемонстрировали пример декомпозиции, — тоже типичный шаблон как будто из учебника


    1. MihaOo
      15.01.2025 10:33

      Как мне кажется, в таком подходе плюсом является тестируемость.

      Если будет один метод и в нём много условных операторов, придётся тестировать много комбинаций входных данных для одного этого метода. Так очень легко просмотреть какой-то "edge case". Тут классы достаточно простые, написать тесты для них - легчайшее занятие.


  1. Ascard
    15.01.2025 10:33

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


  1. MihaOo
    15.01.2025 10:33

    Что бы я добавил:

    1. Финализировал бы методы в AbstractHandler

    2. Финализировал бы всех потомков AbstractHandler

    3. Естественно объявил бы тип для $request

    4. Добавил бы Director класс который собирал бы цепочку что бы не тянуть все эти зависимости в нужный класс тем самым захламляя конструктор. Но тут конечно по обстоятельствам

    5. Так же объявил abstract HandlerException extends Exception и его потомков для каждого хэндлера и, возможно, для каждого отдельного исключительного случая. Очень удобно, если класс исключения берёт на себя ответственность за создание сообщения, это разгружает бизнес логику.