CQRS splits operations into two kinds: commands change data, queries read it. This article is about commands: what they look like in Go code, how the handler works, and why the structure is the way it is.
Why separate writes and reads at all
Imagine a service where the ConfirmOrder method both loads the data needed for display, changes the order's state, and returns a full object with information for the UI. This is convenient while everything is simple. As load grows, it turns out that writes and reads compete for the same resources, and they are hard to scale independently.
CQRS (Command Query Responsibility Segregation) says: let writes and reads live separately. A command changes state and returns a minimum. A query reads data and changes nothing. The boundary is strict.
A command is a struct with a marker
A command in Go is a plain struct holding data. So the compiler can tell commands and queries apart, a marker interface with an unexported method is used:
// 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() {}
The isCommand() method is unexported — only the command package inside core/order can implement it. This is the Go analogue of sealed types: an accidental implementation from a foreign package won't compile.
IdempotencyKey is a standard field for operations that must not be applied twice. The edge handler takes it from the HTTP header Idempotency-Key.
The struct itself is data only, no logic. Mapping from the HTTP request is done by the edge handler (the chi router), not by the command.
One command — one aggregate
A common mistake is to touch two aggregates in a single handler: for example, when creating an order, immediately update the customer's order counter.
// Don't do it this way
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
}
The transaction holds locks on two aggregates. With concurrent requests from a single customer, contention over locks and potential deadlocks arise.
The right approach: a command changes only one aggregate and publishes an event. Whoever tracks the customer's counter will receive the event and update its own data itself:
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
}
The customer's counter will update asynchronously via the OrderCreated event. This is normal consistency between aggregates — data in different parts of the system converges over time, not instantly.
If the business requires two aggregates to change strictly at the same moment — that's a signal: either recheck the boundaries (they may be a single aggregate), or you need a saga with compensations.
The handler's structure
The handler performs four steps in a strict order:
// 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. Load the aggregate
order, err := h.orders.ByID(ctx, cmd.OrderID)
if err != nil {
return fmt.Errorf("load order %s: %w", cmd.OrderID, err)
}
// 2. Call the domain method — it checks business invariants
if err := order.Confirm(h.clock); err != nil {
return err
}
// 3. Save the aggregate
if err := h.orders.Save(ctx, order); err != nil {
return fmt.Errorf("save order: %w", err)
}
// 4. Write the event to the outbox (same transaction)
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
}
A few important details:
UnitOfWork.Within opens a pgx RW transaction and passes it through the context. OrderRepository.ByID takes the pgx.Tx from the context — it doesn't open a transaction itself. This guarantees that Save and Enqueue are a single atomic operation.
The domain method checks invariants. order.Confirm(clock) returns an error if the order is already confirmed or has no items. The handler doesn't check the status directly — it delegates that to the aggregate. Middleware translates aggregate errors into HTTP 409/422 automatically.
The event is written before the transaction completes. If the transaction rolls back, the event won't land in the outbox. There is no double write.
What the handler returns
The handler returns the identifier of the changed entity, an empty struct, or a status. Not a full object with display data.
// Correct — the id of the changed entity
func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd command.ConfirmOrder) (string, error)
// Correct — an empty result for an idempotent command
func (h *CancelOrderHandler) Handle(ctx context.Context, cmd command.CancelOrder) (struct{}, error)
// Wrong — a full DTO from a command handler
func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd command.ConfirmOrder) (view.OrderSummaryDTO, error)
Why not return a full DTO:
- The write handler starts assembling a read projection — JOINs and mappings appear that belong to the query handler.
- With asynchronous read-model updates, data from the write transaction is already stale by the time the client gets the response. A second later, the query handler will return something different.
- Two explicit calls with clear contracts are more reliable than one overloaded call:
POST /orders/{id}/confirmreturns{"order_id": "..."}, and if you need the full summary —GET /orders/{id}/summary.
Validation: contract and invariant
Validation on the command side happens in two places with different jobs.
On input — the contract. The edge handler checks the data format with go-playground/validator before it creates the command:
// 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})
}
In the domain — the invariant. The aggregate checks business rules — and does so regardless of who calls it:
// 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
}
The contract cuts off obviously invalid input at the system boundary (an empty UUID, an overly long key). The invariant protects business rules inside: you can't confirm an already confirmed order, you can't confirm an empty one.
Common mistakes
A separate SELECT in the handler to "read and decide". If you need to check some condition before a change — that's the aggregate's job, not the handler's. Load the aggregate via ByID, call its method — it will sort it out itself.
Returning a full DTO. The handler returns an id or an empty struct. If the UI needs a full object — let it make a separate GET request.
Two aggregates in one transaction. Keep the transaction around a single aggregate. Changes in other aggregates go through events.
Opening the transaction in the repository. The transaction is opened by UnitOfWork.Within in the handler. The repository takes the pgx.Tx from the context — it doesn't open its own.
In short
- A command is a
structwith data and an unexported marker method. Data only, no logic. - One command changes one aggregate. If you need to touch two — publish an event and let the second one react itself.
- The handler does four steps: load the aggregate → call the domain method → save → write the event to the outbox. All in a single transaction via
UnitOfWork. - The handler returns an id or a
struct{}, not a full DTO. - Validation in two places: the contract on input (format), the invariant in the aggregate (business rules).
- The outbox event is written in the same transaction — if the transaction rolls back, the event won't appear either.
What to read next
- Query side — the read handler with a read-only transaction and a ViewRepository.
- Read-model — an independent read-model schema, denormalization, rebuilding.
- Sync via events — how an event from the outbox reaches the read-model through Kafka.
- When CQRS is justified — lightweight CQRS versus a full split.