Почему обычный 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