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 против полного разделения.