Опирается на правила:
R-CQRS-TIER-1…R-CQRS-TIER-5иR-CQRS-TIER-X1…R-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 — стартовали как внутренний CRUD без явной бизнес-домены.
- Появился домен «Заказ» с инвариантами — перешли на Уровень 2:
CreateOrder/ConfirmOrderкакCommand,GetOrderSummaryкакQuery, маркеры +pgx.ReadOnlyна read-handler-ах. - Команды продукта запросили сложные проекции (история транзакций, сводки по клиенту) — перешли к Уровню 3 split: выделили
OrderViewRepositoryс отдельными sqlc-запросами под UI. - 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+W | R-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.ReadOnly | R-CQRS-TIER-2 | pgx.TxOptions{AccessMode: pgx.ReadOnly} обязательно |
Read-методы (SummaryByID, ListByCustomer) в основном OrderRepository на Уровне 3 | R-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.