Опирается на правила: R-CQRS-WHEN-1R-CQRS-WHEN-3 и R-CQRS-WHEN-X1R-CQRS-WHEN-X2 из CQRS Style Guide → раздел 1. Когда CQRS оправдан.

Важно знать

  • CQRS — паттерн с ценой: отдельные handler-интерфейсы, отдельная синхронизация, eventual consistency. Применяем когда выгода покрывает цену, не «потому что красиво».
  • Lightweight CQRS (маркеры Command/Query без разделения хранилищ) — бесплатное разделение, обязательно начиная с Уровня 2.
  • В Go маркер — пустой интерфейс с неэкспортируемым методом (isCommand() / isQuery()); пакетный замок гарантирует, что посторонний тип не реализует его случайно.
  • Read-only транзакция pgx (pgx.TxOptions{AccessMode: pgx.ReadOnly}) — enforcement lightweight CQRS: runtime падает на попытке write в read-only tx, без дополнительных проверок.
  • Денормализованная read-таблица в той же БД — middle-ground: один PostgreSQL, отдельный OrderViewRepository, синхронизация через outbox + segmentio/kafka-go внутри сервиса.
  • Full CQRS с разделением хранилищ (PG + Redis / ElasticSearch / read-DB) — только при read:write ratio ≥ 10:1, принципиально иной структуре read-проекции или необходимости масштабировать read независимо от write.
  • Full CQRS «just in case» для нового сервиса без явной нагрузочной проблемы — карго-культ. Стартуем с lightweight, эволюционируем по метрикам.
  • Разделение баз без измеренной боли добавляет sync complexity, eventual consistency и инфра-стоимость. «У нас будет много чтения» — не достаточная причина.

CQRS (Command Query Responsibility Segregation) — разделение модели записи и модели чтения. В минимальной форме — два маркерных интерфейса и read-only транзакция pgx. В максимальной — два физически разных хранилища с синхронизацией через Kafka. Между крайностями — спектр, и выбор точки зависит от нагрузки, не от моды. Статья раскрывает раздел 1 Go CQRS Style Guide.

Три уровня CQRS

R-CQRS-WHEN-1..3 описывают три точки спектра — все три валидны, но в разных контекстах:

УровеньЧто разделеноКогда применять
Lightweight (маркеры)Command/Query-интерфейсы; read-методы в pgx.ReadOnly-транзакцииУровень 2+ — всегда
Read-projection в той же БДОтдельная таблица order_summary, отдельный OrderViewRepositoryJOIN'ы 5+ таблиц, тяжёлые GROUP BY
Full splitРазные хранилища (PG + ElasticSearch / Redis / read-DB)read:write ≥ 10:1, поиск / аналитика

Эволюция — строго снизу вверх: сначала маркеры, потом отдельная таблица, потом отдельное хранилище. Назад движение обычно говорит о неверном первоначальном решении.

Уровень 1: Lightweight на маркерах — обязательно с Уровня 2

R-CQRS-WHEN-1: на Уровне 2 маркерное разделение обязательно. Это не стоит ничего и даёт enforcement через типы и pgx.

// core/cqrs/cqrs.go
package cqrs

type Command interface{ isCommand() }
type Query   interface{ isQuery()   }
// core/order/command/confirm_order.go
package command

import "core/cqrs"

type ConfirmOrder struct {
    OrderID string
}

func (ConfirmOrder) isCommand() {}
// core/order/query/get_order_summary.go
package query

import "core/cqrs"

type GetOrderSummary struct {
    OrderID string
}

func (GetOrderSummary) isQuery() {}

Неэкспортируемый метод (isCommand, isQuery) — пакетный замок: только пакет cqrs может неявно реализовать интерфейс, посторонний тип снаружи не пройдёт.

Что даёт разделение:

  • Компилятор различает read и write. ConfirmOrderHandler и GetOrderSummaryHandler — разные типы, их не поменяешь местами.
  • Read-only транзакция pgx на query-handler-е — не аннотация, а runtime-гарантия. PostgreSQL отклонит любой INSERT/UPDATE в read-only tx без дополнительных проверок в коде.
  • Валидация разная. Command — go-playground/validator с бизнес-ограничениями; query — обычно только min/max на page/size.
  • Метрики легче. app_command_duration{handler="ConfirmOrder"}app_query_duration{handler="GetOrderSummary"} — RED-метрики разделимы по семантике, не нужен общий bucket «запросов».

Read и write при этом ходят в один и тот же OrderRepository. Никакой дополнительной инфраструктуры — только дисциплина в типах и транзакционных опциях.

// Уровень 2: один репозиторий, две стратегии транзакции
type OrderRepository interface {
    ByID(ctx context.Context, id string) (*Order, error)
    SummaryByID(ctx context.Context, id string) (view.OrderSummaryDTO, error)
    Save(ctx context.Context, o *Order) error
}
// Query handler: read-only транзакция pgx
func (h *GetOrderSummaryHandler) Handle(ctx context.Context, q query.GetOrderSummary) (view.OrderSummaryDTO, error) {
    tx, err := h.db.BeginTx(ctx, pgx.TxOptions{AccessMode: pgx.ReadOnly})
    if err != nil {
        return view.OrderSummaryDTO{}, fmt.Errorf("begin read tx: %w", err)
    }
    defer tx.Rollback(ctx)

    return h.orders.SummaryByID(ctx, q.OrderID)
}

Уровень 2: Денормализованная read-таблица в той же БД — middle-ground

R-CQRS-WHEN-3: когда query становится дорогим даже на read-replica, выделяем read-projection в отдельную таблицу той же БД. Не отдельное хранилище — до этого ещё рано.

Триггеры для перехода:

  • 5+ JOIN'ов в типичном query. Например, OrderSummary собирается из orders, order_items, products, customers, payments — каждый запрос грузит CPU и память.
  • Тяжёлые aggregation'ы. GROUP BY customer_id, DATE(created_at) по миллионам строк для аналитики дашборда в Sber Business — каждая загрузка стоит секунду CPU.
  • Несовпадение структуры. UI хочет customer_name, total_items, last_status_change одним запросом — и собирать это каждый раз лишняя работа. Денормализуем.

Реализация: отдельная таблица order_summary с денормализованными полями, отдельный OrderViewRepository-интерфейс, sqlc генерирует query-файлы под него независимо от write-стороны:

-- read-side: независимая таблица, без FK к write-схеме
CREATE TABLE order_summary (
    order_id      uuid PRIMARY KEY,
    customer_id   uuid NOT NULL,
    customer_name text NOT NULL,
    status        text NOT NULL,
    item_count    int  NOT NULL,
    total_amount  bigint NOT NULL,
    updated_at    timestamptz NOT NULL
);
CREATE INDEX ix_order_summary_customer ON order_summary (customer_id, updated_at DESC);
// Уровень 3: отдельный интерфейс для read-проекции (R-CQRS-TIER-3)
type OrderRepository interface {
    ByID(ctx context.Context, id string) (*Order, error)
    Save(ctx context.Context, o *Order) error
}

type OrderViewRepository interface {
    SummaryByID(ctx context.Context, id string) (view.OrderSummaryDTO, error)
    ListByCustomer(ctx context.Context, customerID string, page Pagination) ([]view.OrderSummaryDTO, error)
}
// core/order/dto/view/order_summary.go
package view

type OrderSummaryDTO struct {
    OrderID      string
    CustomerName string
    TotalAmount  int64
    Status       string
    ItemCount    int
    UpdatedAt    time.Time
}

GET /customers/{id}/orders теперь — один SELECT по индексу, без JOIN'ов. Синхронизация write → read — через outbox + segmentio/kafka-go (см. Sync через события).

Уровень 3: Full CQRS с разделением хранилищ — только при метриках

R-CQRS-WHEN-2: full CQRS оправдан только при измеренной проблеме одного из четырёх типов:

  1. Read:write ratio ≥ 10:1. Типичный e-commerce: 1 заказ → 10+ показов карточки в разных сценариях (история, поиск, аналитика, чек, push). При таком соотношении read-нагрузка диктует архитектуру.
  2. Принципиально иная структура. Full-text search по описаниям Product, фильтры по 20 фасетам, ранжирование по релевантности — это не реляционная задача. ElasticSearch с inverted index решает её на порядок эффективнее, чем PostgreSQL с pg_trgm.
  3. Read-нагрузка превышает throughput write-DB. PostgreSQL выдерживает 5k write/s, но 50k read/s того же объёма данных требует отдельной read-инфраструктуры.
  4. Независимое масштабирование read. Write-DB не должна страдать от read-нагрузки; Redis cluster / ElasticSearch масштабируется горизонтально, PostgreSQL — нет.

Инфраструктура full CQRS:

  • Write: PostgreSQL с агрегатом Order, FOR UPDATE на load, outbox-таблица.
  • Outbox-relay: segmentio/kafka-go producer публикует order.confirmed, order.shipped.
  • Read: ElasticSearch индекс orders с денормализованной структурой; consumer обновляет документ при каждом событии.
  • API: POST /orders → command handler в PG; GET /orders?q=... → query handler в ElasticSearch.
// edge/order_handler.go — chi router
r.Post("/orders",         h.createOrder)     // write → PG
r.Get("/orders",          h.searchOrders)    // read  → ElasticSearch
r.Get("/orders/{id}",     h.getOrderSummary) // read  → order_summary (PG read-model)

Это дорогая инфраструктура: два хранилища, мониторинг обоих, eventual consistency, rebuild-утилиты, сценарии восстановления. Окупается только когда read-replica + кеш уже не справляются.

Что запрещено

Full CQRS «just in case» для нового сервиса

R-CQRS-WHEN-X1: новый сервис стартует с lightweight маркеров, не с разделения хранилищ. Ты не знаешь, какие будут реальные паттерны нагрузки.

// Новый сервис управления заявками в Customer.
// Команда потратила месяц на ElasticSearch + Kafka consumer + rebuild-утилиту.
// Спустя полгода: 300 заявок в день, read-нагрузка 3 req/s.
// Реальный объём покрывался бы одним PG + lightweight маркерами.
// Вместо этого: расследования рассинхронизации, поддержка двух хранилищ,
// on-call по Kafka lag — без бизнес-выгоды.

Делай наоборот: lightweight на старте, измеряй p95 latency и CPU PostgreSQL, эволюционируй когда метрики пробьют SLA.

Разделение баз без явной причины

R-CQRS-WHEN-X2: «у нас будет много чтения» — не причина. Должна быть измеренная боль:

  • p95 query latency растёт линейно с числом записей и пробил SLA.
  • CPU PostgreSQL на 80%+ от read-нагрузки, write деградирует.
  • Бизнес добавил функционал (поиск по тексту, аналитическая витрина), который реляционка не тянет.

До этого порога read-replica PostgreSQL и кеш покрывают 90% случаев. CQRS-сплит — последний шаг, не первый.

Что запрещено — таблица

АнтипаттернПравилоЧто взамен
Full CQRS «just in case» для нового сервисаR-CQRS-WHEN-X1lightweight маркеры на старте, эволюция по метрикам
Разделение баз без измеренной причиныR-CQRS-WHEN-X2read-replica + кеш до явной боли
Lightweight маркеры пропущены на Уровне 2R-CQRS-WHEN-1Command/Query-интерфейсы с isCommand()/isQuery()
Уровень 3 без <X>ViewRepositoryR-CQRS-TIER-X2отдельный интерфейс под read-проекцию
Маркеры без pgx.ReadOnly на queryR-CQRS-TIER-X1pgx.TxOptions{AccessMode: pgx.ReadOnly} — enforcement обязателен

Куда дальше

  • CQRS → раздел 1. Когда CQRS оправдан — нормативные формулировки R-CQRS-WHEN-*.
  • Command side — как устроен write-handler с маркером Command и UnitOfWork.
  • Query side — read-handler с Query-маркером и OrderViewRepository.
  • Read-model — где хранить и как обновлять денормализованную проекцию.
  • Sync через события — outbox + segmentio/kafka-go, idempotent consumer.
  • Уровень и эволюция — переход Tier 2 → 3 → 4 по метрикам.