Schema Evolution
Read-time upcasters that transform old event schemas into newer versions without modifying stored data.
Upcasters transform old event schemas into newer versions at read time. The stored event in the database is never modified — transformations happen in memory during load().
Why Upcasters?
Event sourcing stores events permanently. When the shape of an event changes (e.g., adding a new required field, restructuring nested data), you have two options:
- Migrate stored events — Modify existing rows in the database. This breaks the immutability guarantee and is error-prone at scale.
- Upcast at read time — Transform old events to the new shape when they are loaded. The stored data stays unchanged.
Alvyn uses option 2. Upcasters are pure functions that convert event data from one schema version to the next.
Defining an Upcaster
An upcaster implements the Upcaster interface:
import type { Upcaster } from "alvyn";
const orderPlacedV1ToV2: Upcaster = {
eventType: "OrderPlaced",
fromSchemaVersion: 1,
toSchemaVersion: 2,
upcast(data: { total: number }) {
return {
...data,
currency: "EUR",
};
},
};Registration
Register upcasters with the event store at startup:
// Single upcaster
eventStore.registerUpcaster(orderPlacedV1ToV2);
// Multiple upcasters
eventStore.registerUpcasters([orderPlacedV1ToV2, addressV1ToV2]);
// From an aggregate definition
eventStore.registerUpcasters(Order.getUpcasters());Chained Upcasters
When an event type has gone through multiple schema versions, register upcasters for each step. They are automatically chained:
// v1 -> v2: Added "city" field
const addressV1ToV2: Upcaster = {
eventType: "AddressChanged",
fromSchemaVersion: 1,
toSchemaVersion: 2,
upcast(data: { street: string }) {
return { ...data, city: "Unknown" };
},
};
// v2 -> v3: Restructured to nested object
const addressV2ToV3: Upcaster = {
eventType: "AddressChanged",
fromSchemaVersion: 2,
toSchemaVersion: 3,
upcast(data: { street: string; city: string }) {
return {
address: { street: data.street, city: data.city },
};
},
};
eventStore.registerUpcasters([addressV1ToV2, addressV2ToV3]);A v1 event stored as { street: "123 Main St" } is automatically transformed through both upcasters on read, producing { address: { street: "123 Main St", city: "Unknown" } }.
How Upcasters Work Internally
- Events are stored with a
schema_versioncolumn (default:1). - The
UpcasterRegistrymaintains aMap<eventType, Upcaster[]>sorted byfromSchemaVersionascending. - On read, for each event:
- Look up the upcaster chain for the event type
- Starting from the stored
schema_version, walk the chain - For each upcaster where
fromSchemaVersion === currentVersion, applyupcast()and advancecurrentVersiontotoSchemaVersion - Events already at the latest version skip upcasters entirely
- Invalid or duplicate transitions are rejected at registration; missing links in a chain throw on read instead of returning an older schema as though it were current.
- The database row is never modified.
| Stored Version | Upcasters Applied | Result Version |
|---|---|---|
| 1 | v1->v2, then v2->v3 | 3 |
| 2 | v2->v3 | 3 |
| 3 | (none) | 3 |
Using Upcasters with Aggregates
Define upcasters in the aggregate definition to keep them co-located with the domain logic:
type OrderState = { total: number; currency: string };
const Order = defineAggregate<OrderState, OrderEvents>()({
streamPrefix: "Order",
evolve: {
/* ... */
},
upcasters: [
{
eventType: "OrderPlaced",
fromSchemaVersion: 1,
toSchemaVersion: 2,
upcast(data: { total: number }) {
return { ...data, currency: "EUR" };
},
},
],
});
// At startup: register all aggregate upcasters
eventStore.registerUpcasters(Order.getUpcasters());Writing New Events with Schema Versions
When you create a new schema version, set the schemaVersion on new events so they don't get upcasted unnecessarily:
await Order.append(eventStore, {
entityId: orderId,
expectedVersion: order.version,
events: [
{
type: "OrderPlaced",
data: { total: 99.99, currency: "USD" },
schemaVersion: 2,
},
],
});If schemaVersion is omitted, it defaults to 1.
Best Practices
- Keep upcasters deterministic — No current date, randomness, external lookups, side effects, or async operations. Derive values from stored data or use a documented fixed default (such as
nullfor an unknown historical timestamp). - Never skip versions — If you have v1, v2, and v3, you need both v1->v2 and v2->v3. Don't create v1->v3 shortcuts.
- Co-locate with aggregates — Define upcasters in the aggregate definition.
- Register at startup — All upcasters must be registered before any read operations.
- Test upcaster chains — Verify that old events are correctly transformed through multiple versions. Missing transitions throw when such an event is read.
Step-by-Step Migration Guide
When you need to change the shape of an existing event type:
Step 1: Define the upcaster
type OrderState = { total: number; currency: string };
const Order = defineAggregate<OrderState, OrderEvents>()({
streamPrefix: "Order",
evolve: {
OrderPlaced: (state, event) => ({
...state,
total: event.data?.total ?? state?.total ?? 0,
currency: event.data?.currency ?? state?.currency ?? "EUR",
}),
},
upcasters: [
{
eventType: "OrderPlaced",
fromSchemaVersion: 1,
toSchemaVersion: 2,
upcast(data: { total: number }) {
return { ...data, currency: "EUR" };
},
},
],
});Step 2: Update the event type map
type OrderEvents = {
OrderPlaced: { total: number; currency: string }; // v2
};Step 3: Register upcasters at startup
await eventStore.setup();
eventStore.registerUpcasters(Order.getUpcasters());Step 4: Update new event writes
await Order.append(eventStore, {
entityId: orderId,
expectedVersion: order.version,
events: [
{
type: "OrderPlaced",
data: { total: 99.99, currency: "USD" },
schemaVersion: 2,
},
],
});Step 5: Deploy
Upcasters let new readers understand old data; they do not make old readers forward-compatible. Optional chaining cannot repair renamed fields, changed types, or changed business meaning.
- Deploy readers and upcasters that understand both versions while writers still emit v1. Include projections, subscription consumers, and rollback candidates; complete projection and subscription events upcast automatically, while redacted events and subscriptions with
raw: trueretain the stored schema. - Verify old-history replay and mixed-version fixtures, then enable v2 writes only after every required reader is compatible.
- Keep the upcasters for historical data. After v2 writes begin, rollback only to a reader version that understands v2, or use a deliberately designed compatibility bridge.
Common Migration Scenarios
Adding a required field
{
eventType: "UserCreated",
fromSchemaVersion: 1,
toSchemaVersion: 2,
upcast(data: { name: string }) {
return { ...data, email: "unknown@migrated.com" };
},
}Renaming a field
{
eventType: "UserCreated",
fromSchemaVersion: 1,
toSchemaVersion: 2,
upcast(data: { userName: string }) {
const { userName, ...rest } = data;
return { ...rest, name: userName };
},
}Restructuring nested data
{
eventType: "AddressChanged",
fromSchemaVersion: 1,
toSchemaVersion: 2,
upcast(data: { street: string; city: string }) {
return { address: { street: data.street, city: data.city } };
},
}Changing a field type
{
eventType: "PaymentReceived",
fromSchemaVersion: 1,
toSchemaVersion: 2,
upcast(data: { amount: string }) {
return { ...data, amount: parseFloat(data.amount) };
},
}Removing a field
{
eventType: "UserCreated",
fromSchemaVersion: 1,
toSchemaVersion: 2,
upcast(data: { name: string; legacyId: string }) {
const { legacyId, ...rest } = data;
return rest;
},
}