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

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()   }

// Маркеры для встраивания: неэкспортируемый метод из чужого пакета
// не реализовать, команда получает его через встроенную структуру
type CommandMarker struct{}
type QueryMarker struct{}

func (CommandMarker) isCommand() {}
func (QueryMarker) isQuery()     {}
// core/order/command/confirm_order.go
package command

type ConfirmOrder struct {
    cqrs.CommandMarker
    OrderID        string
    IdempotencyKey string
}

Метод isCommand() неэкспортируемый — реализовать его может только сам пакет cqrs, поэтому команда не пишет метод, а встраивает cqrs.CommandMarker. Это Go-аналог sealed-типов: структура из чужого пакета становится командой только через явное встраивание маркера, случайно — никогда, а свой метод isCommand() в пакете command интерфейс не реализует: компилятор ответит «does not implement cqrs.Command (unexported method isCommand)».

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.

Решение зависит от чужого агрегата

Правило «читать состояние для решения только из своего агрегата» оставляет открытым главный практический случай: нужное поле физически принадлежит другому агрегату, и втащить его в свой нельзя. Например, «нельзя подтвердить заказ, если у покупателя просрочена оплата предыдущего»: просрочка живёт в платежах, а не в заказе. Что делать, по возрастанию цены и надёжности.

1. Передать решение в команду, а не данные. Самый дешёвый и самый недооценённый приём: проверку делает тот, кто её умеет, до команды, и в команду приходит уже готовый ответ.

// edge/handler/order_handler.go: до транзакции команды
eligibility, err := h.payments.EligibilityFor(r.Context(), customerID) // чужой контекст
if err != nil {
    httperr.Write(w, r, err)
    return
}
orderID, err := h.confirmOrder.Handle(r.Context(), command.ConfirmOrder{
    OrderID:        id,
    PaymentAllowed: eligibility.Allowed, // в команду приходит готовый факт
    IdempotencyKey: key,
})

Агрегат при этом принимает решение сам, но по переданному факту, а не по данным чужого агрегата. Правило соблюдено, транзакция короткая, внешний вызов вне неё. Ограничение: факт может устареть между проверкой и выполнением, и здесь нужен следующий пункт.

2. Скопировать нужный факт в свой агрегат через событие. Если решение требуется часто, чужой факт переносят к себе: платежи публикуют «у покупателя просрочка», заказы держат у себя поле и обновляют его по событию. Это не дублирование данных, а проекция чужого факта в свою модель, и она стоит ровно того, чего стоит любая проекция: отставание на доли секунды и необходимость уметь пересобрать. Признак, что приём подходит: факт меняется редко, а читается часто.

3. Принять решение оптимистично и компенсировать. Когда проверить заранее нельзя (резерв на складе, лимит, баланс), команда выполняется, а чужая сторона подтверждает или отклоняет, то есть получается сага. Пользователь видит промежуточное состояние («ожидает подтверждения»), и это честнее, чем блокировать два агрегата в одной транзакции.

4. Признать, что границы неверные. Если решение всегда требует чужого состояния и промежуточное состояние недопустимо, значит, два агрегата на самом деле один. Признак: каждое изменение одного требует одновременного изменения другого.

Чего делать не стоит: читать чужую таблицу напрямую из своего обработчика, держать чужой агрегат в транзакции «только для чтения» и делать вызов в чужой сервис внутри транзакции. Как выбрать: редко меняется и часто нужно, копировать событием; нужно однократно и точно, передавать решением до команды; нельзя знать заранее, сага.

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

Валидация в 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, вызывай его метод — он сам разберётся.

// так делать не нужно
func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd command.ConfirmOrder) (string, error) {
    hasPayment, err := h.payments.ExistsByOrderID(ctx, cmd.OrderID) // отдельный read
    if err != nil {
        return "", err
    }
    if !hasPayment {
        return "", ErrPaymentRequired
    }
    return "", h.uow.Within(ctx, func(ctx context.Context) error { /* загрузка и подтверждение */ return nil })
}

Проблема двойная: между первым и вторым запросом данные могут измениться (гонка без блокировки), и логика чтения просочилась в обработчик записи. Если статус платежа важен для подтверждения, он должен быть полем агрегата, и order.Confirm проверит его сам.

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

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

// частая ошибка: два агрегата под одной транзакцией
func (h *TransferMoneyHandler) Handle(ctx context.Context, cmd command.TransferMoney) error {
    return h.uow.Within(ctx, func(ctx context.Context) error {
        from, err := h.accounts.ByIDForUpdate(ctx, cmd.FromID)
        if err != nil {
            return err
        }
        to, err := h.accounts.ByIDForUpdate(ctx, cmd.ToID)
        if err != nil {
            return err
        }
        if err := from.Debit(cmd.Amount); err != nil {
            return err
        }
        to.Credit(cmd.Amount)
        return errors.Join(h.accounts.Save(ctx, from), h.accounts.Save(ctx, to))
    })
}

// правильно: сага из двух локальных команд
// 1. DebitAccount  → AccountDebited
// 2. CreditAccount по событию
// при ошибке компенсирующая команда CreditAccount (возврат)

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

Коротко

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

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

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