Опирается на правила:
R-CQRS-CMD-1…R-CQRS-CMD-5иR-CQRS-CMD-X1…R-CQRS-CMD-X3из CQRS Rules → раздел 2. Command side.
Важно знать
- Command — иммутабельный
struct, реализует маркер-интерфейсcqrs.Commandчерез неэкспортируемый методisCommand().- Один command меняет один агрегат. Если меняются два — это либо saga, либо неверно нарезаны границы агрегатов.
- Command-handler открывает RW-транзакцию pgx через
UnitOfWork, загружает агрегат, вызывает доменный метод, сохраняет.- Возвращает минимум: id изменённой сущности, статус-код или
struct{}. Никаких read-DTO.- Чтение внутри command — только
ByIDдля загрузки агрегата. ОтдельныйSELECT«прочитать и решить» — нарушениеR-CQRS-CMD-X1.- Валидация: контракт входа через
go-playground/validatorна DTO-слое; бизнес-инварианты — метод агрегата, возвращаетerror-значение.- Изменение нескольких агрегатов без саги — нарушение
R-CQRS-CMD-X3.
Command — это намерение изменить состояние. Всё, что меняет данные, едёт через command-handler; всё, что читает — через query-handler. Граница жёсткая: RW-транзакция pgx открывается на handler-е, не на репозитории.
Command — struct с маркером
R-CQRS-CMD-1: command — иммутабельный struct, реализует интерфейс cqrs.Command пакетным замком.
// 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
IdempotencyKey string
}
func (ConfirmOrder) isCommand() {}
Что важно:
- Неэкспортируемый метод — пакетный замок. Реализовать
isCommand()может только пакетcommandвнутриcore/order. Случайная реализация из другого пакета не скомпилируется. Go не имеет sealed-типов, поэтому замок — единственный рабочий паттерн. - Struct без методов с логикой. Command — данные, не поведение. Mapping из HTTP-запроса происходит в edge-handler-е (chi-роутер), не здесь.
IdempotencyKey— стандартное поле для неидемпотентных операций. Edge-handler берёт его из заголовкаIdempotency-Key.
Command меняет один агрегат
R-CQRS-CMD-2: одна команда — одно изменение одного агрегата. Если в бизнес-логике затрагиваются два — ситуация одна из двух:
- Saga: явная state-machine из нескольких локальных команд с компенсациями.
- Неверные границы: два «агрегата», которые всегда меняются вместе — это один агрегат.
// core/order/handler/create_order_handler.go — ПЛОХО
func (h *CreateOrderHandler) Handle(ctx context.Context, cmd command.CreateOrder) (string, error) {
var orderID string
err := h.uow.Within(ctx, func(ctx context.Context) error {
customer, err := h.customers.ByID(ctx, cmd.CustomerID) // ← загружает Customer
if err != nil {
return err
}
customer.IncrementOrderCount() // ← мутирует Customer
if err := h.customers.Save(ctx, customer); err != nil {
return err
}
order := h.factory.NewOrder(cmd.CustomerID, cmd.Items)
if err := h.orders.Save(ctx, order); err != nil {
return err
}
orderID = order.ID
return nil
})
return orderID, err
}
Транзакция держит блокировки на двух агрегатах. При конкурентном создании заказов от одного клиента — contention и потенциальные дедлоки. Customer.OrderCount обновляется асинхронно через событие.
// ХОРОШО — один агрегат, событие летит дальше
func (h *CreateOrderHandler) Handle(ctx context.Context, cmd command.CreateOrder) (string, error) {
var orderID string
err := h.uow.Within(ctx, func(ctx context.Context) error {
order := h.factory.NewOrder(cmd.CustomerID, cmd.Items)
if err := h.orders.Save(ctx, order); err != nil {
return fmt.Errorf("save order: %w", err)
}
if err := h.outbox.Enqueue(ctx, txFromCtx(ctx), OrderCreatedEvent{
OrderID: order.ID,
CustomerID: cmd.CustomerID,
}); err != nil {
return fmt.Errorf("enqueue event: %w", err)
}
orderID = order.ID
return nil
})
return orderID, err
}
Customer.OrderCount обновит consumer в своём bounded context после получения OrderCreated. Eventual consistency между агрегатами — норма.
Структура command-handler-а
R-CQRS-CMD-3: command-handler — четыре шага в строгом порядке.
// core/order/handler/confirm_order_handler.go
package handler
type ConfirmOrderHandler struct {
orders OrderRepository
outbox OrderOutboxRepository
uow UnitOfWork
clock Clock
}
func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd command.ConfirmOrder) (string, error) {
var orderID string
err := h.uow.Within(ctx, func(ctx context.Context) error {
// 1. Загрузить агрегат (RW-транзакция через UnitOfWork-контекст)
order, err := h.orders.ByID(ctx, cmd.OrderID)
if err != nil {
return fmt.Errorf("load order %s: %w", cmd.OrderID, err)
}
// 2. Вызвать доменный метод — он проверяет инварианты
if err := order.Confirm(h.clock); err != nil {
return err
}
// 3. Сохранить агрегат
if err := h.orders.Save(ctx, order); err != nil {
return fmt.Errorf("save order: %w", err)
}
// 4. Зарегистрировать событие в outbox (та же транзакция)
if err := h.outbox.Enqueue(ctx, txFromCtx(ctx), OrderConfirmedEvent{
EventID: newUUID(),
OrderID: order.ID,
ConfirmedAt: h.clock.Now(),
}); err != nil {
return fmt.Errorf("enqueue OrderConfirmed: %w", err)
}
orderID = order.ID
return nil
})
return orderID, err
}
Что важно в каждом шаге:
UnitOfWork.Withinоткрывает pgx RW-транзакцию и пробрасывает её через context.OrderRepository.ByIDполучаетpgx.Txиз context, не открывает свою. Это гарантирует, чтоSaveиEnqueue— одна атомарная транзакция.- Доменный метод проверяет инварианты.
order.Confirm(clock)возвращаетerror-значение сKind() apperr.Domain, если заказ уже подтверждён или пуст. Handler не проверяет статус напрямую — он передоверяет это агрегату. - Outbox в той же транзакции. Событие
OrderConfirmedпишется вoutboxдоcommit. Еслиcommitпадает — событие не публикуется. Двойной записи нет.
Доменные ошибки агрегата (apperr.Domain) маппит edge-middleware (httperr.Write) в 409/422 по коду Kind() — без ручного switch в handler-е.
Command возвращает минимум
R-CQRS-CMD-4: handler возвращает id, статус или struct{}. Не OrderSummaryDTO.
// ОК — id изменённой сущности
func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd command.ConfirmOrder) (string, error)
// ОК — пустой результат для idempotent-команды
func (h *CancelOrderHandler) Handle(ctx context.Context, cmd command.CancelOrder) (struct{}, error)
// ПЛОХО — полный read-DTO из command-handler
func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd command.ConfirmOrder) (view.OrderSummaryDTO, error)
Почему не OrderSummaryDTO (R-CQRS-CMD-X2):
- Смешение ответственностей. Write-handler собирает read-проекцию — в нём появляются join'ы и маппинги, которые принадлежат query-handler-у.
- При eventual consistency проекция устареет. После write read-model обновится асинхронно. Если command возвращает «свежий» DTO прямо из write-транзакции — он расходится с тем, что вернёт query-handler через секунду.
- Контроллер делает второй вызов.
POST /orders/{id}/confirm→ 200 с{"orderId": "..."}. Если UI нужен полный summary — он делаетGET /orders/{id}/summary. Два явных вызова с понятными контрактами надёжнее, чем один перегруженный.
Валидация: контракт и инвариант
R-CQRS-CMD-5: валидация в command-side происходит в двух местах.
// edge/handler/order_handler.go — контракт входа на DTO
type ConfirmOrderRequest struct {
OrderID string `json:"order_id" validate:"required,uuid4"`
IdempotencyKey string `json:"idempotency_key" validate:"required,min=1,max=64"`
}
func (h *OrderEdgeHandler) ConfirmOrder(w http.ResponseWriter, r *http.Request) {
var req ConfirmOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperr.Write(w, apperr.InvalidInput("decode", err))
return
}
if err := h.validator.Struct(req); err != nil {
httperr.Write(w, apperr.InvalidInput("validate", err))
return
}
orderID, err := h.handler.Handle(r.Context(), command.ConfirmOrder{
OrderID: req.OrderID,
IdempotencyKey: req.IdempotencyKey,
})
if err != nil {
httperr.Write(w, err)
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"order_id": orderID})
}
// core/order/domain/order.go — бизнес-инвариант в агрегате
func (o *Order) Confirm(clock Clock) error {
if o.Status != StatusNew {
return apperr.Conflict("order.already_confirmed",
fmt.Sprintf("order %s is %s, cannot confirm", o.ID, o.Status))
}
if len(o.Items) == 0 {
return apperr.UnprocessableEntity("order.empty",
fmt.Sprintf("order %s has no items", o.ID))
}
o.Status = StatusConfirmed
o.ConfirmedAt = clock.Now()
return nil
}
Контракт (validate:"required,uuid4") — граница системы, отсекает заведомо невалидный вход. Инвариант (StatusNew) — защита агрегата, действует внутри доменного метода независимо от того, кто его вызывает.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Отдельный SELECT в command-handler «прочитать и решить» | R-CQRS-CMD-X1 | ByID для загрузки агрегата; проверки в методе агрегата |
Handler возвращает view.OrderSummaryDTO | R-CQRS-CMD-X2 | Возврат id/struct{}; UI делает отдельный GET |
Два агрегата в одной транзакции UnitOfWork.Within | R-CQRS-CMD-X3 | Saga: локальные команды + компенсации через события |
| RW-транзакция открыта в репозитории, не в handler-е | R-CQRS-CMD-3 | UnitOfWork.Within в handler-е, pgx.Tx через context |
Проверка if order.Status != StatusNew в handler-е | R-CQRS-CMD-5 | Инвариант в методе агрегата, handler получает error-значение |
Куда дальше
- CQRS → раздел 2. Command side — нормативные формулировки
R-CQRS-CMD-*. - Query side — read-handler с
cqrs.Query, read-only транзакция pgx,ViewRepository. - Read-model — независимая схема read-model, денормализация, rebuild.
- Sync через события — как outbox-событие из command-handler доходит до read-model через Kafka.
- Уровень и эволюция — Уровень 2 vs 3, когда появляется
ViewRepository. - Когда CQRS оправдан — когда lightweight CQRS, когда полный split.