The aggregate variant¶
This library supports two ways to organise command handler state and evolve. Understanding both — and when each breaks down — helps you make the right call early.
The default: per-slice state¶
In the vertical slice approach, each command handler owns its own state struct and evolve function:
tasks/
├── events/
└── slices/
├── createtask/
│ └── command.go ← state, evolve, decide all local
└── completetask/
└── command.go ← its own state, evolve, decide
The createtask handler only tracks Exists. The completetask handler tracks Exists and Completed. They look similar, but they are independent copies — each free to diverge.
The aggregate variant¶
You can instead move state and evolve into a shared aggregate folder that all command handlers import:
tasks/
├── aggregate/
│ └── task.go ← shared TaskState + Evolve
├── events/
└── slices/
├── createtask/
│ └── command.go ← imports aggregate.TaskState, aggregate.Evolve
└── completetask/
└── command.go ← same import
// tasks/aggregate/task.go
package aggregate
import "github.com/terraskye/eventsourcing"
import "task-management/tasks/events"
type TaskState struct {
Exists bool
Completed bool
Archived bool
}
func InitialState() TaskState { return TaskState{} }
func Evolve(state TaskState, envelope *eventsourcing.Envelope) TaskState {
switch envelope.Event.(type) {
case *events.TaskCreated:
return TaskState{Exists: true}
case *events.TaskCompleted:
return TaskState{Exists: true, Completed: true}
case *events.TaskArchived:
return TaskState{Exists: true, Completed: state.Completed, Archived: true}
}
return state
}
Each command handler then passes aggregate.InitialState and aggregate.Evolve to NewCommandHandler, and only decide stays local:
// tasks/slices/completetask/command.go
func NewHandler(store eventsourcing.EventStore) eventsourcing.CommandHandler[CompleteTask] {
return eventsourcing.NewCommandHandler(store, aggregate.InitialState, aggregate.Evolve, decide)
}
This is valid Go and the framework supports it fully.
Why the vertical approach is usually better¶
The aggregate variant looks clean when all handlers happen to need the same fields. In practice, subtle differences appear:
ArchiveTaskneeds to know theAssigneeIDto notify them — butCreateTaskdoes not.TransferTaskneeds the current owner — but no other handler does.- A new
PauseTaskcommand needs aPausedAttimestamp that would be noise everywhere else.
Each time, you face the same choice: add the field to TaskState for all handlers, or split the aggregate. Both options grow in difficulty with each edge case. The shared evolve accumulates logic that only some handlers need, and TaskState becomes a union of every handler's requirements rather than the minimal state for any one.
With the per-slice approach, each handler carries only what it needs. Adding a field to completetask/command.go does not touch createtask. Copying is cheaper than managing divergence.
When the aggregate variant still makes sense¶
- A tightly related group of handlers (e.g., a multi-step workflow where every step needs the same invariants) where the state genuinely does not diverge.
- A team convention where a single place for event-to-state mapping is preferable for discoverability.
Even then, watch for the first subtle difference — that is the signal to split back into per-slice state.
Rule of thumb¶
Start with per-slice state. If you find yourself copying a large, stable evolve across many handlers with no variation after several months, the aggregate variant is reasonable. Do not start there.