Skip to content

Event Type Migrations

Event type migrations enable you to evolve your event schemas over time while maintaining compatibility with existing events. When an event type changes, you can define upcasters and downcasters that automatically transform events between different generations.

In evolving systems, event schemas naturally change:

  • Properties are added or removed
  • Properties are renamed
  • Complex properties are split or combined

Chronicle’s migration system allows you to:

  1. Define declarative transformation rules
  2. Automatically store all generations of an event when appending
  3. Read events in any generation format

To define a migration, extend the EventTypeMigration<TUpgrade, TPrevious> base class (it handles generation extraction and validation for you) and override Upcast/Downcast:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Migrations;
[EventType("author-registered", generation: 2)]
public record MigrationsAuthorRegistered(string FirstName, string LastName);
[EventTypeGenerationFor<MigrationsAuthorRegistered>(1)]
public record MigrationsAuthorRegisteredV1(string Name);
public class MigrationsAuthorRegisteredMigration : EventTypeMigration<MigrationsAuthorRegistered, MigrationsAuthorRegisteredV1>
{
public override void Upcast(IEventMigrationBuilder<MigrationsAuthorRegistered, MigrationsAuthorRegisteredV1> builder) =>
builder.Properties(pb => pb
.Split(m => m.FirstName, e => e.Name, PropertySeparator.Space, SplitPartIndex.First)
.Split(m => m.LastName, e => e.Name, PropertySeparator.Space, SplitPartIndex.Second));
public override void Downcast(IEventMigrationBuilder<MigrationsAuthorRegisteredV1, MigrationsAuthorRegistered> builder) =>
builder.Properties(pb => pb
.Combine(m => m.Name, PropertySeparator.Space, e => e.FirstName, e => e.LastName));
}

The previous generation, MigrationsAuthorRegisteredV1, carries [EventTypeGenerationFor<MigrationsAuthorRegistered>(1)] instead of its own [EventType]. The event type id it resolves to comes straight from MigrationsAuthorRegistered’s own [EventType] — it is never independently typed on the previous-generation record, so the two generations cannot drift apart by a hand-typed, mismatched id.

The migration builder supports the following operations:

Splits a source property into parts using a separator:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Migrations;
[EventType("person-registered", generation: 2)]
public record MigrationsSplitPersonRegistered(string FirstName, string LastName);
[EventTypeGenerationFor<MigrationsSplitPersonRegistered>(1)]
public record MigrationsSplitPersonRegisteredV1(string FullName);
public class MigrationsSplitPersonRegisteredMigration : EventTypeMigration<MigrationsSplitPersonRegistered, MigrationsSplitPersonRegisteredV1>
{
public override void Upcast(IEventMigrationBuilder<MigrationsSplitPersonRegistered, MigrationsSplitPersonRegisteredV1> builder) =>
builder.Properties(pb => pb
.Split(m => m.FirstName, e => e.FullName, PropertySeparator.Space, SplitPartIndex.First) // Gets first part
.Split(m => m.LastName, e => e.FullName, PropertySeparator.Space, SplitPartIndex.Second)); // Gets second part
public override void Downcast(IEventMigrationBuilder<MigrationsSplitPersonRegisteredV1, MigrationsSplitPersonRegistered> builder) =>
builder.Properties(pb => pb
.Combine(m => m.FullName, PropertySeparator.Space, e => e.FirstName, e => e.LastName));
}

Combines multiple source properties into a single value using a separator:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Migrations;
[EventType("shipping-address-recorded", generation: 2)]
public record MigrationsCombineShippingAddressRecorded(string FormattedAddress);
[EventTypeGenerationFor<MigrationsCombineShippingAddressRecorded>(1)]
public record MigrationsCombineShippingAddressRecordedV1(string Street, string City);
public class MigrationsCombineShippingAddressRecordedMigration : EventTypeMigration<MigrationsCombineShippingAddressRecorded, MigrationsCombineShippingAddressRecordedV1>
{
public override void Upcast(IEventMigrationBuilder<MigrationsCombineShippingAddressRecorded, MigrationsCombineShippingAddressRecordedV1> builder) =>
builder.Properties(pb => pb
.Combine(m => m.FormattedAddress, PropertySeparator.Space, e => e.Street, e => e.City)); // Joins with space separator
public override void Downcast(IEventMigrationBuilder<MigrationsCombineShippingAddressRecordedV1, MigrationsCombineShippingAddressRecorded> builder) =>
builder.Properties(pb => pb
.Split(m => m.Street, e => e.FormattedAddress, PropertySeparator.Space, SplitPartIndex.First)
.Split(m => m.City, e => e.FormattedAddress, PropertySeparator.Space, SplitPartIndex.Second));
}

Renames a property from a previous name using RenamedFrom:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Migrations;
[EventType("payment-processed", generation: 2)]
public record MigrationsRenamePaymentProcessed(decimal Amount);
[EventTypeGenerationFor<MigrationsRenamePaymentProcessed>(1)]
public record MigrationsRenamePaymentProcessedV1(decimal OldAmount);
public class MigrationsRenamePaymentProcessedMigration : EventTypeMigration<MigrationsRenamePaymentProcessed, MigrationsRenamePaymentProcessedV1>
{
public override void Upcast(IEventMigrationBuilder<MigrationsRenamePaymentProcessed, MigrationsRenamePaymentProcessedV1> builder) =>
builder.Properties(pb => pb
.RenamedFrom(m => m.Amount, e => e.OldAmount));
public override void Downcast(IEventMigrationBuilder<MigrationsRenamePaymentProcessedV1, MigrationsRenamePaymentProcessed> builder) =>
builder.Properties(pb => pb
.RenamedFrom(m => m.OldAmount, e => e.Amount));
}

Sets a default value for a new property:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Migrations;
[EventType("order-shipped", generation: 2)]
public record MigrationsDefaultValueOrderShipped(string TrackingNumber, int RetryCount, string Description);
[EventTypeGenerationFor<MigrationsDefaultValueOrderShipped>(1)]
public record MigrationsDefaultValueOrderShippedV1(string TrackingNumber);
public class MigrationsDefaultValueOrderShippedMigration : EventTypeMigration<MigrationsDefaultValueOrderShipped, MigrationsDefaultValueOrderShippedV1>
{
public override void Upcast(IEventMigrationBuilder<MigrationsDefaultValueOrderShipped, MigrationsDefaultValueOrderShippedV1> builder) =>
builder.Properties(pb => pb
.DefaultValue(m => m.RetryCount, 42)
.DefaultValue(m => m.Description, "default string"));
public override void Downcast(IEventMigrationBuilder<MigrationsDefaultValueOrderShippedV1, MigrationsDefaultValueOrderShipped> builder)
{
// RetryCount and Description did not exist in generation 1 — nothing to map back
}
}

The operations above move values between properties. A value map changes the values themselves — it states which value in one generation is which value in the other. This is what you reach for when an enum is renumbered, or a status code takes on a new set of values, and the old numbers now mean something the new generation spells differently.

Override MapValues on the migration rather than declaring it inside Upcast or Downcast:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Migrations;
public enum MigrationsSubscriptionStateV1
{
Unknown = 0,
Active = 1,
Cancelled = 2
}
public enum MigrationsSubscriptionState
{
Unspecified = 100,
Running = 101,
Stopped = 102
}
[EventType("subscription-state-changed", generation: 2)]
public record MigrationsSubscriptionStateChanged(MigrationsSubscriptionState State);
[EventTypeGenerationFor<MigrationsSubscriptionStateChanged>(1)]
public record MigrationsSubscriptionStateChangedV1(MigrationsSubscriptionStateV1 State);
public class MigrationsSubscriptionStateChangedMigration : EventTypeMigration<MigrationsSubscriptionStateChanged, MigrationsSubscriptionStateChangedV1>
{
public override void Upcast(IEventMigrationBuilder<MigrationsSubscriptionStateChanged, MigrationsSubscriptionStateChangedV1> builder)
{
// The value map covers State in both directions
}
public override void Downcast(IEventMigrationBuilder<MigrationsSubscriptionStateChangedV1, MigrationsSubscriptionStateChanged> builder)
{
// The value map covers State in both directions
}
public override void MapValues(IEventValueMapBuilder<MigrationsSubscriptionStateChanged, MigrationsSubscriptionStateChangedV1> builder) =>
builder.For(current => current.State, previous => previous.State, map => map
.Map(MigrationsSubscriptionStateV1.Unknown, MigrationsSubscriptionState.Unspecified)
.Map(MigrationsSubscriptionStateV1.Active, MigrationsSubscriptionState.Running)
.Map(MigrationsSubscriptionStateV1.Cancelled, MigrationsSubscriptionState.Stopped));
}

A map declared this way is applied in both directions: forward when upcasting, inverted when downcasting. The pair of values is one fact about the two generations, so you state it once and the two directions cannot drift apart.

Three things are worth knowing:

  • Values the map does not mention are carried across unchanged. A map says what changed meaning; it is not an exhaustive listing of an enum that is free to grow.
  • Two values collapsing onto one have no single inverse, so the first pair declared for a target value is the one that represents it going back.
  • The map does not have to stay on the same property. The two property expressions may name different properties, in which case the map also carries the value across — covering a rename and a re-valuing in one statement.

MapValues is applied before Upcast and Downcast run, so a direction that states its own transformation for the same property keeps it.

When an event is appended to the event store:

  1. Chronicle identifies the event’s current generation
  2. The migration system retrieves all registered migrations for the event type
  3. Upcasting: If there are higher generations, the event is transformed upward (1→2→3)
  4. Downcasting: If there are lower generations, the event is transformed downward (3→2→1)
  5. All generations are stored in the event sequence

This ensures that:

  • Older consumers can still read events in their expected format
  • Newer consumers can read events with the latest schema
  • No data is lost during schema evolution

Migrations are automatically discovered and registered when you connect to Chronicle. Simply implement IEventTypeMigrationFor<TEvent> in your client application, and Chronicle will:

  1. Discover all migrators via IClientArtifactsProvider
  2. Build migration definitions with JmesPath transformations
  3. Send the definitions to the kernel during event type registration

Two runtime checks and two code analysis rules exist specifically to stop a broken migration before it ever reaches a real event log:

  • MigrationGenerationsMustShareEventTypeId — thrown from EventTypeMigration<TUpgrade, TPrevious>’s constructor the moment TUpgrade and TPrevious resolve to different event type ids. This turns the old style’s classic mistake — an omitted or mismatched id on the previous generation — into a loud failure at application startup instead of a confusing, silent rejection at the kernel.
  • MultipleMigratorsForSameEventTypeGeneration — thrown by EventTypeMigrators.GetMigratorsFor when two different migrator classes both claim to bridge the same generation pair for one event type. Exactly one migrator may own a generation transition.
  • CHR0037 flags a migration whose two generations don’t resolve to the same event type at compile time — including validating, for the new attribute, that [EventTypeGenerationFor<T>]’s T really is the migration’s TUpgrade.
  • CHR0049 flags [EventTypeGenerationFor<T>] when T isn’t itself marked with [EventType] — the referenced type must carry the real identity directly, not another generation marker.
  1. Mark previous generations with [EventTypeGenerationFor<T>]: it resolves the event type id from the current generation’s own [EventType], so the two can never drift apart the way a hand-typed id on both generations can
  2. Incremental generations: Always migrate between consecutive generations (1→2, 2→3, not 1→3)
  3. Reversible transformations: Ensure downcast can recreate the original structure where possible
  4. Default values: Use DefaultValue() for new properties that didn’t exist in older generations
  5. Test migrations: Verify both upcast and downcast transformations work correctly
  6. Document changes: Keep track of what changed between generations in your event types