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 classwith[EventUpcaster]is discovered - Any
public staticmethod 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.