Skip to main content

Event Versioning

As your system evolves, event schemas change. Event versioning enables graceful schema evolution without breaking existing data.

Before You Start​

  • .NET 10.0
  • Install the required packages:
    dotnet add package Excalibur.EventSourcing
  • Familiarity with domain events and event stores

Why Version Events?​

Events are immutable and stored forever. When business requirements change:

  • New fields need to be added
  • Existing fields may need different types
  • Fields may become required or optional
  • Event structures may need to split or merge

Payload Changes vs Name Changes​

Two different things get called "versioning", and they have different answers:

  • The payload changed shape — a field added, a type widened, one event split into two. That is what this page is about: handle it with an upcaster.
  • The name changed — the type moved namespace or assembly, or you simply want to call it something else. That is handled by a [MessageNameAlias], described in Stable Message Names.

A message's name is its permanent stored identity, so it carries no version segment. Putting one in conflates the two problems and leaves you with a fresh name for every revision — and every reader, filter and route that used the old one broken.

Versioning Strategies​

Use flexible serialization that ignores unknown properties:

// V1 - Original event
[MessageName("Contoso.Orders.OrderCreated")]
public record OrderCreated(Guid OrderId, string CustomerId, decimal TotalAmount) : DomainEvent;

// V2 - Added field (backward compatible)
[MessageName("Contoso.Orders.OrderCreatedV2")]
public record OrderCreatedV2(Guid OrderId, string CustomerId, decimal TotalAmount, string Currency = "USD") : DomainEvent;

// Configure serializer to handle schema evolution
services.AddJsonSerialization(options =>
{
options.ConfigureSerializer = json =>
{
// Ignore unknown properties when deserializing
json.UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip;
};
});

Strong Schema (Message Upcasters)​

Transform old events to new schema during loading using the upcasting pipeline:

// V1 event (stored in database)
[MessageName("Contoso.Orders.OrderCreatedV1")]
public record OrderCreatedV1 : DomainEvent, IVersionedMessage
{
public Guid OrderId { get; init; }
public string CustomerId { get; init; } = string.Empty;
public decimal TotalAmount { get; init; }

// IVersionedMessage implementation
int IVersionedMessage.Version => 1;
public string MessageType => "OrderCreated"; // Same across all versions
}

// V2 event (current schema)
[MessageName("Contoso.Orders.OrderCreated")]
public record OrderCreated : DomainEvent, IVersionedMessage
{
public Guid OrderId { get; init; }
public string CustomerId { get; init; } = string.Empty;
public Money Total { get; init; } = default!;

// IVersionedMessage implementation
int IVersionedMessage.Version => 2;
public string MessageType => "OrderCreated"; // Same across all versions
}

// Upcaster transforms V1 to V2
public class OrderCreatedV1ToV2 : IMessageUpcaster<OrderCreatedV1, OrderCreated>
{
// Required: Specify version transition
public int FromVersion => 1;
public int ToVersion => 2;

public OrderCreated Upcast(OrderCreatedV1 source)
{
return new OrderCreated
{
OrderId = source.OrderId,
CustomerId = source.CustomerId,
Total = Money.USD(source.TotalAmount),
EventId = source.EventId,
OccurredAt = source.OccurredAt
};
}
}

Upcasting Pipeline Registration​

Register upcasters using the builder pattern:

services.AddExcalibur(excalibur => excalibur.AddEventSourcing(builder =>
{
// Register upcasters via the pipeline builder
builder.AddUpcastingPipeline(upcasting =>
{
// Register individual upcasters
upcasting.RegisterUpcaster<OrderCreatedV1, OrderCreated>(new OrderCreatedV1ToV2());

// Or scan assemblies for all upcasters
upcasting.ScanAssembly(typeof(Program).Assembly);

// Enable automatic upcasting during event replay
upcasting.EnableAutoUpcastOnReplay();
});
}));

Each stored shape is a distinct type, and each type declares its own [MessageName] — the name its data was written under. Nothing is derived from the CLR type, so the upcaster knows which shape it read from the name alone:

[MessageName("Contoso.Orders.OrderCreatedV1")]
public record OrderCreatedV1 : DomainEvent, IVersionedMessage { /* ... */ }

[MessageName("Contoso.Orders.OrderCreated")]
public record OrderCreated : DomainEvent, IVersionedMessage { /* ... */ }

Note that IVersionedMessage.MessageType is the logical message these shapes are revisions of, and is unrelated to the stored [MessageName]. If you are not changing the payload's shape but only what the message is called, you want an alias rather than a new type — see Stable Message Names.

Core Interfaces​

IVersionedMessage​

All versioned events must implement this interface:

public interface IVersionedMessage
{
/// <summary>
/// Schema version (start at 1, increment for breaking changes).
/// </summary>
int Version { get; }

/// <summary>
/// Logical message type name (constant across all versions).
/// Example: "OrderCreated" for OrderCreatedV1, OrderCreatedV2, etc.
/// </summary>
string MessageType { get; }
}

IMessageUpcaster​

Transform old events to new schema:

public interface IMessageUpcaster<in TOld, out TNew>
where TOld : IDispatchMessage, IVersionedMessage
where TNew : IDispatchMessage, IVersionedMessage
{
int FromVersion { get; }
int ToVersion { get; }
TNew Upcast(TOld oldMessage);
}

Common Evolution Patterns​

Adding Fields​

// Safe - add optional fields with defaults
[MessageName("Contoso.Orders.OrderCreated")]
public record OrderCreated(
Guid OrderId,
string CustomerId,
decimal TotalAmount,
string? Currency = "USD", // New optional field
DateTime? EstimatedDelivery = null // New optional field
) : DomainEvent;

Renaming Fields​

// Use JSON property name for backward compatibility
[MessageName("Contoso.Orders.OrderCreated")]
public record OrderCreated(
Guid OrderId,
[property: JsonPropertyName("customerId")]
string BuyerId, // Renamed from CustomerId
decimal TotalAmount
) : DomainEvent;

Changing Types​

// V1: decimal
[MessageName("Contoso.Orders.OrderCreatedV1")]
public record OrderCreatedV1 : DomainEvent, IVersionedMessage
{
public Guid OrderId { get; init; }
public string CustomerId { get; init; } = string.Empty;
public decimal TotalAmount { get; init; }

int IVersionedMessage.Version => 1;
public string MessageType => "OrderCreated";
}

// V2: Money value object
[MessageName("Contoso.Orders.OrderCreated")]
public record OrderCreated : DomainEvent, IVersionedMessage
{
public Guid OrderId { get; init; }
public string CustomerId { get; init; } = string.Empty;
public Money Total { get; init; } = default!;

int IVersionedMessage.Version => 2;
public string MessageType => "OrderCreated";
}

// Upcaster
public class OrderCreatedUpcaster : IMessageUpcaster<OrderCreatedV1, OrderCreated>
{
public int FromVersion => 1;
public int ToVersion => 2;

public OrderCreated Upcast(OrderCreatedV1 source)
{
return new OrderCreated
{
OrderId = source.OrderId,
CustomerId = source.CustomerId,
Total = Money.USD(source.TotalAmount),
EventId = source.EventId,
OccurredAt = source.OccurredAt
};
}
}

Splitting Events​

When a single event needs to become multiple events, handle this in your aggregate's event application or use a custom upcaster that returns multiple events:

// V1: Single event with multiple concerns
[MessageName("Contoso.Orders.OrderProcessedV1")]
public record OrderProcessedV1 : DomainEvent, IVersionedMessage
{
public Guid OrderId { get; init; }
public string Status { get; init; } = string.Empty;
public DateTime? ShippedAt { get; init; }
public string? TrackingNumber { get; init; }

int IVersionedMessage.Version => 1;
public string MessageType => "OrderProcessed";
}

// V2: Split into focused events
[MessageName("Contoso.Orders.OrderStatusChanged")]
public record OrderStatusChanged(Guid OrderId, string Status) : DomainEvent;

[MessageName("Contoso.Orders.OrderShipped")]
public record OrderShipped(Guid OrderId, string TrackingNumber, DateTime ShippedAt) : DomainEvent;

// Handle V1 events in aggregate by treating as multiple logical events
protected override bool ApplyEventInternal(IDomainEvent @event)
{
switch (@event)
{
case OrderProcessedV1 e:
// Apply status change
Status = e.Status;
// Apply shipping if present
if (e.ShippedAt.HasValue)
{
TrackingNumber = e.TrackingNumber;
ShippedAt = e.ShippedAt;
}
return true;
case OrderStatusChanged e:
Status = e.Status;
return true;
case OrderShipped e:
TrackingNumber = e.TrackingNumber;
ShippedAt = e.ShippedAt;
return true;
default:
return false;
}
}

Merging Events​

When separate events should become a single event, the aggregate handles both old and new patterns:

// V1: Separate events
[MessageName("Contoso.Orders.AddressChangedV1")]
public record AddressChangedV1(Guid OrderId, Address NewAddress) : DomainEvent;

[MessageName("Contoso.Orders.ContactChangedV1")]
public record ContactChangedV1(Guid OrderId, string Email, string Phone) : DomainEvent;

// V2: Combined event
[MessageName("Contoso.Orders.CustomerDetailsUpdated")]
public record CustomerDetailsUpdated(
Guid OrderId,
Address? NewAddress = null,
string? Email = null,
string? Phone = null) : DomainEvent;

Upcaster Pipeline​

Multiple upcasters chain automatically:

// Event evolution: V1 -> V2 -> V3
services.AddExcalibur(excalibur => excalibur.AddEventSourcing(builder =>
{
builder.AddUpcastingPipeline(upcasting =>
{
upcasting.RegisterUpcaster<OrderCreatedV1, OrderCreatedV2>(new V1ToV2Upcaster()); // 1 -> 2
upcasting.RegisterUpcaster<OrderCreatedV2, OrderCreated>(new V2ToV3Upcaster()); // 2 -> 3
});
}));

// Loading V1 event automatically upgrades through chain:
// V1 -> V2 -> V3 (current)

Aggregate Compatibility​

Update aggregate to handle all versions:

public class Order : AggregateRoot<Guid>
{
public string CustomerId { get; private set; } = string.Empty;
public Money Total { get; private set; } = default!;

private Order() { }

protected override bool ApplyEventInternal(IDomainEvent @event)
{
switch (@event)
{
// Handle current version
case OrderCreated e:
Id = e.OrderId;
CustomerId = e.CustomerId;
Total = e.Total;
return true;

// Handle legacy version (if upcaster not configured)
case OrderCreatedV1 e:
Id = e.OrderId;
CustomerId = e.CustomerId;
Total = Money.USD(e.TotalAmount);
return true;

default:
return false;
}
}
}

Testing Event Versioning​

public class EventVersioningTests
{
[Fact]
public void Upcaster_Transforms_V1_To_V2()
{
// Arrange
var orderId = Guid.NewGuid();
var v1Event = new OrderCreatedV1
{
OrderId = orderId,
CustomerId = "customer-1",
TotalAmount = 100m
};
var upcaster = new OrderCreatedUpcaster();

// Act
var v2Event = upcaster.Upcast(v1Event);

// Assert
v2Event.OrderId.Should().Be(v1Event.OrderId);
v2Event.CustomerId.Should().Be(v1Event.CustomerId);
v2Event.Total.Amount.Should().Be(100m);
v2Event.Total.ISOCurrencyCode.Should().Be("USD");
}

[Fact]
public async Task Aggregate_Loads_From_Mixed_Versions()
{
// Arrange - use DI to get configured repository with upcasting
var services = new ServiceCollection();
services.AddExcalibur(excalibur => excalibur.AddEventSourcing(builder =>
{
builder.UseEventStore<InMemoryEventStore>();
builder.AddRepository<Order, Guid>();
builder.AddUpcastingPipeline(upcasting =>
{
upcasting.RegisterUpcaster<OrderCreatedV1, OrderCreated>(
new OrderCreatedUpcaster());
upcasting.EnableAutoUpcastOnReplay();
});
}));

var sp = services.BuildServiceProvider();
var repository = sp.GetRequiredService<IEventSourcedRepository<Order, Guid>>();

// Create order with V1 event (simulating legacy data)
var orderId = Guid.NewGuid();
var order = Order.Create(orderId, "customer-1");
await repository.SaveAsync(order, CancellationToken.None);

// Act - Load triggers automatic upcasting
var loaded = await repository.GetByIdAsync(orderId, CancellationToken.None);

// Assert
loaded.Should().NotBeNull();
loaded!.CustomerId.Should().Be("customer-1");
}
}

Best Practices​

Do​

  • Use optional fields with defaults for new properties
  • Keep upgraders simple and stateless
  • Test upgraders thoroughly
  • Document breaking changes
  • Keep the [MessageName] stable across payload revisions; rename only via [MessageNameAlias]

Don't​

  • Remove fields from events
  • Change the meaning of existing fields
  • Make optional fields required
  • Store upgrader state
  • Skip versions in upgrade chain

Migration Checklist​

When evolving an event schema:

  1. Create new event type implementing IVersionedMessage:
    • [MessageName] attribute declaring the name this shape is stored under
    • int Version property (increment from previous)
    • string MessageType property (same as previous version)
  2. Implement upcaster from previous version (IMessageUpcaster<TOld, TNew>):
    • int FromVersion property
    • int ToVersion property
    • TNew Upcast(TOld) method
  3. Register upcaster via AddUpcastingPipeline
  4. Update aggregate's ApplyEventInternal
  5. Update projections to handle both versions
  6. Test with mixed-version event streams
  7. Deploy to staging and verify
  8. Monitor upcasting performance in production

Next Steps​

See Also​

  • Version Upgrades — Guide for upgrading across framework versions
  • Projections — How projections handle versioned events and schema changes
  • Migrations — Database migration strategies for event store schema changes
  • Serialization — Serialization configuration that underpins event versioning