Skip to content

Event Upcasting

Event upcasting transforms old event versions to current versions at read time, without rewriting stored data. The [EventUpcaster] attribute marks a static class as an upcaster container, and the generator builds a switch-based pipeline applied during event deserialization.

Overview

Events are immutable facts — once stored, they should never be modified. But event schemas evolve over time. Upcasting bridges the gap: old events are transparently transformed to the current schema when read.

using Deepstaging.EventStore;

// V1 event (no longer used in handlers, but still in the store)
public record OrderCreatedV1(OrderId Id, string CustomerName);

// V2 event (current — added Currency field)
public sealed record OrderCreated(
    OrderId Id, string CustomerName, string Currency) : IAggregateEvent;

[EventUpcaster]
public static class OrderEventUpcasters
{
    public static OrderCreated Upcast(OrderCreatedV1 old) =>
        new(old.Id, old.CustomerName, Currency: "USD");
}

The generator produces a pipeline that applies upcasters before events reach your aggregates:

// Generated
internal static class AppEventStoreUpcasters
{
    internal static object ApplyUpcasters(object @event) => @event switch
    {
        OrderCreatedV1 v => OrderEventUpcasters.Upcast(v),
        _ => @event
    };
}

[EventUpcaster]

Marks a static class as an upcaster container. No properties — all public static methods are discovered as upcasting rules.

[EventUpcaster]
public static class OrderEventUpcasters
{
    // Each method: old event type → current event type
    public static OrderCreated Upcast(OrderCreatedV1 old) =>
        new(old.Id, old.CustomerName, Currency: "USD");

    public static OrderShipped Upcast(OrderShippedV1 old) =>
        new(old.Id, old.TrackingNumber, Carrier: "Unknown");
}

Method Requirements

Each upcasting method must:

  • Be public static
  • Accept exactly one parameter (the old event type)
  • Return the new event type

The method name can be anything (Upcast is conventional but not required).

Multiple Upcasters

You can have multiple [EventUpcaster] classes. All are discovered and combined into a single pipeline:

[EventUpcaster]
public static class OrderEventUpcasters
{
    public static OrderCreated Upcast(OrderCreatedV1 old) =>
        new(old.Id, old.CustomerName, Currency: "USD");
}

[EventUpcaster]
public static class ShippingEventUpcasters
{
    public static OrderShipped Upcast(OrderShippedV1 old) =>
        new(old.Id, old.TrackingNumber, Carrier: "Unknown");
}

Generated pipeline:

internal static object ApplyUpcasters(object @event) => @event switch
{
    OrderCreatedV1 v => OrderEventUpcasters.Upcast(v),
    OrderShippedV1 v => ShippingEventUpcasters.Upcast(v),
    _ => @event
};

Chained Upcasting

For events that have gone through multiple versions, chain upcasters:

// V1 → V2 → V3 (current)
public record OrderCreatedV1(Guid Id, string Name);
public record OrderCreatedV2(OrderId Id, string Name, string Currency);
public sealed record OrderCreated(
    OrderId Id, string CustomerName, string Currency) : IAggregateEvent;

[EventUpcaster]
public static class OrderEventUpcasters
{
    // V1 → V2
    public static OrderCreatedV2 Upcast(OrderCreatedV1 old) =>
        new(new OrderId(old.Id), old.Name, "USD");

    // V2 → V3 (current)
    public static OrderCreated Upcast(OrderCreatedV2 old) =>
        new(old.Id, old.Name, old.Currency);
}

The pipeline applies matching upcasters repeatedly until no more match, so V1 events are transformed through V1 → V2 → V3.

Design Principles

Non-Destructive

Upcasting happens at read time. The original events remain in the store exactly as they were written. This means:

  • No data migrations required
  • Rollbacks are safe — old code can still read old events
  • Audit trail is preserved

Compile-Time Pipeline

The upcaster pipeline is generated at compile time as a switch expression. No reflection, no runtime type discovery. If an old event type is referenced that doesn't exist, you get a compiler error.

Convention Over Configuration

  • Any static class with [EventUpcaster] is discovered
  • Any public static method with one parameter is an upcasting rule
  • The parameter type determines which events are matched
  • The return type determines the output

Complete Example

using Deepstaging.EventStore;
using Deepstaging.Ids;

[TypedId] [StreamId] public readonly partial struct OrderId;

// Current events
public sealed record OrderCreated(
    OrderId Id, string CustomerName, string Currency) : IAggregateEvent;
public sealed record OrderShipped(
    OrderId Id, string TrackingNumber, string Carrier) : IAggregateEvent;

// Legacy event versions (kept for deserialization compatibility)
public record OrderCreatedV1(OrderId Id, string CustomerName);
public record OrderShippedV1(OrderId Id, string TrackingNumber);

// Upcasters
[EventUpcaster]
public static class OrderEventUpcasters
{
    public static OrderCreated Upcast(OrderCreatedV1 old) =>
        new(old.Id, old.CustomerName, Currency: "USD");

    public static OrderShipped Upcast(OrderShippedV1 old) =>
        new(old.Id, old.TrackingNumber, Carrier: "Unknown");
}

// Aggregate — only handles current event versions
[EventSourcedAggregate]
public partial record Order(OrderId Id, string CustomerName, string Currency)
{
    public static Order Create(OrderCreated e) =>
        new(e.Id, e.CustomerName, e.Currency);
}

[EventStore]
public static partial class AppEventStore;

Old OrderCreatedV1 events stored in the database are transparently upcasted to OrderCreated before reaching the aggregate's Create method. No migration needed.