Skip to content

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.

  • A Chronicle-enabled .NET application
  • An event type marked with [EventType] that has evolved beyond generation 1

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.

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.

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.

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));

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));

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));

If your event type spans more than two generations, define one migrator per generation pair. Chronicle chains them automatically.

// Generation 1 → 2: rename Email
public 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 / LastName
public 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.

When your application connects to Chronicle, the client:

  1. Discovers all EventTypeMigration<TUpgrade, TPrevious> implementations via IClientArtifactsProvider
  2. Invokes Upcast and Downcast on each migrator to capture the transformation declarations
  3. Converts the declarations into JmesPath expressions
  4. 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.

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.