CQRS in Go — Part 2: command handlers without side effects

CQRS in Go series:

If you followed Part 1, you know what an aggregate is and how Transition() reconstructs state from events. Now, a more concrete question: who decides to emit those events? Who validates that a command is legitimate? That's the role of the command handler.

And that's where many CQRS projects drift. The handler starts clean, then accumulates dependencies. A repo here, an email service there. Three months later, you have a test that spins up a database to verify that a required field is actually required.

There's a better way.

The problem with classic handlers

Here's what a "standard" handler looks like — the one you write when you apply CQRS without pushing the reasoning all the way through:

// ❌ Classic handler — depends on everything
func (h *OrderHandler) PlaceOrder(ctx context.Context, cmd PlaceOrderCmd) error {
    customer, err := h.customerRepo.Get(ctx, cmd.CustomerID)
    if err != nil {
        return err
    }

    order := Order{
        ID:         uuid.New(),
        CustomerID: customer.ID,
        Items:      cmd.Items,
    }

    if err := h.orderRepo.Save(ctx, order); err != nil {
        return err
    }
    if err := h.emailService.Send(customer.Email, "Order placed"); err != nil {
        return err
    }

    return nil
}

To test this function, you need: a mock customerRepo, a mock orderRepo, a mock emailService. Three mocks for a single test. And each test becomes a wiring exercise — you spend more time configuring the environment than testing the business logic.

The worst part: the actual business logic fits in five lines. The rest is I/O. And that's precisely the problem: I/O and business logic are mixed together.

The magic signature

The solution fits in one signature:

Handle(ctx context.Context, state Order, cmd PlaceOrderCmd) (Events, error)

Three important things in this signature:

  • The handler receives the current state of the aggregate — not a repository, not a DB connection. The state is already reconstructed when the handler is called.
  • It returns events — not a modified state, not a boolean. Facts: "here's what happened".
  • Zero I/O, zero side effects. The handler doesn't even know PostgreSQL exists.

Here's the complete implementation:

type PlaceOrderCmd struct {
    OrderID    uuid.UUID
    CustomerID uuid.UUID
    Items      []OrderItem
}

func (cmd PlaceOrderCmd) Validate() error {
    if cmd.OrderID == uuid.Nil {
        return fmt.Errorf("order ID required")
    }
    if len(cmd.Items) == 0 {
        return fmt.Errorf("at least one item required")
    }
    return nil
}

type PlaceOrderHandler struct{}

func (h PlaceOrderHandler) Handle(ctx context.Context, state Order, cmd PlaceOrderCmd) (Events, error) {
    if err := cmd.Validate(); err != nil {
        return nil, err
    }

    // Business invariant: can't place an order that's already placed
    if state.Status == OrderStatusPlaced {
        return nil, ErrOrderAlreadyPlaced
    }

    total := 0
    for _, item := range cmd.Items {
        total += item.UnitPrice * item.Quantity
    }

    var events Events
    events.Append(OrderPlaced{
        OrderID:    cmd.OrderID,
        CustomerID: cmd.CustomerID,
        Items:      cmd.Items,
        Total:      total,
        PlacedAt:   time.Now(),
    })

    return events, nil
}

This handler is a pure function disguised as a method. Same inputs, same output, every time. No internal state. No hidden dependencies. Testable in complete isolation.

Validation in two layers

Look at the structure: validation is split into two distinct levels with different responsibilities.

Layer 1: cmd.Validate() — structural validation. Required fields, correct formats, simple constraints. This validation can be called before even loading the aggregate from the store. No business context needed. It belongs to the command itself.

Layer 2: business invariants in the handler — these validations require the current state. "Can we cancel this order?" depends on the order's current status. Without state, you can't answer.

type CancelOrderCmd struct {
    OrderID uuid.UUID
    Reason  string
}

func (cmd CancelOrderCmd) Validate() error {
    if cmd.Reason == "" {
        return fmt.Errorf("cancellation reason required")
    }
    return nil
}

func (h CancelOrderHandler) Handle(ctx context.Context, state Order, cmd CancelOrderCmd) (Events, error) {
    if err := cmd.Validate(); err != nil {
        return nil, err
    }

    // Business invariants — depend on state
    if state.Status == OrderStatusShipped {
        return nil, fmt.Errorf("cannot cancel shipped order")
    }
    if state.Status == OrderStatusCancelled {
        return nil, fmt.Errorf("order already cancelled")
    }

    var events Events
    events.Append(OrderCancelled{
        OrderID:     cmd.OrderID,
        Reason:      cmd.Reason,
        CancelledAt: time.Now(),
    })

    return events, nil
}

The separation is clean. Structural validation errors surface early, before any loading from the store. Business invariant errors surface later, when context is available.

Tests — 10 lines, no mocks

This is where the design pays off. Here's a complete test for the happy path:

func TestPlaceOrder_Success(t *testing.T) {
    handler := PlaceOrderHandler{}
    state := Order{} // empty state = new order

    cmd := PlaceOrderCmd{
        OrderID:    uuid.New(),
        CustomerID: uuid.New(),
        Items:      []OrderItem{{ProductID: "SKU-1", Quantity: 2, UnitPrice: 1500}},
    }

    events, err := handler.Handle(context.Background(), state, cmd)

    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if len(events) != 1 {
        t.Fatalf("expected 1 event, got %d", len(events))
    }

    placed, ok := events[0].(OrderPlaced)
    if !ok {
        t.Fatalf("expected OrderPlaced, got %T", events[0])
    }
    if placed.Total != 3000 {
        t.Errorf("expected total 3000, got %d", placed.Total)
    }
}

No mocks. No DB. No Docker. No setup/teardown. This test runs in microseconds on any machine.

Business invariants test just as simply — just pass a state with the right status:

func TestPlaceOrder_AlreadyPlaced(t *testing.T) {
    handler := PlaceOrderHandler{}
    state := Order{Status: OrderStatusPlaced} // order already placed

    _, err := handler.Handle(context.Background(), state, PlaceOrderCmd{
        OrderID: uuid.New(),
        Items:   []OrderItem{{ProductID: "SKU-1", Quantity: 1, UnitPrice: 100}},
    })

    if err != ErrOrderAlreadyPlaced {
        t.Fatalf("expected ErrOrderAlreadyPlaced, got %v", err)
    }
}

And table-driven tests are particularly readable because the only variable between cases is the initial state:

func TestCancelOrder(t *testing.T) {
    tests := []struct {
        name    string
        state   Order
        wantErr bool
    }{
        {"draft order", Order{Status: OrderStatusDraft}, false},
        {"placed order", Order{Status: OrderStatusPlaced}, false},
        {"shipped order", Order{Status: OrderStatusShipped}, true},
        {"already cancelled", Order{Status: OrderStatusCancelled}, true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            _, err := CancelOrderHandler{}.Handle(context.Background(), tt.state, CancelOrderCmd{
                OrderID: uuid.New(),
                Reason:  "changed mind",
            })
            if (err != nil) != tt.wantErr {
                t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr)
            }
        })
    }
}

Four status cases, four lines in the table. The test logic is entirely in the data — which is exactly what table-driven tests aim for.

The Events type and Append()

The Events type is intentionally simple:

type Events []DomainEvent

func (e *Events) Append(event DomainEvent) {
    *e = append(*e, event)
}

It's a slice. No event bus, no dispatcher, no middleware. Just a slice with a helper method so the calling code is a bit more readable than *e = append(*e, event).

Why so simple? Because the handler doesn't need to know what happens next. It emits events. That's it. What happens after — persistence, projections, sagas — is the command gateway's problem.

The full flow — from HTTP to storage

For the complete picture, here's what the command gateway does when it receives a command. The handler sees none of this:

// What the CommandGateway does when it receives a command:
// 1. Load the aggregate's existing events from the store
// 2. Replay events to reconstruct state (via Transition())
// 3. Call handler.Handle(ctx, state, cmd)
// 4. Store the new events in the store
// 5. Notify event handlers (projections, sagas)

func (g *CommandGateway) Dispatch(ctx context.Context, aggregateID uuid.UUID, cmd Command) error {
    // Load and reconstruct the aggregate
    storedEvents, err := g.store.Load(ctx, aggregateID)
    if err != nil {
        return fmt.Errorf("loading aggregate %s: %w", aggregateID, err)
    }

    state := g.aggregate.Rebuild(storedEvents)

    // The handler knows nothing of what came before
    newEvents, err := g.handler.Handle(ctx, state, cmd)
    if err != nil {
        return err
    }

    // Store the new events
    if err := g.store.Append(ctx, aggregateID, newEvents); err != nil {
        return fmt.Errorf("appending events: %w", err)
    }

    // Notify projections and sagas (async)
    g.bus.Publish(newEvents)

    return nil
}

The handler is called at step 3. It receives an already-reconstructed state, returns events, and doesn't know what comes before or after. This deliberate ignorance is what makes it testable.

The details of the PostgreSQL store and event-based reconstruction are covered in Part 4. What matters here: the handler is pure.

Summary

  • Pure handler: no dependency on repos, DBs, or external services. Receives a state, returns events.
  • Signature Handle(ctx, state, cmd) (Events, error): that's the contract. The entire architecture follows from it.
  • Two-layer validation: structural on the command (cmd.Validate()), business in the handler. Each where it has the necessary context.
  • Tests without mocks: build a state, call the handler, verify the events. No setup, no infrastructure, no Docker.
  • Events as a slice: type Events []DomainEvent. No bus built into the handler. Routing complexity belongs elsewhere.
  • The gateway orchestrates: loading from the store, state reconstruction, handler call, event persistence. The handler sees none of this — and that's intentional.

Part 3 covers sagas: how to react to an event to trigger other commands, and how to orchestrate business processes that span multiple aggregates.

📄 Associated CLAUDE.md

Comments (0)