C# client usage
This guide covers how to declare event type migrations in a .NET client, the operations available to you, and what happens when your migrators are registered with the Chronicle Kernel.
Prerequisites
Section titled “Prerequisites”- A Chronicle-enabled .NET application
- An event type marked with
[EventType]that has evolved beyond generation 1
Marking event types with generations
Section titled “Marking event types with generations”Every [EventType] that has evolved past its first version must declare its current generation. You keep both the old and new record types in your codebase — Chronicle identifies them by the shared event type identifier, not the C# class name.
using Cratis.Chronicle.Events;
// Generation 1 (original) — no explicit generation needed, defaults to 1[EventType]public record MigrationsDotnetClientAuthorRegisteredV1(string Name);
// Generation 2 — Name has been split into FirstName and LastName[EventType("dotnet-client-author-registered", generation: 2)]public record MigrationsDotnetClientAuthorRegistered(string FirstName, string LastName);Both records carry the same event type identifier (derived from the type name base). The generation number is what tells Chronicle how to route migrations.
Defining a migrator
Section titled “Defining a migrator”Extend EventTypeMigration<TUpgrade, TPrevious> where TUpgrade is the newer generation and TPrevious is the older one:
using Cratis.Chronicle.Events.Migrations;
public class MigrationsDotnetClientAuthorRegisteredMigration : EventTypeMigration<MigrationsDotnetClientAuthorRegistered, MigrationsDotnetClientAuthorRegisteredV1>{ public override void Upcast(IEventMigrationBuilder<MigrationsDotnetClientAuthorRegistered, MigrationsDotnetClientAuthorRegisteredV1> 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<MigrationsDotnetClientAuthorRegisteredV1, MigrationsDotnetClientAuthorRegistered> builder) => builder.Properties(pb => pb .Combine(m => m.Name, PropertySeparator.Space, e => e.FirstName, e => e.LastName));}The From and To generation numbers are read automatically from the [EventType] attributes on TPrevious and TUpgrade. You do not declare them yourself, and the base class validates at construction time that To == From + 1, preventing accidental generation gaps.
Migrators are discovered automatically at startup — no explicit registration is needed.
Migration operations
Section titled “Migration operations”All operations are called on the property builder inside builder.Properties(pb => ...). Each call declares one output property using a target property expression and one or more source property expressions drawn from the opposite generation’s record.
Extracts one segment of a string property by splitting on a separator and taking the part at a given index.
using Cratis.Chronicle.Events;using Cratis.Chronicle.Events.Migrations;
[EventType]public record MigrationsDotnetClientSplitPersonRegisteredV1(string FullName);
[EventType("dotnet-client-person-registered", generation: 2)]public record MigrationsDotnetClientSplitPersonRegistered(string FirstName, string LastName);
public class MigrationsDotnetClientSplitPersonRegisteredMigration : EventTypeMigration<MigrationsDotnetClientSplitPersonRegistered, MigrationsDotnetClientSplitPersonRegisteredV1>{ public override void Upcast(IEventMigrationBuilder<MigrationsDotnetClientSplitPersonRegistered, MigrationsDotnetClientSplitPersonRegisteredV1> builder) => builder.Properties(pb => pb .Split(t => t.FirstName, s => s.FullName, PropertySeparator.Space, SplitPartIndex.First) .Split(t => t.LastName, s => s.FullName, PropertySeparator.Space, SplitPartIndex.Second));
public override void Downcast(IEventMigrationBuilder<MigrationsDotnetClientSplitPersonRegisteredV1, MigrationsDotnetClientSplitPersonRegistered> builder) => builder.Properties(pb => pb .Combine(t => t.FullName, PropertySeparator.Space, s => s.FirstName, s => s.LastName));}PropertySeparator.Space is a built-in constant. Any string is also implicitly convertible to a PropertySeparator, so ":" works directly for colon-delimited fields. SplitPartIndex.First (index 0) and SplitPartIndex.Second (index 1) cover the most common cases; pass any int for deeper splits.
Combine
Section titled “Combine”Concatenates multiple source properties into a single string target property, joining with a separator. The second argument is a PropertySeparator — use the built-in PropertySeparator.Space or pass any string (e.g. ":").
using Cratis.Chronicle.Events;using Cratis.Chronicle.Events.Migrations;
[EventType]public record MigrationsDotnetClientCombineShippingAddressRecordedV1(string Street, string City);
[EventType("dotnet-client-shipping-address-recorded", generation: 2)]public record MigrationsDotnetClientCombineShippingAddressRecorded(string FullAddress);
public class MigrationsDotnetClientCombineShippingAddressRecordedMigration : EventTypeMigration<MigrationsDotnetClientCombineShippingAddressRecorded, MigrationsDotnetClientCombineShippingAddressRecordedV1>{ public override void Upcast(IEventMigrationBuilder<MigrationsDotnetClientCombineShippingAddressRecorded, MigrationsDotnetClientCombineShippingAddressRecordedV1> builder) => builder.Properties(pb => pb .Combine(t => t.FullAddress, PropertySeparator.Space, s => s.Street, s => s.City));
public override void Downcast(IEventMigrationBuilder<MigrationsDotnetClientCombineShippingAddressRecordedV1, MigrationsDotnetClientCombineShippingAddressRecorded> builder) => builder.Properties(pb => pb .Split(t => t.Street, s => s.FullAddress, PropertySeparator.Space, SplitPartIndex.First) .Split(t => t.City, s => s.FullAddress, PropertySeparator.Space, SplitPartIndex.Second));}RenamedFrom
Section titled “RenamedFrom”Maps a property from its old name in the source generation to its new name in the target generation.
using Cratis.Chronicle.Events;using Cratis.Chronicle.Events.Migrations;
[EventType]public record MigrationsDotnetClientRenamedFromCustomerRegisteredV1(string EmailAddress);
[EventType("dotnet-client-customer-registered", generation: 2)]public record MigrationsDotnetClientRenamedFromCustomerRegistered(string Email);
public class MigrationsDotnetClientRenamedFromCustomerRegisteredMigration : EventTypeMigration<MigrationsDotnetClientRenamedFromCustomerRegistered, MigrationsDotnetClientRenamedFromCustomerRegisteredV1>{ public override void Upcast(IEventMigrationBuilder<MigrationsDotnetClientRenamedFromCustomerRegistered, MigrationsDotnetClientRenamedFromCustomerRegisteredV1> builder) => builder.Properties(pb => pb .RenamedFrom(t => t.Email, s => s.EmailAddress));
public override void Downcast(IEventMigrationBuilder<MigrationsDotnetClientRenamedFromCustomerRegisteredV1, MigrationsDotnetClientRenamedFromCustomerRegistered> builder) => builder.Properties(pb => pb .RenamedFrom(t => t.EmailAddress, s => s.Email));}DefaultValue
Section titled “DefaultValue”Provides a literal default for a property that did not exist in the source generation. Chronicle applies this value to any event stored before the property was introduced.
using Cratis.Chronicle.Events;using Cratis.Chronicle.Events.Migrations;
[EventType]public record MigrationsDotnetClientDefaultValueTaskCreatedV1(string Title);
[EventType("dotnet-client-task-created", generation: 2)]public record MigrationsDotnetClientDefaultValueTaskCreated(string Title, string Status, int RetryCount, bool Enabled);
public class MigrationsDotnetClientDefaultValueTaskCreatedMigration : EventTypeMigration<MigrationsDotnetClientDefaultValueTaskCreated, MigrationsDotnetClientDefaultValueTaskCreatedV1>{ public override void Upcast(IEventMigrationBuilder<MigrationsDotnetClientDefaultValueTaskCreated, MigrationsDotnetClientDefaultValueTaskCreatedV1> builder) => builder.Properties(pb => pb .DefaultValue(t => t.Status, "active") .DefaultValue(t => t.RetryCount, 0) .DefaultValue(t => t.Enabled, true));
public override void Downcast(IEventMigrationBuilder<MigrationsDotnetClientDefaultValueTaskCreatedV1, MigrationsDotnetClientDefaultValueTaskCreated> builder) { // Status, RetryCount, and Enabled did not exist in generation 1 — nothing to map back }}Multi-generation migrations
Section titled “Multi-generation migrations”If your event type spans more than two generations, define one migrator per generation pair. Chronicle chains them automatically.
using Cratis.Chronicle.Events;using Cratis.Chronicle.Events.Migrations;
[EventType]public record MigrationsDotnetClientMultiGenPersonRegisteredV1(string EmailAddress, string Name);
[EventType("dotnet-client-multi-gen-person-registered", generation: 2)]public record MigrationsDotnetClientMultiGenPersonRegisteredV2(string Email, string Name);
[EventType("dotnet-client-multi-gen-person-registered", generation: 3)]public record MigrationsDotnetClientMultiGenPersonRegistered(string Email, string FirstName, string LastName);
// Generation 1 → 2: rename EmailAddress to Emailpublic class MigrationsDotnetClientMultiGenPersonRegisteredV1ToV2 : EventTypeMigration<MigrationsDotnetClientMultiGenPersonRegisteredV2, MigrationsDotnetClientMultiGenPersonRegisteredV1>{ public override void Upcast(IEventMigrationBuilder<MigrationsDotnetClientMultiGenPersonRegisteredV2, MigrationsDotnetClientMultiGenPersonRegisteredV1> builder) => builder.Properties(pb => pb .RenamedFrom(t => t.Email, s => s.EmailAddress));
public override void Downcast(IEventMigrationBuilder<MigrationsDotnetClientMultiGenPersonRegisteredV1, MigrationsDotnetClientMultiGenPersonRegisteredV2> builder) => builder.Properties(pb => pb .RenamedFrom(t => t.EmailAddress, s => s.Email));}
// Generation 2 → 3: split Name into FirstName / LastNamepublic class MigrationsDotnetClientMultiGenPersonRegisteredV2ToV3 : EventTypeMigration<MigrationsDotnetClientMultiGenPersonRegistered, MigrationsDotnetClientMultiGenPersonRegisteredV2>{ public override void Upcast(IEventMigrationBuilder<MigrationsDotnetClientMultiGenPersonRegistered, MigrationsDotnetClientMultiGenPersonRegisteredV2> builder) => builder.Properties(pb => pb .Split(t => t.FirstName, s => s.Name, PropertySeparator.Space, SplitPartIndex.First) .Split(t => t.LastName, s => s.Name, PropertySeparator.Space, SplitPartIndex.Second));
public override void Downcast(IEventMigrationBuilder<MigrationsDotnetClientMultiGenPersonRegisteredV2, MigrationsDotnetClientMultiGenPersonRegistered> builder) => builder.Properties(pb => pb .Combine(t => t.Name, PropertySeparator.Space, s => s.FirstName, s => s.LastName));}When a generation 1 event arrives, the Kernel chains the upcasts: 1→2, then 2→3, and stores all three generations.
How registration works
Section titled “How registration works”When your application connects to Chronicle, the client:
- Discovers all
EventTypeMigration<TUpgrade, TPrevious>implementations viaIClientArtifactsProvider - Invokes
UpcastandDowncaston each migrator to capture the transformation declarations - Converts the declarations into JmesPath expressions
- Sends the complete
EventTypeDefinition— including all generations and their migration definitions — to the Kernel during event type registration
From that point on, the Kernel applies the migrations autonomously on every event append, without any further involvement from the client.
Validation: missing migrators
Section titled “Validation: missing migrators”If an event type is declared with a generation higher than 1 but has no migrators covering all generations up to the current one, Chronicle throws MissingEventTypeMigrators during startup. This prevents silent data loss from an incomplete migration chain.
Cratis.Chronicle.Events.Migrations.MissingEventTypeMigrators: Event type 'AuthorRegistered' is at generation 3 but no migrators are registered for it.Ensure every generation gap has a corresponding EventTypeMigration<TUpgrade, TPrevious> subclass before deploying an event type with a new generation.