Опирается на правила: R-CQRS-TIER-1R-CQRS-TIER-5 и R-CQRS-TIER-X1R-CQRS-TIER-X2 из CQRS Style Guide → раздел 6. Уровень и эволюция.

Важно знать

  • Уровень 1 (плоский handler → repository): CQRS не применяется. Маркеров нет.
  • Уровень 2 (Use Case Pattern): маркеры Command / Query обязательны. Один <X>Repository, read через pgx.TxOptions{AccessMode: pgx.ReadOnly}.
  • Уровень 3 split (DDD + Hexagonal): отдельный <X>ViewRepository-интерфейс с read-DTO; write — <X>Repository с агрегатом.
  • Уровень 3 event-driven: read-model в отдельной таблице / Redis / ES, sync через outbox + segmentio/kafka-go.
  • Маркеры без enforcement (pgx.ReadOnly на read-side) — карго-культ (R-CQRS-TIER-X1).
  • Event-driven read-model с одним <X>Repository для R+W — несостыковка уровней (R-CQRS-TIER-X2).
  • Эволюция строго снизу вверх: 1 → 2 → 3-split → 3-event-driven. Откат — признак ошибки планирования.

CQRS — не «всё или ничего», а шкала. На каждом уровне берётся ровно столько, сколько даёт ощутимую пользу при текущем объёме и нагрузке. Стартовать с event-driven read-model на сервисе без доказанной read-боли означает платить инфраструктурную стоимость за несуществующую проблему. Раскрытие правил R-CQRS-TIER-* ниже.

Уровень 1 — CQRS не применяется

R-CQRS-TIER-1: на Уровне 1 (плоский service-handler → repository) CQRS не используется. Маркерных интерфейсов нет, один repository, транзакции одинаковые для read и write.

// internal/order/service.go — Уровень 1, без маркеров
type OrderService struct {
    db *pgxpool.Pool
}

func (s *OrderService) CreateOrder(ctx context.Context, customerID string, items []string) (string, error) {
    // ...write path...
}

func (s *OrderService) GetOrder(ctx context.Context, id string) (OrderDTO, error) {
    // ...read path, та же транзакционная стратегия...
}

Уровень 1 — внутренние утилиты, тонкие proxy, CRUD-сервисы без явной бизнес-домены. Вводить Command/Query-маркеры здесь не нужно.

Уровень 2 — lightweight CQRS обязателен

R-CQRS-TIER-2: на Уровне 2 маркеры Command / Query обязательны. Маркер — пустой интерфейс с неэкспортируемым методом (пакетный замок исключает случайную реализацию):

// core/cqrs/cqrs.go
package cqrs

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

type CreateOrder struct {
    CustomerID string
    Items      []string
}

func (CreateOrder) isCommand() {}

// core/order/query/get_order_summary.go
package query

type GetOrderSummary struct {
    OrderID string
}

func (GetOrderSummary) isQuery() {}

Read и write идут через один и тот же <X>Repository, но с разными транзакционными стратегиями.

Command-handler: pgx.TxOptions{} (rw-транзакция по умолчанию):

// core/order/handler/create_order_handler.go
type CreateOrderHandler struct {
    orders OrderRepository
    uow    UnitOfWork
}

func (h *CreateOrderHandler) Handle(ctx context.Context, cmd command.CreateOrder) (string, error) {
    var id string
    err := h.uow.Within(ctx, func(ctx context.Context) error {
        order := NewOrder(cmd.CustomerID, cmd.Items)
        if err := h.orders.Save(ctx, order); err != nil {
            return fmt.Errorf("save order: %w", err)
        }
        id = order.ID
        return nil
    })
    return id, err
}

Query-handler: pgx.TxOptions{AccessMode: pgx.ReadOnly} — enforcement маркера. Без pgx.ReadOnly маркер Query ничего не гарантирует (R-CQRS-TIER-X1):

// core/order/handler/get_order_summary_handler.go
type GetOrderSummaryHandler struct {
    orders OrderRepository
    db     *pgxpool.Pool
}

func (h *GetOrderSummaryHandler) Handle(ctx context.Context, q query.GetOrderSummary) (OrderSummaryDTO, error) {
    tx, err := h.db.BeginTx(ctx, pgx.TxOptions{AccessMode: pgx.ReadOnly})
    if err != nil {
        return OrderSummaryDTO{}, fmt.Errorf("begin read tx: %w", err)
    }
    defer tx.Rollback(ctx)

    summary, err := h.orders.SummaryByID(ctx, q.OrderID)
    if err != nil {
        return OrderSummaryDTO{}, fmt.Errorf("read summary %s: %w", q.OrderID, err)
    }
    return summary, nil
}

Единый OrderRepository на Уровне 2 — это упрощение, а не недостаток. Выгода от разделения интерфейсов на этом уровне незначительна, стоимость — преждевременная сложность.

// core/order/port/out/order_repository.go — Уровень 2: один интерфейс
type OrderRepository interface {
    ByID(ctx context.Context, id string) (*Order, error)
    SummaryByID(ctx context.Context, id string) (OrderSummaryDTO, error)
    Save(ctx context.Context, o *Order) error
}

pgx на попытке write в ReadOnly-транзакции вернёт ошибку — механический enforcement без дополнительного кода.

Уровень 3 split — отдельный ViewRepository

R-CQRS-TIER-3: на Уровне 3 (DDD + Hexagonal) появляется явное разделение интерфейсов. <X>Repository — write: агрегат, rw-транзакция. <X>ViewRepository — read: read-DTO, pgx.ReadOnly.

// core/order/port/out/order_repository.go
type OrderRepository interface {
    ByID(ctx context.Context, id string) (*Order, error)
    Save(ctx context.Context, o *Order) error
}

// core/order/port/out/order_view_repository.go
type OrderViewRepository interface {
    SummaryByID(ctx context.Context, id string) (view.OrderSummaryDTO, error)
    ListByCustomer(ctx context.Context, customerID string, p Pagination) ([]view.OrderSummaryDTO, error)
}

Read-DTO — самостоятельные структуры в core/<bc>/dto/view/, форма подчинена API, не агрегату:

// core/order/dto/view/order_summary.go
package view

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

sqlc генерирует отдельные файлы запросов под каждый интерфейс. Если оба интерфейса реализует один адаптер — это деталь persistence-слоя, в ядро не просачивается.

Query-handler обращается к OrderViewRepository, не к OrderRepository:

// core/order/handler/get_order_summary_handler.go
type GetOrderSummaryHandler struct {
    views OrderViewRepository
    db    *pgxpool.Pool
}

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)

    summary, err := h.views.SummaryByID(ctx, q.OrderID)
    if err != nil {
        return view.OrderSummaryDTO{}, fmt.Errorf("read summary %s: %w", q.OrderID, err)
    }
    return summary, nil
}

Read и write по-прежнему используют одно физическое хранилище — PostgreSQL. Разделение пока только на уровне типов и интерфейсов, не инфраструктуры.

Уровень 3 event-driven — отдельное хранилище

R-CQRS-TIER-4: следующий шаг, когда нагрузки или паттерны чтения требуют отдельной инфраструктуры. Read-model переезжает в денормализованную PG-таблицу, Redis-Hash (go-redis/v9) или ES-индекс, синхронизируется через outbox + segmentio/kafka-go.

write-side:                         read-side:
  PostgreSQL                          order_summary (PG-таблица / Redis)
  ├── orders (агрегат)                ├── денормализованная схема
  └── outbox                          └── индексы под query-паттерны
       ↓
  outbox-relay (goroutine / cron)
       ↓
  Kafka (order.events)
       ↓
  read-side consumer
       ↓
  UPSERT order_summary

OrderViewRepository читает из order_summary, а не из orders. Интерфейс не меняется — меняется реализация адаптера:

// adapters/out/persistence/order_summary_repository.go
func (r *OrderSummaryRepository) SummaryByID(ctx context.Context, id string) (view.OrderSummaryDTO, error) {
    row, err := r.queries.GetOrderSummary(ctx, id)   // sqlc → order_summary
    if err != nil {
        if errors.Is(err, pgx.ErrNoRows) {
            return view.OrderSummaryDTO{}, apperr.New(apperr.NotFound, "order summary not found")
        }
        return view.OrderSummaryDTO{}, fmt.Errorf("get order summary: %w", err)
    }
    return toOrderSummaryDTO(row), nil
}

Consumer пишет в order_summary через idempotent UPSERT с processed_event-проверкой (R-CQRS-SYNC-2). Rebuild-команда (cmd/rebuild/main.go) обходит агрегаты и пересчитывает read-model при бутстрапе или disaster recovery (R-CQRS-RM-4).

Стоимость перехода:

  • Eventual consistency (100ms–2s в норме).
  • Новые failure modes: lag consumer, stuck outbox, drift между write и read.
  • Дополнительные runtime-компоненты: outbox-relay goroutine, Kafka consumer, мониторинг lag.

Переход оправдан, когда write-сторона страдает от read-нагрузки или read-проекция фундаментально другая (full-text search, аналитические сводки). До этого порога read-replica + кеш решают дешевле.

Эволюция строго снизу вверх

R-CQRS-TIER-5: движение по уровням — строго 1 → 2 → 3-split → 3-event-driven. Каждый переход обоснован метриками, новыми требованиями или фактической болью — не «потому что так принято в архитектурных статьях».

Типичный путь сервиса управления заказами:

  1. Уровень 1 — стартовали как внутренний CRUD без явной бизнес-домены.
  2. Появился домен «Заказ» с инвариантами — перешли на Уровень 2: CreateOrder/ConfirmOrder как Command, GetOrderSummary как Query, маркеры + pgx.ReadOnly на read-handler-ах.
  3. Команды продукта запросили сложные проекции (история транзакций, сводки по клиенту) — перешли к Уровню 3 split: выделили OrderViewRepository с отдельными sqlc-запросами под UI.
  4. p95 latency list-запросов пробил SLA при росте нагрузки — перешли к Уровню 3 event-driven: order_summary как отдельная денормализованная таблица, sync через outbox + Kafka.

Возврат назад случается при слиянии сервисов или упрощении продукта — это редкость, не нормальный рефакторинг.

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

АнтипаттернПравилоЧто взамен
Маркеры Command/Query без pgx.ReadOnly на read-handler-еR-CQRS-TIER-X1Либо полный переход на Уровень 2 с pgx.ReadOnly, либо убрать маркеры
Event-driven read-model (order_summary) с единым OrderRepository для R+WR-CQRS-TIER-X2Отдельный OrderViewRepository-интерфейс
Прыжок Уровень 1 → Уровень 3 event-driven без промежуточных шаговR-CQRS-TIER-5Эволюция по метрикам: 1 → 2 → 3-split → 3-event-driven
pgx.TxOptions{} (rw) на query-handler-е вместо pgx.ReadOnlyR-CQRS-TIER-2pgx.TxOptions{AccessMode: pgx.ReadOnly} обязательно
Read-методы (SummaryByID, ListByCustomer) в основном OrderRepository на Уровне 3R-CQRS-QRY-X2Перенести в OrderViewRepository

Куда дальше

  • Когда CQRS оправдан — пороги перехода между уровнями в Go-идиомах.
  • Command side — write-handler: UnitOfWork, pgx.Tx через context, (string, error).
  • Query side — read-handler с OrderViewRepository и pgx.ReadOnly.
  • Read-model — денормализованная PG-таблица, Redis-Hash, rebuild-команда.
  • Sync через события — outbox + segmentio/kafka-go, idempotent consumer.