Schema Evolution
Read-time upcasters that transform old event schemas into newer versions without modifying stored data.
Schema Evolution (Upcasters)
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 "@lox-solutions/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
- 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 pure — Simple data transformations with no side effects or async operations.
- 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.
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,
currency: event.data?.currency ?? state.currency,
}),
},
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 are backward-compatible by design. The deployment order does not matter:
- New code reads old events -> upcasted transparently
- Old code reads old events -> works as before
- Old code reads new v2 events -> use optional chaining in evolve handlers for safety
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;
},
}