CQRS без фанатизма: разделяем чтение и запись
CQRS без фанатизма: разделяем чтение и запись
CQRS = Command Query Responsibility Segregation. Паттерн часто соседствует с Hexagonal Architecture и DDD.
Идея простая: запись и чтение решают разные задачи - значит, им нужны разные модели.
- Запись: бизнес-правила, инварианты, транзакции
- Чтение: скорость, удобный формат под UI, агрегация
Когда один SQL-запрос пытается обслуживать и доменные правила, и красивый список для фронта - получается «комбайн», который и медленный, и хрупкий.
Проблема классического CRUD
В типичном CRUD-приложении одна и та же модель отвечает за всё:
// Одна структура для записи И чтения - типичный CRUD
type Lesson struct {
ID int64
Title string
Content string
TrackID int64
Order int
CreatedAt time.Time
UpdatedAt time.Time
}
// Для записи нужна валидация и бизнес-правила
func (s *LessonService) Complete(ctx context.Context, userID, lessonID int64) error {
lesson, err := s.repo.GetByID(ctx, lessonID)
if err != nil { return err }
// проверяем доступ, порядок прохождения, прогресс...
// обновляем progress...
return nil
}
// Для чтения нужен JOIN с прогрессом, треком, сортировкой
func (s *LessonService) ListForUser(ctx context.Context, userID, trackID int64) ([]LessonWithProgress, error) {
// SELECT l.*, p.percent, p.completed_at, t.title as track_title
// FROM lessons l
// LEFT JOIN progress p ON p.lesson_id = l.id AND p.user_id = $1
// JOIN tracks t ON t.id = l.track_id
// WHERE l.track_id = $2
// ORDER BY l.order
// ... и это ещё простой случай
}
<?php
declare(strict_types=1);
// Одна сущность для записи И чтения - типичный CRUD
final class Lesson
{
public function __construct(
public int $id,
public string $title,
public string $content,
public int $trackId,
public int $order,
public DateTimeImmutable $createdAt,
public DateTimeImmutable $updatedAt,
) {}
}
final class LessonService
{
public function __construct(
private readonly LessonRepository $repo,
) {}
// Для записи нужна валидация и бизнес-правила
public function complete(int $userId, int $lessonId): void
{
$lesson = $this->repo->getById($lessonId);
// проверяем доступ, порядок прохождения, прогресс...
// обновляем progress...
}
// Для чтения нужен JOIN с прогрессом, треком, сортировкой
/** @return LessonWithProgress[] */
public function listForUser(int $userId, int $trackId): array
{
// SELECT l.*, p.percent, p.completed_at, t.title as track_title
// FROM lessons l
// LEFT JOIN progress p ON p.lesson_id = l.id AND p.user_id = :uid
// JOIN tracks t ON t.id = l.track_id
// WHERE l.track_id = :tid
// ORDER BY l.order
// ... и это ещё простой случай
return $this->repo->listWithProgress($trackId, $userId);
}
}
Проблемы растут с масштабом:
Признак Что болит
─────────────────────── ────────────────────────────────────
Медленные списки JOIN'ы на каждый запрос, нет кэша
Сложные миграции Изменил модель → сломал и запись, и чтение
Запутанный код Service знает и про бизнес-правила, и про UI-формат
Проблемы с производительностью Индексы для записи ≠ индексы для чтения
Что предлагает CQRS
Разделяем ответственность:
┌─────────────────┐ ┌──────────────────┐
│ Write Model │ │ Read Model │
│ │ │ │
│ - Валидация │ │ - Быстрые │
│ - Инварианты │ │ списки │
│ - Транзакции │ │ - Денормализация │
│ - Бизнес- │ │ - Формат │
│ правила │ │ под UI │
└─────────────────┘ └──────────────────┘
Поток Command и поток Query
Два независимых потока сходятся только через хранилище (или через события):
┌──────────────┐
POST /orders → │ CommandHandler│ → Domain → Repo → Write DB
└──────┬───────┘ │
│ событие │ projection
▼ ▼
(OrderCreated) ┌─────────────┐
│ │ Read DB │
└─────────────────────▶│ (denormaliz)│
└──────┬──────┘
GET /orders/123 ──────────────────────────────────────┘
▲
┌────────┴───────┐
│ QueryHandler │
└────────────────┘
Левый путь - изменение состояния: валидация, инварианты, транзакция. Правый путь - чтение: денормализованная модель под нужды UI, без бизнес-логики.
Eventual consistency: stale reads
Между записью и появлением данных в read-модели проходит время - миллисекунды, секунды, иногда минуты. Если UI сразу после POST делает GET - может получить старое состояние:
1. POST /orders → write-model: создан order #42
2. GET /orders → read-model: ещё не обновилась, order #42 не виден
3. (через 200ms) проекция применила событие
4. GET /orders → читает корректно
Способы пережить окно несогласованности:
Подход Где жмёт Когда выбирать
──────────────── ────────────────── ──────────────────────────────
Optimistic UI фронт сразу списки, ленты, нотификации;
рисует новый ошибка маловероятна → откатываем
элемент локально
Read-your-writes запрос помечается критичные UI (после оплаты:
по correlation_id версией; UI ждёт, пользователь должен видеть
пока read догонит результат сразу)
Sync projection проекция в той же MVP, малая нагрузка; теряем
транзакции главное преимущество CQRS
(Outbox-pattern)
Lambda-architecture read хитро мержит только когда других вариантов нет
«старое + дельту» - дорого по сложности
Реальный кейс: order management
Один и тот же домен - заказы интернет-магазина - естественно делится:
Write side (Command): Read side (Query):
───────────────────── ────────────────────
PlaceOrder OrderListForCustomer
- проверить остатки - card-view: id, статус, сумма,
- резервировать товар фото первого товара, ETA
- применить промокод - сортировка по дате
- создать платёж - фильтр по статусу
JOIN: orders + items + products
CancelOrder OrderDetails
- можно ли отменить - всё для страницы заказа
(по статусу/времени) - таймлайн событий
- вернуть резерв - адрес доставки, чек, payment_id
- инициировать рефанд - одна денормализованная таблица
ShipOrder DashboardForOps
- валидация маршрута - аггрегаты: orders_pending,
- назначение курьера revenue_today, avg_delivery_time
- read из materialized view, обновляется
раз в минуту
Write side оперирует малыми, согласованными кусками (один Aggregate - один Order). Read side собирает широкие, удобные проекции под конкретные экраны. Изменения в UI не задевают доменную модель и наоборот.
CQRS ≠ Event Sourcing
Их часто путают, потому что они хорошо стыкуются. Но это разные паттерны с разными решениями:
CQRS Event Sourcing
───────────────────── ──────────────────────────────
Что разделяет модель чтения и записи состояние и события (history)
Где хранит истину в write DB (текущее в event store (журнал событий -
состояние) состояние выводится reduce-ом)
Сложность умеренная высокая (миграции схем событий,
snapshot-ы, projection-rebuild)
Обязательно вместе? нет нет (CQRS можно без ES; ES
редко без CQRS)
Можно делать:
- CQRS без ES - две модели поверх обычной БД (90% случаев).
- ES без CQRS - теоретически возможно, но редко полезно: писать событиями и читать reduce-ом «на лету» = медленные чтения.
- CQRS + ES - кибератлантида: события как источник истины, проекции под чтение. Мощно, но дорого в эксплуатации.
Большинству Go-проектов нужен только CQRS (уровни 1-2). Event Sourcing - для финтеха, аудита, регулируемых доменов, где важна неизменная история.
Уровни CQRS
CQRS - это спектр, не бинарный выбор:
Уровень 0: CRUD
Одна модель на всё. Работает для простых CRUD-приложений.
Уровень 1: Разные структуры (← начни здесь)
Write model и Read model - разные Go-структуры,
но одна БД. Минимум сложности, максимум пользы.
Уровень 2: Разные таблицы
Read model в отдельной таблице или materialized view.
Обновляется асинхронно по событиям.
Уровень 3: Разные БД
Write в PostgreSQL, Read в Elasticsearch/Redis.
Для высоких нагрузок, но сложность резко растёт.
Для большинства Go-проектов достаточно уровня 1-2.
Пример: разделение моделей
// Write model - строгая, с бизнес-правилами
type CompleteLessonCmd struct {
UserID int64
LessonID int64
}
func (h *CompleteLessonHandler) Handle(ctx context.Context, cmd CompleteLessonCmd) error {
// проверяем: урок существует, пользователь имеет доступ,
// предыдущие уроки пройдены
return h.repo.MarkCompleted(ctx, cmd.UserID, cmd.LessonID)
}
// Read model - удобная, под конкретный UI
type LessonListItem struct {
LessonID int64 `json:"lesson_id"`
Title string `json:"title"`
Order int `json:"order"`
Completed bool `json:"completed"`
CompletedAt string `json:"completed_at,omitempty"`
}
func (q *LessonListQuery) Execute(ctx context.Context, trackID, userID int64) ([]LessonListItem, error) {
// Запрос оптимизирован под чтение: один SELECT из денормализованной таблицы
return q.readRepo.ListByTrack(ctx, trackID, userID)
}
<?php
declare(strict_types=1);
// Write model - строгая, с бизнес-правилами
final readonly class CompleteLessonCommand
{
public function __construct(
public int $userId,
public int $lessonId,
) {}
}
final class CompleteLessonHandler
{
public function __construct(
private readonly LessonProgressRepository $repo,
) {}
public function __invoke(CompleteLessonCommand $cmd): void
{
// проверяем: урок существует, пользователь имеет доступ,
// предыдущие уроки пройдены
$this->repo->markCompleted($cmd->userId, $cmd->lessonId);
}
}
// Read model - удобная, под конкретный UI
final readonly class LessonListItem
{
public function __construct(
public int $lessonId,
public string $title,
public int $order,
public bool $completed,
public ?string $completedAt = null,
) {}
}
final class LessonListQuery
{
public function __construct(
private readonly LessonReadRepository $readRepo,
) {}
/** @return LessonListItem[] */
public function execute(int $trackId, int $userId): array
{
// Запрос оптимизирован под чтение: один SELECT из денормализованной таблицы
return $this->readRepo->listByTrack($trackId, $userId);
}
}
В Symfony такой Handler помечают атрибутом #[AsMessageHandler] - MessageBusInterface сам найдёт его и доставит команду.
Когда CQRS избыточен
Не каждому проекту нужен CQRS:
CQRS помогает, когда: CQRS избыточен, когда:
───────────────────── ────────────────────────
Чтение и запись сильно различаются Простой CRUD без сложной логики
Нужна высокая скорость чтения Мало пользователей (< 100)
Сложные бизнес-правила Один разработчик, MVP-стадия
Разные команды работают над Данные меняются и читаются
чтением и записью одинаково
Краткая история
Термин CQRS ввёл Greg Young в 2010 году, развив идею CQS (Command-Query Separation) Бертрана Мейера. Разница:
- CQS - принцип уровня метода: метод либо возвращает данные, либо меняет состояние
- CQRS - архитектурный паттерн: отдельные модели для чтения и записи
Мини-задание
- Найди в своём проекте один endpoint, который делает и запись, и сложный SELECT для UI
- Нарисуй на бумаге, как бы выглядели две отдельные структуры: WriteModel и ReadModel
- Определи, на каком «уровне CQRS» находится твой текущий проект (0-3)
- Подумай: есть ли место, где read model ускорил бы UI?