Alvyn logoAlvyn

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:

  1. Migrate stored events — Modify existing rows in the database. This breaks the immutability guarantee and is error-prone at scale.
  2. 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

  1. Events are stored with a schema_version column (default: 1).
  2. The UpcasterRegistry maintains a Map<eventType, Upcaster[]> sorted by fromSchemaVersion ascending.
  3. 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, apply upcast() and advance currentVersion to toSchemaVersion
    • Events already at the latest version skip upcasters entirely
  4. The database row is never modified.
Stored VersionUpcasters AppliedResult Version
1v1->v2, then v2->v33
2v2->v33
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

  1. Keep upcasters pure — Simple data transformations with no side effects or async operations.
  2. Never skip versions — If you have v1, v2, and v3, you need both v1->v2 and v2->v3. Don't create v1->v3 shortcuts.
  3. Co-locate with aggregates — Define upcasters in the aggregate definition.
  4. Register at startup — All upcasters must be registered before any read operations.
  5. 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;
  },
}

On this page