CQRS и Event Sourcing в PHP: Архитектура для сложных систем

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Когда обычный CRUD перестаёт работать: знакомство с CQRS и Event Sourcing

Вы когда-нибудь сталкивались с ситуацией, когда ваше PHP-приложение росло, росло... и в один прекрасный день обычный UserController превращался в монстра на 2000 строк? Знакомо? Баги плодятся, производительность падает, а добавление новой фичи вызывает нервный тик у всей команды. Поздравляю, вы доросли до момента, когда пора внедрять серьёзные архитектурные паттерны.

Сегодня мы поговорим о двух мощнейших инструментах — CQRS (Command Query Responsibility Segregation) и Event Sourcing. Не пугайтесь сложных терминов. На самом деле, это просто элегантные способы сказать: "Давайте перестанем мешать чтение и запись данных в одну кучу" и "Будем хранить не текущее состояние, а историю изменений".

Эта статья для PHP-разработчиков, которые уже освоили основы ООП и хотят поднять свои проекты на новый уровень. Вы узнаете, как применять CQRS и Event Sourcing на практике, увидите реальный код и поймёте, когда эти паттерны действительно нужны, а когда от них лучше отказаться.

Что такое CQRS? Разделяем команды и запросы

Представьте, что ваш код — это ресторан. Обычно официант (контроллер) принимает заказ (запрос на запись), бежит на кухню (база данных), готовит блюдо (обрабатывает данные) и тут же его подаёт (возвращает ответ). Всё прекрасно, пока в ресторане 10 человек. Но когда приходит 1000 гостей, кухня начинает задыхаться.

CQRS (Command Query Responsibility Segregation) предлагает разделить ответственность: одни повара (команды) только готовят, другие официанты (запросы) только подают. Технически это означает, что вы используете разные модели для чтения и записи данных.

Базовая реализация CQRS на PHP

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

// Команда — это DTO с данными для изменения состояния
class CreateUserCommand {
    public function __construct(
        public readonly string $email,
        public readonly string $name
    ) {}
}

// Обработчик команды — здесь происходит бизнес-логика
class CreateUserHandler {
    public function __construct(
        private UserRepository $repository,
        private EventDispatcher $dispatcher
    ) {}

    public function handle(CreateUserCommand $command): void {
        $user = new User($command->email, $command->name);
        $this->repository->save($user);
        $this->dispatcher->dispatch(new UserCreatedEvent($user->getId()));
    }
}

// Запрос — это DTO для получения данных
class GetUserQuery {
    public function __construct(
        public readonly int $userId
    ) {}
}

// Обработчик запроса — только чтение, никаких изменений
class GetUserHandler {
    public function __construct(
        private UserReadRepository $readRepository
    ) {}

    public function handle(GetUserQuery $query): ?UserDTO {
        return $this->readRepository->findById($query->userId);
    }
}

Обратите внимание: у нас два разных репозитория. UserRepository отвечает за запись (может использовать сложную бизнес-логику, валидацию), а UserReadRepository — за чтение (может быть простым SQL-запросом или даже денормализованной таблицей).

Командная шина (Command Bus)

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

// Простейшая реализация Command Bus
class SimpleCommandBus {
    public function __construct(
        private array $handlers = []
    ) {}

    public function registerHandler(string $commandClass, object $handler): void {
        $this->handlers[$commandClass] = $handler;
    }

    public function dispatch(object $command): void {
        $handler = $this->handlers[$command::class] 
            ?? throw new \RuntimeException('No handler for ' . $command::class);
        $handler->handle($command);
    }
}

// Использование
$bus = new SimpleCommandBus();
$bus->registerHandler(CreateUserCommand::class, new CreateUserHandler($repo, $dispatcher));
$bus->dispatch(new CreateUserCommand('user@example.com', 'John Doe'));

Существуют отличные библиотеки, которые делают это за вас: tactician, league/tactician или встроенный Message Bus в Symfony Messenger.

Event Sourcing: Храним историю, а не состояние

Теперь переходим ко второму паттерну. Event Sourcing — это подход, при котором вы сохраняете не текущее состояние объекта, а последовательность событий, которые к этому состоянию привели.

Представьте банковский счёт. Вместо того чтобы хранить "баланс = 1000 рублей", вы храните: "открытие счёта (+500)", "зарплата (+700)", "покупка кофе (-200)". Чтобы узнать текущий баланс, вы просто "воспроизводите" все события.

Реализация Event Sourcing в PHP

Давайте создадим простой агрегат (корень агрегации в терминах DDD) для управления заказами.

// Базовое событие
interface DomainEvent {
    public function occurredAt(): \DateTimeImmutable;
}

// Конкретное событие
class OrderCreated implements DomainEvent {
    public function __construct(
        public readonly string $orderId,
        public readonly string $customerEmail,
        public readonly \DateTimeImmutable $occurredAt = new \DateTimeImmutable()
    ) {}

    public function occurredAt(): \DateTimeImmutable {
        return $this->occurredAt;
    }
}

class OrderShipped implements DomainEvent {
    public function __construct(
        public readonly string $orderId,
        public readonly string $trackingNumber,
        public readonly \DateTimeImmutable $occurredAt = new \DateTimeImmutable()
    ) {}

    public function occurredAt(): \DateTimeImmutable {
        return $this->occurredAt;
    }
}

// Агрегат — восстанавливается из событий
class Order {
    private string $id;
    private string $status;
    private array $recordedEvents = [];

    private function __construct() {}

    // Фабричный метод — создаёт новый заказ и записывает событие
    public static function create(string $orderId, string $customerEmail): self {
        $order = new self();
        $order->recordThat(new OrderCreated($orderId, $customerEmail));
        return $order;
    }

    // Команда для изменения состояния
    public function ship(string $trackingNumber): void {
        // Бизнес-логика: нельзя отправить уже отправленный заказ
        if ($this->status === 'shipped') {
            throw new \RuntimeException('Order already shipped');
        }
        $this->recordThat(new OrderShipped($this->id, $trackingNumber));
    }

    // Внутренний метод — применяет событие к агрегату
    private function apply(DomainEvent $event): void {
        match ($event::class) {
            OrderCreated::class => function(OrderCreated $e) {
                $this->id = $e->orderId;
                $this->status = 'pending';
            },
            OrderShipped::class => function(OrderShipped $e) {
                $this->status = 'shipped';
            },
        };
    }

    // Запись события + немедленное применение
    private function recordThat(DomainEvent $event): void {
        $this->apply($event);
        $this->recordedEvents[] = $event;
    }

    // Получить все незафиксированные события
    public function releaseEvents(): array {
        $events = $this->recordedEvents;
        $this->recordedEvents = [];
        return $events;                

Блоги

Рекомендаций по книгам