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.
// Generation 1 (original) — no explicit generation needed, defaults to 1[EventType]public record AuthorRegisteredV1(string Name);
// Generation 2 — Name has been split into FirstName and LastName[EventType(generation: 2)]public record AuthorRegistered(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;using Cratis.Chronicle.Events.Migrations;
public class AuthorRegisteredMigration : EventTypeMigration<AuthorRegistered, AuthorRegisteredV1>{ public override void Upcast(IEventMigrationBuilder<AuthorRegistered, AuthorRegisteredV1> builder) => builder.Properties(pb => pb .Split(e => e.FirstName, s => s.Name, PropertySeparator.Space, SplitPartIndex.First) .Split(e => e.LastName, s => s.Name, PropertySeparator.Space, SplitPartIndex.Second));
public override void Downcast(IEventMigrationBuilder<AuthorRegisteredV1, AuthorRegistered> builder) => builder.Properties(pb => pb .Combine(e => e.Name, PropertySeparator.Space, s => s.FirstName, s => s.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.
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));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. ":").
builder.Properties(pb => pb .Combine(t => t.FullName, PropertySeparator.Space, s => s.FirstName, s => s.LastName));RenamedFrom
Section titled “RenamedFrom”Maps a property from its old name in the source generation to its new name in the target generation.
builder.Properties(pb => pb .RenamedFrom(t => t.Email, s => s.EmailAddress));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.
builder.Properties(pb => pb .DefaultValue(t => t.Status, "active") .DefaultValue(t => t.RetryCount, 0) .DefaultValue(t => t.Enabled, true));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.
// Generation 1 → 2: rename Emailpublic class PersonRegisteredV1ToV2 : EventTypeMigration<PersonRegisteredV2, PersonRegisteredV1>{ public override void Upcast(IEventMigrationBuilder<PersonRegisteredV2, PersonRegisteredV1> builder) => builder.Properties(pb => pb .RenamedFrom(t => t.Email, s => s.EmailAddress));
public override void Downcast(IEventMigrationBuilder<PersonRegisteredV1, PersonRegisteredV2> builder) => builder.Properties(pb => pb .RenamedFrom(t => t.EmailAddress, s => s.Email));}
// Generation 2 → 3: split Name into FirstName / LastNamepublic class PersonRegisteredV2ToV3 : EventTypeMigration<PersonRegistered, PersonRegisteredV2>{ public override void Upcast(IEventMigrationBuilder<PersonRegistered, PersonRegisteredV2> 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<PersonRegisteredV2, PersonRegistered> 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.