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.
Why Migrations?
Section titled “Why Migrations?”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:
- Define declarative transformation rules
- Automatically store all generations of an event when appending
- Read events in any generation format
Defining Migrations
Section titled “Defining Migrations”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]public record MigrationsAuthorRegisteredV1(string Name);
[EventType("author-registered", generation: 2)]public record MigrationsAuthorRegistered(string FirstName, string LastName);
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));}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.Events.MigrationsAuthorRegisteredV1 do use Chronicle.Events.EventType, id: "author-registered", generation: 1
defstruct [:name]end
defmodule MyApp.Events.MigrationsAuthorRegistered do use Chronicle.Events.EventType, id: "author-registered", generation: 2
defstruct [:first_name, :last_name]end
defmodule MyApp.Migrations.MigrationsAuthorRegisteredMigration do use Chronicle.Events.Migration, from: {MyApp.Events.MigrationsAuthorRegisteredV1, generation: 1}, to: {MyApp.Events.MigrationsAuthorRegistered, generation: 2}
alias Chronicle.Events.MigrationBuilder
@impl true def upcast(builder) do builder |> MigrationBuilder.split_property(:name, :first_name, " ", 0) |> MigrationBuilder.split_property(:name, :last_name, " ", 1) end
@impl true def downcast(builder) do builder |> MigrationBuilder.combine_properties([:first_name, :last_name], :name, " ") endendimport { eventType, eventTypeMigration, IEventTypeMigration, IEventMigrationBuilder } from '@cratis/chronicle';
@eventType()class MigrationsAuthorRegisteredV1 { constructor(readonly name: string) {}}
@eventType('author-registered', 2)class MigrationsAuthorRegistered { constructor( readonly firstName: string, readonly lastName: string ) {}}
@eventTypeMigration(MigrationsAuthorRegistered, MigrationsAuthorRegisteredV1)class MigrationsAuthorRegisteredMigration implements IEventTypeMigration<MigrationsAuthorRegistered, MigrationsAuthorRegisteredV1> { upcast(builder: IEventMigrationBuilder<MigrationsAuthorRegistered, MigrationsAuthorRegisteredV1>): void { builder.properties(pb => pb .split('firstName', 'name', ' ', 0) .split('lastName', 'name', ' ', 1)); }
downcast(builder: IEventMigrationBuilder<MigrationsAuthorRegisteredV1, MigrationsAuthorRegistered>): void { builder.properties(pb => pb .combine('name', ' ', 'firstName', 'lastName')); }}Kotlin and Java don’t currently have a migration API — there’s no equivalent to EventTypeMigration<TUpgrade, TPrevious> in the Kotlin client SDK.
Migration Operations
Section titled “Migration Operations”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]public record MigrationsSplitPersonRegisteredV1(string FullName);
[EventType("person-registered", generation: 2)]public record MigrationsSplitPersonRegistered(string FirstName, string LastName);
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));}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.Events.MigrationsSplitPersonRegisteredV1 do use Chronicle.Events.EventType, id: "person-registered", generation: 1
defstruct [:full_name]end
defmodule MyApp.Events.MigrationsSplitPersonRegistered do use Chronicle.Events.EventType, id: "person-registered", generation: 2
defstruct [:first_name, :last_name]end
defmodule MyApp.Migrations.MigrationsSplitPersonRegisteredMigration do use Chronicle.Events.Migration, from: {MyApp.Events.MigrationsSplitPersonRegisteredV1, generation: 1}, to: {MyApp.Events.MigrationsSplitPersonRegistered, generation: 2}
alias Chronicle.Events.MigrationBuilder
@impl true def upcast(builder) do builder # Gets first part |> MigrationBuilder.split_property(:full_name, :first_name, " ", 0) # Gets second part |> MigrationBuilder.split_property(:full_name, :last_name, " ", 1) end
@impl true def downcast(builder) do builder |> MigrationBuilder.combine_properties([:first_name, :last_name], :full_name, " ") endendimport { eventType, eventTypeMigration, IEventTypeMigration, IEventMigrationBuilder } from '@cratis/chronicle';
@eventType()class MigrationsSplitPersonRegisteredV1 { constructor(readonly fullName: string) {}}
@eventType('person-registered', 2)class MigrationsSplitPersonRegistered { constructor( readonly firstName: string, readonly lastName: string ) {}}
@eventTypeMigration(MigrationsSplitPersonRegistered, MigrationsSplitPersonRegisteredV1)class MigrationsSplitPersonRegisteredMigration implements IEventTypeMigration<MigrationsSplitPersonRegistered, MigrationsSplitPersonRegisteredV1> { upcast(builder: IEventMigrationBuilder<MigrationsSplitPersonRegistered, MigrationsSplitPersonRegisteredV1>): void { builder.properties(pb => pb .split('firstName', 'fullName', ' ', 0) // Gets first part .split('lastName', 'fullName', ' ', 1)); // Gets second part }
downcast(builder: IEventMigrationBuilder<MigrationsSplitPersonRegisteredV1, MigrationsSplitPersonRegistered>): void { builder.properties(pb => pb .combine('fullName', ' ', 'firstName', 'lastName')); }}Combine
Section titled “Combine”Combines multiple source properties into a single value using a separator:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Events.Migrations;
[EventType]public record MigrationsCombineShippingAddressRecordedV1(string Street, string City);
[EventType("shipping-address-recorded", generation: 2)]public record MigrationsCombineShippingAddressRecorded(string FormattedAddress);
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));}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.Events.MigrationsCombineShippingAddressRecordedV1 do use Chronicle.Events.EventType, id: "shipping-address-recorded", generation: 1
defstruct [:street, :city]end
defmodule MyApp.Events.MigrationsCombineShippingAddressRecorded do use Chronicle.Events.EventType, id: "shipping-address-recorded", generation: 2
defstruct [:formatted_address]end
defmodule MyApp.Migrations.MigrationsCombineShippingAddressRecordedMigration do use Chronicle.Events.Migration, from: {MyApp.Events.MigrationsCombineShippingAddressRecordedV1, generation: 1}, to: {MyApp.Events.MigrationsCombineShippingAddressRecorded, generation: 2}
alias Chronicle.Events.MigrationBuilder
@impl true def upcast(builder) do builder # Joins with space separator |> MigrationBuilder.combine_properties([:street, :city], :formatted_address, " ") end
@impl true def downcast(builder) do builder |> MigrationBuilder.split_property(:formatted_address, :street, " ", 0) |> MigrationBuilder.split_property(:formatted_address, :city, " ", 1) endendimport { eventType, eventTypeMigration, IEventTypeMigration, IEventMigrationBuilder } from '@cratis/chronicle';
@eventType()class MigrationsCombineShippingAddressRecordedV1 { constructor( readonly street: string, readonly city: string ) {}}
@eventType('shipping-address-recorded', 2)class MigrationsCombineShippingAddressRecorded { constructor(readonly formattedAddress: string) {}}
@eventTypeMigration(MigrationsCombineShippingAddressRecorded, MigrationsCombineShippingAddressRecordedV1)class MigrationsCombineShippingAddressRecordedMigration implements IEventTypeMigration<MigrationsCombineShippingAddressRecorded, MigrationsCombineShippingAddressRecordedV1> { upcast(builder: IEventMigrationBuilder<MigrationsCombineShippingAddressRecorded, MigrationsCombineShippingAddressRecordedV1>): void { builder.properties(pb => pb .combine('formattedAddress', ' ', 'street', 'city')); // Joins with space separator }
downcast(builder: IEventMigrationBuilder<MigrationsCombineShippingAddressRecordedV1, MigrationsCombineShippingAddressRecorded>): void { builder.properties(pb => pb .split('street', 'formattedAddress', ' ', 0) .split('city', 'formattedAddress', ' ', 1)); }}Rename
Section titled “Rename”Renames a property from a previous name using RenamedFrom:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Events.Migrations;
[EventType]public record MigrationsRenamePaymentProcessedV1(decimal OldAmount);
[EventType("payment-processed", generation: 2)]public record MigrationsRenamePaymentProcessed(decimal Amount);
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));}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.Events.MigrationsRenamePaymentProcessedV1 do use Chronicle.Events.EventType, id: "payment-processed", generation: 1
defstruct [:old_amount]end
defmodule MyApp.Events.MigrationsRenamePaymentProcessed do use Chronicle.Events.EventType, id: "payment-processed", generation: 2
defstruct [:amount]end
defmodule MyApp.Migrations.MigrationsRenamePaymentProcessedMigration do use Chronicle.Events.Migration, from: {MyApp.Events.MigrationsRenamePaymentProcessedV1, generation: 1}, to: {MyApp.Events.MigrationsRenamePaymentProcessed, generation: 2}
alias Chronicle.Events.MigrationBuilder
@impl true def upcast(builder) do builder |> MigrationBuilder.rename_property(:old_amount, :amount) end
@impl true def downcast(builder) do builder |> MigrationBuilder.rename_property(:amount, :old_amount) endendimport { eventType, eventTypeMigration, IEventTypeMigration, IEventMigrationBuilder } from '@cratis/chronicle';
@eventType()class MigrationsRenamePaymentProcessedV1 { constructor(readonly oldAmount: number) {}}
@eventType('payment-processed', 2)class MigrationsRenamePaymentProcessed { constructor(readonly amount: number) {}}
@eventTypeMigration(MigrationsRenamePaymentProcessed, MigrationsRenamePaymentProcessedV1)class MigrationsRenamePaymentProcessedMigration implements IEventTypeMigration<MigrationsRenamePaymentProcessed, MigrationsRenamePaymentProcessedV1> { upcast(builder: IEventMigrationBuilder<MigrationsRenamePaymentProcessed, MigrationsRenamePaymentProcessedV1>): void { builder.properties(pb => pb .renamedFrom('amount', 'oldAmount')); }
downcast(builder: IEventMigrationBuilder<MigrationsRenamePaymentProcessedV1, MigrationsRenamePaymentProcessed>): void { builder.properties(pb => pb .renamedFrom('oldAmount', 'amount')); }}Default Value
Section titled “Default Value”Sets a default value for a new property:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Events.Migrations;
[EventType]public record MigrationsDefaultValueOrderShippedV1(string TrackingNumber);
[EventType("order-shipped", generation: 2)]public record MigrationsDefaultValueOrderShipped(string TrackingNumber, int RetryCount, string Description);
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 }}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.Events.MigrationsDefaultValueOrderShippedV1 do use Chronicle.Events.EventType, id: "order-shipped", generation: 1
defstruct [:tracking_number]end
defmodule MyApp.Events.MigrationsDefaultValueOrderShipped do use Chronicle.Events.EventType, id: "order-shipped", generation: 2
defstruct [:tracking_number, :retry_count, :description]end
defmodule MyApp.Migrations.MigrationsDefaultValueOrderShippedMigration do use Chronicle.Events.Migration, from: {MyApp.Events.MigrationsDefaultValueOrderShippedV1, generation: 1}, to: {MyApp.Events.MigrationsDefaultValueOrderShipped, generation: 2}
alias Chronicle.Events.MigrationBuilder
@impl true def upcast(builder) do builder |> MigrationBuilder.default_value(:retry_count, 42) |> MigrationBuilder.default_value(:description, "default string") end
@impl true def downcast(builder) do # retry_count and description did not exist in generation 1 — nothing to map back builder endendimport { eventType, eventTypeMigration, IEventTypeMigration, IEventMigrationBuilder } from '@cratis/chronicle';
@eventType()class MigrationsDefaultValueOrderShippedV1 { constructor(readonly trackingNumber: string) {}}
@eventType('order-shipped', 2)class MigrationsDefaultValueOrderShipped { constructor( readonly trackingNumber: string, readonly retryCount: number, readonly description: string ) {}}
@eventTypeMigration(MigrationsDefaultValueOrderShipped, MigrationsDefaultValueOrderShippedV1)class MigrationsDefaultValueOrderShippedMigration implements IEventTypeMigration<MigrationsDefaultValueOrderShipped, MigrationsDefaultValueOrderShippedV1> { upcast(builder: IEventMigrationBuilder<MigrationsDefaultValueOrderShipped, MigrationsDefaultValueOrderShippedV1>): void { builder.properties(pb => pb .defaultValue('retryCount', 42) .defaultValue('description', 'default string')); }
downcast(_builder: IEventMigrationBuilder<MigrationsDefaultValueOrderShippedV1, MigrationsDefaultValueOrderShipped>): void { // retryCount and description did not exist in generation 1 — nothing to map back }}How Migrations Work
Section titled “How Migrations Work”When an event is appended to the event store:
- Chronicle identifies the event’s current generation
- The migration system retrieves all registered migrations for the event type
- Upcasting: If there are higher generations, the event is transformed upward (1→2→3)
- Downcasting: If there are lower generations, the event is transformed downward (3→2→1)
- 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
Registration
Section titled “Registration”Migrations are automatically discovered and registered when you connect to Chronicle.
Simply implement IEventTypeMigrationFor<TEvent> in your client application, and
Chronicle will:
- Discover all migrators via
IClientArtifactsProvider - Build migration definitions with JmesPath transformations
- Send the definitions to the kernel during event type registration
Best Practices
Section titled “Best Practices”- Incremental generations: Always migrate between consecutive generations (1→2, 2→3, not 1→3)
- Reversible transformations: Ensure downcast can recreate the original structure where possible
- Default values: Use
DefaultValue()for new properties that didn’t exist in older generations - Test migrations: Verify both upcast and downcast transformations work correctly
- Document changes: Keep track of what changed between generations in your event types