Когда обычный 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;