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 as the same event type by a shared event type id, not by C# class name.

using Cratis.Chronicle.Events;
// Generation 2 (current) — Name has been split into FirstName and LastName
[EventType("dotnet-client-author-registered", generation: 2)]
public record MigrationsDotnetClientAuthorRegistered(string FirstName, string LastName);
// Generation 1 (original) — marked as a previous generation of the current record above,
// instead of carrying its own [EventType]
[EventTypeGenerationFor<MigrationsDotnetClientAuthorRegistered>(1)]
public record MigrationsDotnetClientAuthorRegisteredV1(string Name);

The current generation, MigrationsDotnetClientAuthorRegistered, carries the real [EventType] with its explicit id. The previous generation, MigrationsDotnetClientAuthorRegisteredV1, carries [EventTypeGenerationFor<MigrationsDotnetClientAuthorRegistered>(1)] instead — it has no independent id of its own at all. Its event type id is resolved directly from the current generation’s [EventType], so the two can never end up with different ids no matter how the records are renamed later.

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 TPrevious’s [EventTypeGenerationFor<T>] (or its [EventType], if you’re using the old style) and from TUpgrade’s [EventType]. You do not declare them yourself. The base class constructor also validates, at that point, that both generations resolve to the same event type id (throwing MigrationGenerationsMustShareEventTypeId immediately if they don’t) and that To == From + 1 (throwing InvalidMigrationGenerationGap otherwise), preventing both an accidental identity mismatch and a generation gap.

Migrators are discovered automatically at startup — no explicit registration is needed. If two different migrator classes both claim to bridge the same generation pair for one event type, EventTypeMigrators.GetMigratorsFor throws MultipleMigratorsForSameEventTypeGeneration — exactly one migrator may own a given transition.

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("dotnet-client-person-registered", generation: 2)]
public record MigrationsDotnetClientSplitPersonRegistered(string FirstName, string LastName);
[EventTypeGenerationFor<MigrationsDotnetClientSplitPersonRegistered>(1)]
public record MigrationsDotnetClientSplitPersonRegisteredV1(string FullName);
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("dotnet-client-shipping-address-recorded", generation: 2)]
public record MigrationsDotnetClientCombineShippingAddressRecorded(string FullAddress);
[EventTypeGenerationFor<MigrationsDotnetClientCombineShippingAddressRecorded>(1)]
public record MigrationsDotnetClientCombineShippingAddressRecordedV1(string Street, string City);
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("dotnet-client-customer-registered", generation: 2)]
public record MigrationsDotnetClientRenamedFromCustomerRegistered(string Email);
[EventTypeGenerationFor<MigrationsDotnetClientRenamedFromCustomerRegistered>(1)]
public record MigrationsDotnetClientRenamedFromCustomerRegisteredV1(string EmailAddress);
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("dotnet-client-task-created", generation: 2)]
public record MigrationsDotnetClientDefaultValueTaskCreated(string Title, string Status, int RetryCount, bool Enabled);
[EventTypeGenerationFor<MigrationsDotnetClientDefaultValueTaskCreated>(1)]
public record MigrationsDotnetClientDefaultValueTaskCreatedV1(string Title);
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
}
}

The four operations above move values between properties. MapValues changes the values themselves — it says which value in one generation is which value in the other, which is what you need when an enum is renumbered or a code set is replaced wholesale.

Unlike the property-builder operations, a value map is declared by overriding MapValues on the migration:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Migrations;
public enum MigrationsDotnetClientPaymentStatusV1
{
Pending = 0,
Settled = 1
}
public enum MigrationsDotnetClientPaymentStatus
{
Awaiting = 10,
Completed = 11
}
[EventType("dotnet-client-payment-processed", generation: 2)]
public record MigrationsDotnetClientPaymentProcessed(MigrationsDotnetClientPaymentStatus Status);
[EventTypeGenerationFor<MigrationsDotnetClientPaymentProcessed>(1)]
public record MigrationsDotnetClientPaymentProcessedV1(MigrationsDotnetClientPaymentStatusV1 Status);
public class MigrationsDotnetClientPaymentProcessedMigration : EventTypeMigration<MigrationsDotnetClientPaymentProcessed, MigrationsDotnetClientPaymentProcessedV1>
{
public override void Upcast(IEventMigrationBuilder<MigrationsDotnetClientPaymentProcessed, MigrationsDotnetClientPaymentProcessedV1> builder)
{
// Status is covered by the value map
}
public override void Downcast(IEventMigrationBuilder<MigrationsDotnetClientPaymentProcessedV1, MigrationsDotnetClientPaymentProcessed> builder)
{
// Status is covered by the value map
}
public override void MapValues(IEventValueMapBuilder<MigrationsDotnetClientPaymentProcessed, MigrationsDotnetClientPaymentProcessedV1> builder) =>
builder.For(current => current.Status, previous => previous.Status, map => map
.Map(MigrationsDotnetClientPaymentStatusV1.Pending, MigrationsDotnetClientPaymentStatus.Awaiting)
.Map(MigrationsDotnetClientPaymentStatusV1.Settled, MigrationsDotnetClientPaymentStatus.Completed));
}

The map is applied forward when upcasting and inverted when downcasting, so you state it once. Values it does not mention are carried across unchanged, and two values collapsing onto one take the first pair declared for that value on the way back.

MapValues runs before Upcast and Downcast, so a direction that declares its own transformation for the same property keeps it. That is also the way to express a translation that is not symmetric — several values collapsing onto one, where the way back has to make a choice the forward map cannot state. Declare that as a MapValues on the property builder, per direction:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Migrations;
public enum MigrationsDotnetClientDeclineReasonV1
{
InsufficientFunds = 0,
CardExpired = 1,
CardReported = 2
}
public enum MigrationsDotnetClientDeclineReason
{
Funds = 0,
Card = 1
}
[EventType("dotnet-client-payment-declined", generation: 2)]
public record MigrationsDotnetClientPaymentDeclined(MigrationsDotnetClientDeclineReason Reason);
[EventTypeGenerationFor<MigrationsDotnetClientPaymentDeclined>(1)]
public record MigrationsDotnetClientPaymentDeclinedV1(MigrationsDotnetClientDeclineReasonV1 Reason);
public class MigrationsDotnetClientPaymentDeclinedMigration : EventTypeMigration<MigrationsDotnetClientPaymentDeclined, MigrationsDotnetClientPaymentDeclinedV1>
{
public override void Upcast(IEventMigrationBuilder<MigrationsDotnetClientPaymentDeclined, MigrationsDotnetClientPaymentDeclinedV1> builder) =>
builder.Properties(pb => pb
.MapValues(current => current.Reason, previous => previous.Reason, map => map
.Map(MigrationsDotnetClientDeclineReasonV1.InsufficientFunds, MigrationsDotnetClientDeclineReason.Funds)
.Map(MigrationsDotnetClientDeclineReasonV1.CardExpired, MigrationsDotnetClientDeclineReason.Card)
.Map(MigrationsDotnetClientDeclineReasonV1.CardReported, MigrationsDotnetClientDeclineReason.Card)));
public override void Downcast(IEventMigrationBuilder<MigrationsDotnetClientPaymentDeclinedV1, MigrationsDotnetClientPaymentDeclined> builder) =>
builder.Properties(pb => pb
.MapValues(previous => previous.Reason, current => current.Reason, map => map
.Map(MigrationsDotnetClientDeclineReason.Funds, MigrationsDotnetClientDeclineReasonV1.InsufficientFunds)
.Map(MigrationsDotnetClientDeclineReason.Card, MigrationsDotnetClientDeclineReasonV1.CardExpired)));
}

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("dotnet-client-multi-gen-person-registered", generation: 3)]
public record MigrationsDotnetClientMultiGenPersonRegistered(string Email, string FirstName, string LastName);
[EventTypeGenerationFor<MigrationsDotnetClientMultiGenPersonRegistered>(2)]
public record MigrationsDotnetClientMultiGenPersonRegisteredV2(string Email, string Name);
[EventTypeGenerationFor<MigrationsDotnetClientMultiGenPersonRegistered>(1)]
public record MigrationsDotnetClientMultiGenPersonRegisteredV1(string EmailAddress, string Name);
// 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.