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.

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.

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.

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.

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

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

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
}
}

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 Email
public 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 / LastName
public 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.

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.