← Back to the section

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}/confirm returns {"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 struct with 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.
  • 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.