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

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

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

Начать курс

Почему обычный CRUD перестаёт работать, когда проект растёт

Представьте: вы пишете интернет-магазин на PHP. Сначала всё просто — добавил товар, изменил цену, удалил. Но проходит полгода, и появляются новые требования: аудит всех изменений, восстановление данных на определённую дату, сложная логика скидок. Обычный CRUD (Create, Read, Update, Delete) начинает трещать по швам. База данных превращается в кашу, а каждый новый запрос клиента требует переписывания половины системы.

Знакомо? Тогда вам точно пригодятся CQRS (Command Query Responsibility Segregation) и Event Sourcing — два мощных паттерна, которые меняют подход к разработке. В этой статье мы разберём их на реальных примерах PHP, чтобы вы могли применить их в своих проектах.

Что такое CQRS и Event Sourcing простыми словами

CQRS: разделение команд и запросов

CQRS — это архитектурный паттерн, который разделяет операции на два типа: команды (изменяют состояние) и запросы (читают состояние). В классическом MVC и контроллер, и модель работают с одними и теми же сущностями. CQRS предлагает развести эти ответственности.

Представьте библиотеку: есть отдельный стол для записи книг (команды) и отдельный стол для чтения (запросы). Никто не мешает друг другу, и каждый оптимизирован под свою задачу.

Event Sourcing: история изменений вместо текущего состояния

Event Sourcing — это способ хранения данных, где вы сохраняете не текущее состояние объекта, а последовательность всех событий, которые к нему привели. Вместо одной строки "Заказ №123: статус = доставлен" вы храните: "Заказ создан → Оплачен → Отгружен → Доставлен".

Это как видеозапись вместо фотографии: вы всегда можете отмотать назад и увидеть, что произошло в любой момент.

Когда стоит применять CQRS и Event Sourcing в PHP

Эти паттерны — не серебряная пуля. Для простого блога они избыточны. Но вот сценарии, где они незаменимы:

  • Сложная доменная логика (финансы, бронирование, логистика)
  • Требования к аудиту — нужно знать, кто и когда менял данные
  • Восстановление состояния на любую дату
  • Микросервисная архитектура — события легко передавать между сервисами
  • Высокая нагрузка на чтение — можно сделать отдельную read-модель, оптимизированную для запросов

Если ваш проект — это интернет-магазин с тысячами заказов, CRM-система или банковское приложение — CQRS и Event Sourcing вам точно пригодятся.

Практическая реализация CQRS в PHP

Давайте перейдём к коду. Мы реализуем простую систему управления заказами. Начнём с CQRS.

Шаг 1: Определяем команды и запросы

Команда — это DTO (Data Transfer Object), который содержит все данные для выполнения операции. Команда никогда не возвращает данные — только изменяет состояние.

<?php
// src/Application/Command/CreateOrderCommand.php
namespace App\Application\Command;

class CreateOrderCommand
{
    public function __construct(
        public readonly string $orderId,
        public readonly string $customerId,
        public readonly array $items, // [['productId' => '...', 'quantity' => 2], ...]
        public readonly \DateTimeImmutable $createdAt
    ) {}
}

Запрос — это тоже DTO, но для чтения. Он может содержать фильтры и возвращать данные.

<?php
// src/Application/Query/GetOrderQuery.php
namespace App\Application\Query;

class GetOrderQuery
{
    public function __construct(
        public readonly string $orderId
    ) {}
}

Шаг 2: Создаём обработчики команд

Обработчик команды — это сервис, который выполняет бизнес-логику. Он принимает команду и изменяет состояние через репозиторий.

<?php
// src/Application/Handler/CreateOrderHandler.php
namespace App\Application\Handler;

use App\Application\Command\CreateOrderCommand;
use App\Domain\Order;
use App\Domain\OrderRepositoryInterface;

class CreateOrderHandler
{
    public function __construct(
        private OrderRepositoryInterface $orderRepository
    ) {}

    public function __invoke(CreateOrderCommand $command): void
    {
        $order = Order::create(
            $command->orderId,
            $command->customerId,
            $command->items,
            $command->createdAt
        );

        $this->orderRepository->save($order);
    }
}

Шаг 3: Реализуем Query Bus для чтения

Для запросов мы используем отдельный Query Bus. Он может обращаться к специализированной read-модели (например, денормализованной таблице в БД или Elasticsearch).

<?php
// src/Application/Query/GetOrderQueryHandler.php
namespace App\Application\Query;

use App\Infrastructure\ReadModel\OrderReadModel;

class GetOrderQueryHandler
{
    public function __construct(
        private OrderReadModel $orderReadModel
    ) {}

    public function __invoke(GetOrderQuery $query): array
    {
        return $this->orderReadModel->findById($query->orderId);
    }
}

Добавляем Event Sourcing: сохраняем историю изменений

Теперь усложним пример. Вместо того чтобы сохранять текущее состояние заказа, мы будем хранить все события.

События как основа доменной модели

Каждое изменение порождает событие. Событие — это неизменяемый объект, который описывает, что произошло.

<?php
// src/Domain/Event/OrderCreated.php
namespace App\Domain\Event;

class OrderCreated
{
    public function __construct(
        public readonly string $orderId,
        public readonly string $customerId,
        public readonly array $items,
        public readonly \DateTimeImmutable $occurredAt
    ) {}
}

// src/Domain/Event/OrderShipped.php
namespace App\Domain\Event;

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

Агрегат: восстанавливаем состояние из событий

Агрегат — это корневая сущность, которая применяет события к своему состоянию. Он содержит метод apply() для каждого типа событий.

<?php
// src/Domain/Order.php
namespace App\Domain;

use App\Domain\Event\OrderCreated;
use App\Domain\Event\OrderShipped;

class Order
{
    private string $orderId;
    private string $status;
    private array $events = []; // новые события, которые ещё не сохранены

    private function __construct() {}

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

    // Метод для отправки заказа
    public function ship(string $trackingNumber): void                

Блоги

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