CQRS in Go — Part 1: the aggregate, Transition() and the Clone() you forget

CQRS in Go series:

What an aggregate is

The aggregate is the guardian of business consistency. It's the one that says "no" when an operation is invalid: you can't cancel an order that's already been shipped, you can't add an item to a closed cart. This logic lives in the aggregate and nowhere else.

In Event Sourcing, the aggregate has no directly persisted state. Its state is the result of replaying all its events from the beginning. Each event represents something that happened — immutable, factual, in the past. The aggregate replays them in order and reconstructs its current state.

What the aggregate does not do: I/O. It doesn't read from the database, doesn't make HTTP requests, doesn't send emails. It receives a command, validates whether it can be applied to its current state, and emits events. That's it. This constraint is what makes it testable at zero cost.

The structure in Go

An Order aggregate in Go looks like this:

type OrderStatus string

const (
    OrderStatusDraft     OrderStatus = "draft"
    OrderStatusPlaced    OrderStatus = "placed"
    OrderStatusShipped   OrderStatus = "shipped"
    OrderStatusCancelled OrderStatus = "cancelled"
)

type OrderItem struct {
    ProductID string
    Quantity  int
    UnitPrice int // in cents
}

type Order struct {
    OrderID    uuid.UUID
    CustomerID uuid.UUID
    Status     OrderStatus
    Items      []OrderItem
    Total      int
    CreatedAt  time.Time
}

Two deliberate choices here. First, value struct, not a pointer. When you pass an Order, Go makes a copy of it. That's exactly what we want: two versions of a state don't share memory by default (except for slices and maps — we'll get to that). An aggregate passed by pointer between multiple goroutines is a source of silent bugs.

Second, fields are exported here to simplify the example, but in production you keep them private to the package and only expose what's needed through methods. The aggregate is not a DTO.

Events as the source of truth

Events are facts that have occurred. They don't describe an intention — that's the role of commands — they describe what happened.

type OrderPlaced struct {
    OrderID    uuid.UUID
    CustomerID uuid.UUID
    Items      []OrderItem
    Total      int
    PlacedAt   time.Time
}

type OrderItemAdded struct {
    OrderID  uuid.UUID
    Item     OrderItem
    NewTotal int
}

type OrderCancelled struct {
    OrderID     uuid.UUID
    Reason      string
    CancelledAt time.Time
}

Once emitted, an event never changes. You don't correct a past event — you emit a new event that compensates. That's the fundamental difference from an UPDATE in a database.

The aggregate declares the event types it handles via EventKinds(). This is useful for the event store which needs to know how to deserialize stored events:

func (o Order) EventKinds() []DomainEvent {
    return []DomainEvent{
        OrderPlaced{},
        OrderItemAdded{},
        OrderCancelled{},
    }
}

Transition() — the heart of the pattern

Transition() is the method that applies an event to the current state and returns a new state. It's the only place where the aggregate's state evolves.

func (o Order) Transition(event DomainEvent) Order {
    switch e := event.(type) {
    case OrderPlaced:
        o.OrderID = e.OrderID
        o.CustomerID = e.CustomerID
        o.Status = OrderStatusPlaced
        o.Items = e.Items
        o.Total = e.Total
        o.CreatedAt = e.PlacedAt

    case OrderItemAdded:
        o.Items = append(o.Items, e.Item)
        o.Total = e.NewTotal

    case OrderCancelled:
        o.Status = OrderStatusCancelled
    }
    return o
}

The receiver is a value, not a pointer. When Go calls this method, it copies the current Order into o. We modify this copy and return it. If you ignore the return value, nothing has changed — the original Order is intact.

It's a pure reducer, exactly like Array.prototype.reduce in JavaScript or fold in Haskell. The current state is a deterministic function of all past events:

state = events.reduce(initialState, transition)

The practical consequence: you can reconstruct the aggregate's state at any historical point in time by replaying events up to that point. To debug a bug that occurred on February 14th at 2:37 PM, replay the events up to that timestamp and inspect the state. No logs to dig through, no snapshots to restore.

The Clone() trap — the silent bug

This is where most CQRS implementations in Go fall apart. And it's silent: no panic, no error, just corrupted data.

The problem comes from Go's memory model for slices. A slice is a three-field structure: a pointer to the backing array, a length, and a capacity. When Go copies a struct containing a slice, it copies these three fields — but not the backing array. Both copies point to the same array in memory.

append is particularly treacherous. If the slice has enough capacity to accommodate the new element, append writes directly into the existing backing array without allocating a new one. Result: the old state is modified without the code knowing it.

package main

import "fmt"

type OrderItem struct {
    ProductID string
    Quantity  int
}

type Order struct {
    Items []OrderItem
}

func main() {
    // Slice with capacity 4, length 1 — enough room for append without reallocation
    original := Order{
        Items: make([]OrderItem, 1, 4),
    }
    original.Items[0] = OrderItem{ProductID: "A", Quantity: 1}

    // Simulate a naive Transition: copy the struct, append to the copy
    modified := original
    modified.Items = append(modified.Items, OrderItem{ProductID: "B", Quantity: 2})

    // The trap: append wrote into the shared backing array.
    // original.Items still points to len=1, but index [1] in memory
    // now contains product "B". Reslice and it reappears.
    fmt.Println("original length:", len(original.Items))  // 1 — reassuring
    fmt.Println("modified length:", len(modified.Items))  // 2

    // But:
    ghost := original.Items[:2] // Reslice within existing capacity
    fmt.Println("ghost item:", ghost[1].ProductID) // "B" — it's there
}

In practice in a CQRS system, this scenario occurs as soon as you store a snapshot or pass a state between goroutines. The old state you thought was immutable has been silently modified.

The solution is Clone(). Before any Transition(), explicitly clone the state to break the link with the backing array:

func (o Order) Clone() Order {
    c := o
    if o.Items != nil {
        c.Items = make([]OrderItem, len(o.Items))
        copy(c.Items, o.Items)
    }
    return c
}

copy allocates a new backing array and copies the elements into it. After that, c.Items and o.Items are completely independent. Any subsequent append on one doesn't touch the other.

The same constraint applies to maps. Go copies the reference, not the content. A Cart with a map requires an explicit deep clone:

type Cart struct {
    Items map[uuid.UUID]CartItem
}

func (c Cart) Clone() Cart {
    clone := c
    clone.Items = make(map[uuid.UUID]CartItem, len(c.Items))
    for k, v := range c.Items {
        clone.Items[k] = v
    }
    return clone
}

If CartItem itself contains slices or maps, you need to go one level deeper. That's the only case where the depth of the clone depends on the actual data structure — there's no safe generic shortcut in Go.

The production rule: always call Clone() before Transition(). You can also integrate it directly into Transition() as the first instruction, which makes the pattern foolproof at the cost of a systematic copy even when it's not needed. For most aggregates, this overhead is negligible.

Replay — reconstructing state from events

With Clone() and Transition() in place, the replay fits in five lines:

func Replay(events []DomainEvent) Order {
    var state Order
    for _, event := range events {
        state = state.Clone().Transition(event)
    }
    return state
}

Start from an empty Order, apply each event in order, and get the final state. Proof that the aggregate is pure: this function is deterministic. Same list of events, same result, every time. No network calls, no cache, no system clock. You can call it from a test without any mocks.

Replay is also what allows you to fix bugs after the fact. If the logic in Transition() was incorrect and you fix it, you can replay all historical events and get corrected states — provided the events themselves are correct, which they are by definition since they represent what actually happened.

Summary

  • The aggregate is the guardian of business consistency. It does no I/O.
  • Its state is a pure function of the events it has received since its creation.
  • Transition(event) applies an event and returns a new state — method on a value, not a pointer.
  • Go copies structs by value, but slices and maps share their backing store. Without Clone(), mutations via append can silently corrupt old states.
  • Clone() must deep copy all collections in the struct.
  • Replay reconstructs any historical state by replaying events — no I/O, therefore trivially testable.

Part 2 covers command handlers: how to validate a command against the aggregate's current state, emit the appropriate events, and keep everything free of side effects and implicit dependencies.

📄 Associated CLAUDE.md

Comments (0)