Event Upcasting

A technique for migrating event schemas over time in Event Sourcing systems by transforming old event payloads to the current schema at read time — without modifying the stored events.

Problem

In an event-sourced system, events are immutable once written. But business requirements evolve: fields get renamed, types change, new required fields are added, events get split. When you replay old events to rebuild aggregate state, the old events no longer match your current domain model.

Options that don’t work well:

  • Rewrite history: Immutability is violated; corrupts the audit trail
  • Keep old handlers: Code complexity grows indefinitely as every version must be supported forever
  • Ignore old events: Breaks rebuild correctness

Solution / Explanation

Upcasting applies a chain of transformers to old events before they reach the aggregate. Each transformer handles one version transition:

Raw event (v1) from store
       │
       ▼
Upcaster v1→v2: renames "userName" to "name"
       │
       ▼
Upcaster v2→v3: adds default "locale": "en-US"
       │
       ▼
Current event schema (v3) reaches aggregate

The event store remains unchanged. The transformation happens in memory at read time.

When to Apply Upcasting

Change TypeUpcaster Approach
Field renamedevent.name = event.userName; delete event.userName
Field added (with default)event.locale = event.locale || "en-US"
Field removedSimply don’t include removed field in output
Field type changedTransform value at upcast time
Event splitOne event becomes two — return an array from upcaster
Event renamedChange event.type field

Upcast Chain

Multiple upcasters are chained in sequence. Each handles a single version step:

EventStore.load(aggregateId)
    → raw events
    → UpcasterChain.upcast(events)  // applies all transformers in order
    → current-schema events
    → Aggregate.apply(events)

Alternative: Event Versioning

Instead of upcasting, use explicit version fields:

{ "type": "UserCreated", "version": 2, "name": "Alice" }
{ "type": "UserCreated", "version": 1, "userName": "Bob" }

Handle each version with a dedicated handler:

on UserCreated v1: use event.userName
on UserCreated v2: use event.name

Trade-off: Versioning keeps handlers explicit but multiplies handler count. Upcasting keeps handlers simple but hides transformations.

Framework Support

Upcasting support is available in event sourcing frameworks across multiple languages and ecosystems. Consult the sources section for specific library examples.

Trade-offs

BenefitCost
Event store remains immutable (audit intact)Upcast chain must be maintained
Aggregate code only handles current schemaHidden complexity in transformer chain
Smooth schema evolutionPerformance cost for old events (all transformed at read)
Multiple versions supported simultaneouslyMust ensure upcasters are idempotent