Skip to main content

Schema Evolution with Upcasting

Your event from 2019 still works in 2025. No migration scripts. No downtime.


The Problem

Events are immutable. Once persisted, they cannot change. But schemas evolve: fields are added, types change, structures are reorganized.

Traditional migrations modify data in place—possible for current state, impossible for immutable history.


The Solution: Transform at Read Time

Upcasters transform old events into new shapes when they're read, not when they're stored:

The original bytes remain untouched. The audit trail is immutable. But your aggregates always see the current schema.


How It Works

Register upcasters that transform events from one type to the next:

illustrative - upcaster chain
class PlayerUpcaster(Upcaster):
name = "upcaster-player"
domain = "player"

@upcasts(PlayerRegisteredV1, PlayerRegisteredV2)
def v1_to_v2(self, old: PlayerRegisteredV1) -> PlayerRegisteredV2:
# V2 added email field
return PlayerRegisteredV2(
username=old.username,
email=None,
)

@upcasts(PlayerRegisteredV2, PlayerRegistered)
def v2_to_v3(self, old: PlayerRegisteredV2) -> PlayerRegistered:
# Current version added tier field
return PlayerRegistered(
username=old.username,
email=old.email,
tier="basic",
)

Version is encoded in the type name. The framework chains transformations: a V1 event transforms to V2, then to the current PlayerRegistered, arriving at your aggregate in the latest shape.


Version Detection

The upcaster matches events by type URL suffix. Two common patterns:

Keep version in the proto package, not the type name. Cleaner organization, easier tooling:

illustrative - package versioning
// proto/myapp/v1/player.proto
package myapp.v1;
message PlayerRegistered { ... }

// proto/myapp/v2/player.proto
package myapp.v2;
message PlayerRegistered { ... }

// proto/myapp/v3/player.proto (current)
package myapp.v3;
message PlayerRegistered { ... }

Type URLs: myapp.v1.PlayerRegisteredmyapp.v2.PlayerRegisteredmyapp.v3.PlayerRegistered

Type Name Suffixes

Simpler for small schemas, but clutters the namespace:

illustrative - type name suffixes
message PlayerRegisteredV1 { ... }
message PlayerRegisteredV2 { ... }
message PlayerRegisteredV3 { ... } // current

Examples Across Languages

class PlayerUpcaster(Upcaster):
name = "upcaster-player"
domain = "player"

@upcasts(FundsDepositedV1, FundsDeposited)
def upcast_deposit(self, old: FundsDepositedV1) -> FundsDeposited:
# V1 had raw int, current uses Currency
return FundsDeposited(
amount=Currency(amount=old.amount, currency_code="USD"),
)

Guidelines

Keep Upcasters Pure

Upcasters should be pure functions: same input, same output. No database lookups, no external calls.

illustrative - pure vs impure upcasters
# Good: pure transformation
def upcast(event):
event["new_field"] = derive_from_existing(event["old_field"])
return event

# Bad: external dependency
def upcast(event):
event["user_name"] = db.lookup_user(event["user_id"]) # Don't do this
return event

Chain, Don't Skip

Always upcast to the next version, not directly to latest:

illustrative - chained vs skipped versions
# Good: V1 → V2 → current
@upcasts(OrderCreatedV1, OrderCreatedV2)
def v1_to_v2(self, old): ...

@upcasts(OrderCreatedV2, OrderCreated)
def v2_to_current(self, old): ...

# Bad: skip versions
@upcasts(OrderCreatedV1, OrderCreated)
def v1_to_current(self, old): ... # What about V2 events?

Test with Real History

Your test suite should include events from every historical version:

illustrative - upcast chain test
def test_upcast_chain():
v1_event = load_fixture("player_registered_v1.json")
v3_event = upcast_chain(v1_event)

assert v3_event["email"] is None
assert v3_event["tier"] == "basic"

Deployment

Upcasters deploy with your application code. When you release a new version:

  1. Add upcaster for old → new transformation
  2. Deploy new code
  3. Old events transform on read
  4. New events persist in new format

No migration window. No downtime. Old and new events coexist seamlessly.


See Also