Опирается на правила: R-CQRS-CMD-1R-CQRS-CMD-5 и R-CQRS-CMD-X1R-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-X1ByID для загрузки агрегата; проверки в методе агрегата
Handler возвращает view.OrderSummaryDTOR-CQRS-CMD-X2Возврат id/struct{}; UI делает отдельный GET
Два агрегата в одной транзакции UnitOfWork.WithinR-CQRS-CMD-X3Saga: локальные команды + компенсации через события
RW-транзакция открыта в репозитории, не в handler-еR-CQRS-CMD-3UnitOfWork.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.