← назад к разделу

CQRS разделяет операции на два типа: команды меняют данные, запросы читают. Эта статья — про команды: как они выглядят в коде на Go, как работает handler и почему структура именно такая.

Зачем вообще разделять запись и чтение

Представь сервис, где метод ConfirmOrder и загружает нужные данные для отображения, и меняет состояние заказа, и возвращает полный объект с информацией для UI. Это удобно, пока всё просто. Когда нагрузка растёт, выясняется: запись и чтение конкурируют за одни ресурсы, и их сложно масштабировать по отдельности.

CQRS (Command Query Responsibility Segregation) говорит: пусть запись и чтение живут раздельно. Команда меняет состояние и возвращает минимум. Запрос читает данные и не меняет ничего. Граница жёсткая.

Команда — это struct с маркером

Команда в Go — обычный struct с данными. Чтобы компилятор различал команды и запросы, используют маркер-интерфейс с неэкспортируемым методом:

// core/cqrs/cqrs.go
package cqrs

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

type ConfirmOrder struct {
    OrderID        string
    IdempotencyKey string
}

func (ConfirmOrder) isCommand() {}

Метод isCommand() неэкспортируемый — реализовать его может только пакет command внутри core/order. Это Go-аналог sealed-типов: случайная реализация из чужого пакета не скомпилируется.

IdempotencyKey — стандартное поле для операций, которые нельзя применить дважды. Edge-handler берёт его из HTTP-заголовка Idempotency-Key.

Сам struct — только данные, никакой логики. Маппинг из HTTP-запроса делает edge-handler (chi-роутер), не команда.

Одна команда — один агрегат

Распространённая ошибка — в одном handler-е тронуть два агрегата: например, при создании заказа сразу обновить счётчик заказов у клиента.

// Так делать не стоит
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)
        if err != nil {
            return err
        }
        customer.IncrementOrderCount()
        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
}

Транзакция держит блокировки на двух агрегатах. При параллельных запросах от одного клиента возникает конкуренция за блокировки и потенциальные взаимоблокировки.

Правильный подход: команда меняет только один агрегат и публикует событие. Кто следит за счётчиком клиента — получит событие и обновит свои данные сам:

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
}

Счётчик у клиента обновится асинхронно через событие OrderCreated. Это нормальная согласованность между агрегатами — данные в разных частях системы сходятся со временем, а не мгновенно.

Если бизнес требует строго одновременного изменения двух агрегатов — это сигнал: либо перепроверь границы (возможно, они один агрегат), либо нужна сага с компенсациями.

Структура handler-а

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. Загрузить агрегат
        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) возвращает ошибку, если заказ уже подтверждён или в нём нет позиций. Handler не проверяет статус напрямую — он передаёт это агрегату. Ошибки агрегата middleware переводит в HTTP 409/422 автоматически.

Событие записывается до завершения транзакции. Если транзакция откатится — событие не попадёт в outbox. Двойной записи не бывает.

Что возвращает handler

Handler возвращает идентификатор изменённой сущности, пустой struct или статус. Не полный объект с данными для отображения.

// Правильно — id изменённой сущности
func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd command.ConfirmOrder) (string, error)

// Правильно — пустой результат для идемпотентной команды
func (h *CancelOrderHandler) Handle(ctx context.Context, cmd command.CancelOrder) (struct{}, error)

// Ошибка — полный DTO из command-handler
func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd command.ConfirmOrder) (view.OrderSummaryDTO, error)

Почему не возвращать полный DTO:

  • Write-handler начинает собирать read-проекцию — появляются JOIN-ы и маппинги, которые принадлежат query-handler-у.
  • При асинхронном обновлении read-model данные из write-транзакции устаревают уже к моменту ответа клиенту. Query-handler через секунду вернёт другое.
  • Два явных вызова с понятными контрактами надёжнее перегруженного одного: POST /orders/{id}/confirm возвращает {"order_id": "..."}, а если нужен полный summary — GET /orders/{id}/summary.

Валидация: контракт и инвариант

Валидация в command-side происходит в двух местах с разными задачами.

На входе — контракт. Edge-handler проверяет формат данных через go-playground/validator до того, как создаёт команду:

// edge/handler/order_handler.go
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, r, apperr.NewValidation("decode: "+err.Error()))
        return
    }
    if err := h.validator.Struct(req); err != nil {
        httperr.Write(w, r, apperr.NewValidation(err.Error()))
        return
    }
    orderID, err := h.handler.Handle(r.Context(), command.ConfirmOrder{
        OrderID:        req.OrderID,
        IdempotencyKey: req.IdempotencyKey,
    })
    if err != nil {
        httperr.Write(w, r, 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 &OrderAlreadyConfirmedError{OrderID: o.ID, Status: string(o.Status)}
    }
    if len(o.Items) == 0 {
        return &EmptyOrderError{OrderID: o.ID}
    }
    o.Status = StatusConfirmed
    o.ConfirmedAt = clock.Now()
    return nil
}

Контракт отсекает явно неверный вход на границе системы (пустой UUID, слишком длинный ключ). Инвариант защищает бизнес-правила внутри: нельзя подтвердить уже подтверждённый заказ, нельзя подтвердить пустой.

Частые ошибки

Отдельный SELECT в handler-е «прочитать и решить». Если нужно проверить какое-то условие перед изменением — это задача агрегата, а не handler-а. Загружай агрегат через ByID, вызывай его метод — он сам разберётся.

Возврат полного DTO. Handler возвращает id или пустой struct. Если UI нужен полный объект — пусть делает отдельный GET-запрос.

Два агрегата в одной транзакции. Держи транзакцию вокруг одного агрегата. Изменения в других агрегатах — через события.

Открытие транзакции в репозитории. Транзакцию открывает UnitOfWork.Within в handler-е. Репозиторий берёт pgx.Tx из context — не открывает свою.

Коротко

  • Команда — struct с данными и неэкспортируемым методом-маркером. Только данные, никакой логики.
  • Одна команда меняет один агрегат. Если нужно затронуть два — публикуй событие, пусть второй реагирует сам.
  • Handler делает четыре шага: загрузить агрегат → вызвать доменный метод → сохранить → записать событие в outbox. Всё в одной транзакции через UnitOfWork.
  • Handler возвращает id или struct{}, не полный DTO.
  • Валидация в двух местах: контракт на входе (формат), инвариант в агрегате (бизнес-правила).
  • Событие в outbox пишется в той же транзакции — если транзакция откатится, событие тоже не появится.

Что почитать дальше

  • Query side — read-handler с read-only транзакцией и ViewRepository.
  • Read-model — независимая схема read-model, денормализация, пересборка.
  • Sync через события — как событие из outbox доходит до read-model через Kafka.
  • Когда CQRS оправдан — lightweight CQRS против полного разделения.