Skip to main content

Handling Event Versioning in Event Sourcing: Implementing Upcasting

 The "Immutability Tax" is the price we pay for the benefits of Event Sourcing. You have a pristine history of everything that happened in your system, stored as immutable facts. But business requirements change.

A classic scenario: You have millions of UserRegistered events stored over three years. Originally, you captured a single name field. Today, the business requires distinct firstName and lastName fields for marketing personalization.

In a CRUD system, you would write a SQL migration script to split the column. In Event Sourcing, you cannot change the past. The events in your store are immutable. If you deploy code that expects firstName but the event store feeds it a JSON blob containing only name, your deserializer will throw an exception, or your Aggregate will receive undefined values and crash the system during replay.

Rewriting the event store (modification in place) violates the core tenet of the pattern. Creating a new stream and migrating data is operational heavy lifting that requires downtime.

The robust solution is Upcasting.

The Root Cause: Schema-Code Mismatch

The failure occurs at the boundary between your Persistence Infrastructure and your Domain Layer.

  1. Storage: The Event Store holds a serialized JSON payload: {"name": "John Doe", "email": "john@example.com"} linked to version 1.
  2. Runtime: Your current Domain Event class UserRegistered requires:
    constructor(
      public readonly firstName: string, 
      public readonly lastName: string,
      public readonly email: string
    ) {}
    
  3. Deserialization: When the application reads the stream to reconstruct the Aggregate state, the JSON mapper fails to find firstName in the V1 payload.

The system crashes because the historical data no longer adheres to the current invariant. We need a transformation layer that adapts history to the present without modifying the storage.

The Solution: Lazy Upcasting

Upcasting creates an intermediary layer in your deserialization pipeline. It intercepts raw events retrieved from the database and, if they detect an older schema version, creates a "virtual" event in the new format before passing it to the domain.

This happens in memory, on the fly. The database remains untouched.

Step 1: Define the Event Schemas

We treat event shapes as explicit contracts. Here is our evolution from V1 to V2.

// type-defs.ts

// Metadata usually stored alongside the event payload in the DB
export interface EventEnvelope {
  eventId: string;
  eventType: string;
  schemaVersion: number;
  payload: unknown;
  occurredAt: Date;
}

// The Legacy Event Structure (V1)
// This is what is physically sitting in the DB
export interface UserRegisteredV1 {
  userId: string;
  fullName: string; // The problematic field
  email: string;
}

// The Current Event Structure (V2)
// This is what our Domain Logic expects to use
export class UserRegistered {
  constructor(
    public readonly userId: string,
    public readonly firstName: string,
    public readonly lastName: string,
    public readonly email: string
  ) {}
}

Step 2: Implement the Upcaster

An Upcaster is a pure function or a stateless class that takes a generic JSON payload and the metadata, transforms it, and returns the new structure.

We handle the logic of splitting the name here. Note that this logic is effectively a migration script that runs every time the event is read.

// upcasters/user-registered-upcaster.ts

import { UserRegisteredV1, EventEnvelope } from './type-defs';

export class UserRegisteredUpcaster {
  // Target version this upcaster produces
  public readonly targetVersion = 2;
  
  // The event type string this upcaster applies to
  public readonly eventType = 'UserRegistered';

  public upcast(envelope: EventEnvelope): Record<string, any> {
    // Safety check (in production, this might be handled by a registry)
    if (envelope.schemaVersion !== 1) {
      throw new Error(`Upcaster expects V1, received V${envelope.schemaVersion}`);
    }

    const oldPayload = envelope.payload as UserRegisteredV1;

    // Transformation Logic: Split the full name
    // Fallback handling for edge cases (e.g., single word names)
    const nameParts = oldPayload.fullName.trim().split(' ');
    const firstName = nameParts[0];
    const lastName = nameParts.length > 1 ? nameParts.slice(1).join(' ') : '';

    console.debug(`[Upcasting] Converting UserRegistered V1 -> V2 for ID: ${oldPayload.userId}`);

    // Return the shape of V2 (raw object, not class instance yet)
    return {
      userId: oldPayload.userId,
      email: oldPayload.email,
      firstName: firstName,
      lastName: lastName,
    };
  }
}

Step 3: The Event Serializer Pipeline

This is where the magic happens. Your event repository or serializer needs to be aware of upcasters. When fetching events, it checks the version number. If the stored version is lower than the code version, it pipes the data through the upcaster.

// infrastructure/event-serializer.ts

import { EventEnvelope, UserRegistered } from './type-defs';
import { UserRegisteredUpcaster } from './upcasters/user-registered-upcaster';

export class EventSerializer {
  private upcasters = new Map<string, UserRegisteredUpcaster>();

  constructor() {
    // Register upcasters. In a real app, use DI to auto-register.
    const upcaster = new UserRegisteredUpcaster();
    this.upcasters.set(upcaster.eventType, upcaster);
  }

  public deserialize(envelope: EventEnvelope): any {
    let payload = envelope.payload;
    let version = envelope.schemaVersion;

    // Check if an upcaster exists for this event type
    const upcaster = this.upcasters.get(envelope.eventType);

    // If upcaster exists and the event version is older than what the upcaster targets
    if (upcaster && version < upcaster.targetVersion) {
      // 1. Transform the JSON payload
      payload = upcaster.upcast(envelope);
      // 2. Bump the version locally (in-memory only)
      version = upcaster.targetVersion;
    }

    // Now mapped to the latest JSON structure, we can safely hydrate the Domain Object
    return this.hydrateDomainEvent(envelope.eventType, payload);
  }

  private hydrateDomainEvent(eventType: string, payload: any): any {
    switch (eventType) {
      case 'UserRegistered':
        return new UserRegistered(
          payload.userId,
          payload.firstName,
          payload.lastName,
          payload.email
        );
      default:
        throw new Error(`Unknown event type: ${eventType}`);
    }
  }
}

Step 4: Simulating the Replay

Here is how you would use this infrastructure code to read an old event from the "database" and get a valid V2 object.

// main.ts
import { EventSerializer } from './infrastructure/event-serializer';
import { EventEnvelope } from './type-defs';

// 1. Simulate a raw event fetch from a database (e.g., EventStoreDB, PostgreSQL)
const rawEventFromDb: EventEnvelope = {
  eventId: 'evt_123456789',
  eventType: 'UserRegistered',
  schemaVersion: 1, // <--- OLD VERSION
  occurredAt: new Date('2021-01-01T10:00:00Z'),
  payload: {
    userId: 'user_999',
    fullName: 'Grace Hopper', // <--- OLD SCHEMA
    email: 'grace@navy.mil'
  }
};

// 2. Initialize Serializer
const serializer = new EventSerializer();

// 3. Deserialize
try {
  const domainEvent = serializer.deserialize(rawEventFromDb);
  
  console.log('Successfully rehydrated domain event:');
  console.log(domainEvent);
  
  /* Output:
     UserRegistered {
       userId: 'user_999',
       firstName: 'Grace',
       lastName: 'Hopper',
       email: 'grace@navy.mil'
     }
  */
  
  console.log(`Is instance of UserRegistered? ${domainEvent.constructor.name === 'UserRegistered'}`); // true

} catch (error) {
  console.error('Deserialization failed:', error);
}

Why This Works

1. Decoupled Persistence and Logic

The database acts strictly as a transaction log. It is not required to conform to the current object model of the application code. The Serializer/Upcaster layer acts as an Anti-Corruption Layer (ACL) between the persistent history and the current domain model.

2. Zero Downtime Deployment

Because the transformation happens in memory on read:

  1. You deploy the new UserRegistered class and the UserRegisteredUpcaster.
  2. The application starts.
  3. As soon as an Aggregate loads, it reads old V1 events, upcasts them to V2, and applies them to the state.
  4. No database migration scripts are required. The "migration" is distributed over time, occurring only when data is accessed.

3. Chaining

If, a year later, you move to V3 (e.g., splitting email into localPart and domain), you create a UserRegisteredV2ToV3Upcaster. The Serializer simply runs the chain: V1 -> V2 -> V3.

Conclusion

Event Sourcing requires a shift in mindset regarding data evolution. You cannot use ALTER TABLE. Instead, you must accept that your application will handle multiple versions of facts simultaneously.

Upcasting moves the complexity of schema migration from the database layer (ops) to the application layer (dev). It makes your domain model resilient to change while preserving the semantic integrity of your immutable history. Implement upcasters as strictly typed, pure functions, and your event streams will remain maintainable indefinitely.