How to version events¶
Handle event schema changes over time by introducing new versioned event types rather than mutating existing ones.
The problem¶
Events in the store are immutable facts — they cannot be changed after being written. But the schema of an event often needs to evolve: a field is renamed, a new required field is added, or the structure changes significantly.
A common mistake is to try to "upcast" old events on read — silently transforming them into the new shape before they reach application code. This approach has a hidden cost: it forces a single interpretation of the migration onto every consumer. A projector that needs to distinguish whether an event was written before or after the schema change cannot do so.
The approach used here is simpler and more flexible: introduce a new event type for each schema change and let each consumer handle the versions it cares about.
Introducing a new event version¶
When a schema change is needed, define a new event type alongside the existing one. Use a V2 (or V3, etc.) suffix:
// Original event — still in the store, never removed.
type TaskCreated struct {
TaskID uuid.UUID
Title string
}
func (e *TaskCreated) EventType() string { return "task.created" }
func (e *TaskCreated) AggregateID() string { return e.TaskID.String() }
// New version — written for all events from this point forward.
type TaskCreatedV2 struct {
TaskID uuid.UUID
Title string
Description string // newly required field
CreatedBy string // newly required field
}
func (e *TaskCreatedV2) EventType() string { return "task.created.v2" }
func (e *TaskCreatedV2) AggregateID() string { return e.TaskID.String() }
Your decide function now emits TaskCreatedV2 for all new commands. Old TaskCreated events remain in the store exactly as they were written.
Registering both versions¶
Register both types so the store can deserialize them:
func init() {
eventsourcing.RegisterEvent(&events.TaskCreated{})
eventsourcing.RegisterEvent(&events.TaskCreatedV2{})
}
See register events for serialization for background.
Handling both versions in evolve¶
Your aggregate's evolve function handles whichever versions affect state. If the old and new versions both carry information relevant to the aggregate's state, handle both:
func evolve(state TaskState, event eventsourcing.Event) TaskState {
switch e := event.(type) {
case *events.TaskCreated:
state.TaskID = e.TaskID
state.Title = e.Title
// Description and CreatedBy are absent — use zero values or defaults.
case *events.TaskCreatedV2:
state.TaskID = e.TaskID
state.Title = e.Title
state.Description = e.Description
state.CreatedBy = e.CreatedBy
case *events.TaskCompleted:
state.Done = true
}
return state
}
If the aggregate's business logic does not need the old event at all (for example, because you only create new tasks going forward), you can simply omit the old case and let it fall through.
Handling both versions in projectors¶
Each projector independently decides how to handle each version. A simple summary projector might treat both versions the same way:
func (p *TaskSummaryProjector) OnTaskCreated(ctx context.Context, e *events.TaskCreated) error {
return p.store.Insert(TaskSummary{ID: e.TaskID, Title: e.Title})
}
func (p *TaskSummaryProjector) OnTaskCreatedV2(ctx context.Context, e *events.TaskCreatedV2) error {
return p.store.Insert(TaskSummary{
ID: e.TaskID,
Title: e.Title,
Description: e.Description,
CreatedBy: e.CreatedBy,
})
}
A projector that builds a "created by user" index only subscribes to TaskCreatedV2 and ignores the old version entirely — because the required data was simply not captured before the migration.
This is the core benefit of versioned events over upcasting: each projector applies its own interpretation. A projector that needs to know a field was missing can; a projector that doesn't care treats both versions equivalently.
Renaming an event type (alternative to versioning)¶
If you want to change the Go type name but keep the stored event name the same, use RegisterEventByName:
// Old events were stored as "task.created".
// The Go type was renamed from TaskCreated to TaskCreatedEvent.
eventsourcing.RegisterEventByName("task.created", func() eventsourcing.Event {
return &events.TaskCreatedEvent{}
})
This is a rename, not a schema change — use it only when the struct fields have not changed.
Guidelines¶
| Situation | Approach |
|---|---|
| Added a new optional field | New event version (V2), handle both in consumers |
| Added a new required field | New event version (V2), old version treated as "data not available" |
| Renamed a field | New event version (V2) — never rename fields on existing types |
| Renamed the Go type only (no field changes) | RegisterEventByName to map old stored name to new type |
| Removed a field | New event version (V2), drop the field from the new struct |
Never modify the fields of an existing event struct. Old events in the store were serialized with the old schema; changing the struct silently breaks deserialization for those events.