The decide/evolve pattern¶
The decide/evolve pattern is the core of every command handler. It separates state reconstruction (evolve) from business logic (decide).
The two functions¶
events in store
│
│ evolve(state, event) → state
▼
current state
│
│ decide(state, command) → events | error
▼
new events (saved to store)
evolve¶
type Evolver[T any] func(currentState T, envelope *Envelope) T
evolve answers: "given that this event happened, what is the new state?"
- Pure function — no side effects, no I/O.
- Called once per stored event, in order.
- Returns the updated state.
- Must handle unknown event types gracefully (return state unchanged).
decide¶
type Decider[T any, C Command] func(state T, cmd C) ([]Event, error)
decide answers: "given the current state, can we apply this command, and what events should result?"
- Pure function — no side effects, no I/O.
- Returns events to persist, or an error to reject the command.
- Returns an empty slice (and nil error) for no-op commands.
- Must not mutate the state argument.
Why two functions?¶
The separation ensures that decide always works with a complete, consistent view of the aggregate's history. This makes business rules easy to express and test.
It also enables optimistic concurrency: if two commands are sent simultaneously, the second one reloads events, re-evolves state (including the first command's events), and re-runs decide with the updated state. This guarantees business rules are never evaluated against stale state.
State is minimal¶
The aggregate state (T) should contain only the fields needed to enforce business rules in decide. It is not the same as a read model or a database row.
// Good: minimal state for enforcing task business rules
type taskState struct {
Exists bool
Completed bool
}
// Too much: includes read model data not needed for decisions
type taskState struct {
Exists bool
Completed bool
Title string // not needed for decisions
Description string // not needed for decisions
CreatedAt time.Time
}
Each command handler has its own state struct. A CreateTask handler only needs to know if the task exists. A CompleteTask handler needs to know if it exists and whether it's already completed.
Pattern for unknown events¶
Because multiple command handlers write to the same stream, an evolve function will encounter events it did not produce. Always return state unchanged for unhandled events:
func evolve(state taskState, env *eventsourcing.Envelope) taskState {
switch env.Event.(type) {
case *events.TaskCreated:
return taskState{Exists: true}
case *events.TaskCompleted:
return taskState{Exists: true, Completed: true}
// unknown events: fall through
}
return state // unchanged
}
Idempotency¶
A decide function can return no events (empty slice, nil error) to indicate the command had no effect. This is used for idempotent commands — commands that are safe to retry:
func decide(state taskState, cmd ArchiveTask) ([]eventsourcing.Event, error) {
if state.Archived {
return nil, nil // already done — no-op
}
return []eventsourcing.Event{&events.TaskArchived{...}}, nil
}
Testing¶
Because both functions are pure, they are trivially testable without any infrastructure:
func TestDecide_RejectDuplicate(t *testing.T) {
events, err := decide(taskState{Exists: true}, CreateTask{Title: "X"})
if err == nil {
t.Fatal("expected rejection")
}
if len(events) != 0 {
t.Fatal("expected no events on rejection")
}
}
func TestEvolve_TaskCreated(t *testing.T) {
state := evolve(taskState{}, &eventsourcing.Envelope{
Event: &events.TaskCreated{},
})
if !state.Exists {
t.Error("expected Exists = true after TaskCreated")
}
}