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

Разделяем ответственность:

CRUD vs CQRS: одна модель тянет всё против пары моделей под задачу

┌─────────────────┐     ┌──────────────────┐
│  Write Model    │     │   Read Model     │
│                 │     │                  │
│ - Валидация    │     │ - Быстрые       │
│ - Инварианты   │     │    списки        │
│ - Транзакции   │     │ - Денормализация │
│ - Бизнес- │     │ - Формат        │
│    правила      │     │    под UI        │
└─────────────────┘     └──────────────────┘

Поток Command и поток Query

Два независимых потока сходятся только через хранилище (или через события):

Поток Command и Query: команда идёт в Write DB, проекция обновляет Read DB, запрос читает оттуда

                   ┌──────────────┐
   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 хитро мержит       только когда других вариантов нет
                      «старое + дельту» - дорого по сложности
Eventual consistency означает «сходится через короткое время», не «когда-нибудь». Если ваша read-модель не догоняет за минуты - это **поломка**, не норма. Мониторь lag проекции (события в очереди, age последнего применённого события) через [метрики Prometheus](../observability/02-prometheus.md).

Реальный кейс: 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 - для финтеха, аудита, регулируемых доменов, где важна неизменная история.

Можно разделить модели даже в одном сервисе и одной БД. Не обязательно сразу микросервисы, Kafka и свечи по вечерам. Начни с двух разных структур - одна для записи, другая для чтения.

Уровни 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 «на будущее». Начни с простого CRUD. Когда почувствуешь, что модели чтения и записи тянут в разные стороны - тогда разделяй. Преждевременная абстракция хуже, чем её отсутствие.

Краткая история

Термин CQRS ввёл Greg Young в 2010 году, развив идею CQS (Command-Query Separation) Бертрана Мейера. Разница:

  • CQS - принцип уровня метода: метод либо возвращает данные, либо меняет состояние
  • CQRS - архитектурный паттерн: отдельные модели для чтения и записи

Мини-задание

  • Найди в своём проекте один endpoint, который делает и запись, и сложный SELECT для UI
  • Нарисуй на бумаге, как бы выглядели две отдельные структуры: WriteModel и ReadModel
  • Определи, на каком «уровне CQRS» находится твой текущий проект (0-3)
  • Подумай: есть ли место, где read model ускорил бы UI?

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.