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