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

View C# snippet source on GitHub

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

View C# snippet source on GitHub

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

View C# snippet source on GitHub

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

View C# snippet source on GitHub

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

View C# snippet source on GitHub

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

View C# snippet source on GitHub

An event’s payload is rarely flat — a value object groups related values, and that inner record evolves on its own schedule. Every operation above addresses such a property by the expression you would write anyway:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Migrations;
public sealed record MigrationsDotnetClientNestedPrice(decimal Amount, string Description);
public sealed record MigrationsDotnetClientNestedPriceV1(decimal Amount);
[EventType("dotnet-client-nested-price-set", generation: 2)]
public record MigrationsDotnetClientNestedPriceSet(MigrationsDotnetClientNestedPrice Price);
[EventTypeGenerationFor<MigrationsDotnetClientNestedPriceSet>(1)]
public record MigrationsDotnetClientNestedPriceSetV1(MigrationsDotnetClientNestedPriceV1 Price);
public class MigrationsDotnetClientNestedPriceSetMigration : EventTypeMigration<MigrationsDotnetClientNestedPriceSet, MigrationsDotnetClientNestedPriceSetV1>
{
public override void Upcast(IEventMigrationBuilder<MigrationsDotnetClientNestedPriceSet, MigrationsDotnetClientNestedPriceSetV1> builder) =>
builder.Properties(pb => pb.DefaultValue(m => m.Price.Description, "unspecified"));
public override void Downcast(IEventMigrationBuilder<MigrationsDotnetClientNestedPriceSetV1, MigrationsDotnetClientNestedPriceSet> builder)
{
// Description does not exist in generation 1 - the schema drops it on the way back
}
}

View C# snippet source on GitHub

A typed expression such as e => e.Price.Description identifies CLR members. Chronicle resolves each member to its JSON name: [JsonPropertyName] takes precedence, otherwise the client’s JSON naming policy applies. Without explicit names, a camelCase policy produces price.description. Chronicle resolves that payload path segment by segment: reading walks into the object, writing creates the objects along the way when the stored payload does not carry them yet. That also lets a migration restructure a payload — RenamedFrom(m => m.Price.Amount, e => e.Amount) moves a value that used to sit at the top level into the object it now belongs to.

This resolution applies to typed source and target expressions for every operation, including bidirectional value maps. Raw string paths on IEventMigrationPropertyBuilder are already JSON paths and remain exactly as supplied. For example, a raw path price.description is not converted again, even with a custom naming policy. Use the exact names in the serialized payload, including explicit [JsonPropertyName] names. Existing raw-string migrations need no naming-policy upgrade workaround.

Separators, default values, and mapped values are literals and are not renamed. These builders describe property paths, not arbitrary custom-converter transformations.

A path that goes nowhere is rejected at registration with InvalidMigrationPropertyForEventType, naming the path so you can see which segment is wrong.

Adding or removing an optional property — at any depth — needs a migration class, but no property operations inside it:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Migrations;
public sealed record MigrationsDotnetClientOptionalNote(string Text, string? Author = null);
public sealed record MigrationsDotnetClientOptionalNoteV1(string Text);
[EventType("dotnet-client-optional-note-added", generation: 2)]
public record MigrationsDotnetClientNoteAdded(MigrationsDotnetClientOptionalNote Note);
[EventTypeGenerationFor<MigrationsDotnetClientNoteAdded>(1)]
public record MigrationsDotnetClientNoteAddedV1(MigrationsDotnetClientOptionalNoteV1 Note);
public class MigrationsDotnetClientNoteAddedMigration : EventTypeMigration<MigrationsDotnetClientNoteAdded, MigrationsDotnetClientNoteAddedV1>
{
// Author is optional, so the target generation's schema fills it in on the way up
// and drops it on the way down. The class only has to exist.
public override void Upcast(IEventMigrationBuilder<MigrationsDotnetClientNoteAdded, MigrationsDotnetClientNoteAddedV1> builder)
{
}
public override void Downcast(IEventMigrationBuilder<MigrationsDotnetClientNoteAddedV1, MigrationsDotnetClientNoteAdded> builder)
{
}
}

View C# snippet source on GitHub

This looks like something is missing, and it isn’t. Chronicle converts a payload by walking the target generation’s schema: upcasting materializes a property the older payload never carried as absent, and downcasting drops one the older generation does not declare. The class only has to exist so the generation chain is complete — see Validation: missing migrators.

Reach for DefaultValue when absent is the wrong answer and you want a specific value instead.

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

View C# snippet source on GitHub

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

View C# snippet source on GitHub

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

View C# snippet source on GitHub

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.