Event Type Migrations
Chronicle stores events forever. When an event's schema must change, you write a migration rather than editing the original record — Chronicle auto-discovers migrations and applies them when reading old events.
You only need this once events of the prior shape exist somewhere you can't regenerate (a real environment's event log). Before that — in greenfield development with disposable data — rename event types and change schemas freely; a migration just adds dead code that hides the real schema in
git log.
When you need it
- An
[EventType]needs a new required property (adding it would break observers reading old events). - A property is renamed; old events carry the old name.
- The event shape changes structurally.
Never add a nullable value type to an
[EventType]to represent "absent on old events" — Chronicle's analyzer warns on nullable event members. Add a migration with a default instead.
Generations
Every [EventType] has a generation (starts at 1). Each schema change increments it. Chronicle routes stored events through the migration chain before delivering them to projections/reducers.
Generation 1 (stored) → Migration 1→2 → Migration 2→3 → Current (Generation 3)
Steps
1. Keep the prior record and bump the generation on the new one
Keep the old shape available to the migration as TPrevious. Mark the current record's generation with [EventType(generation: N)] as always, and mark the prior record with [EventTypeGenerationFor<TCurrent>(N-1)] instead of its own [EventType] — its event type id is then resolved from the current record's [EventType], so the two can never end up with mismatched ids:
[EventType(generation: 2)]
public record OrderPlaced(OrderId OrderId, Currency Currency); // generation 2 (current)
[EventTypeGenerationFor<OrderPlaced>(1)]
public record OrderPlacedV1(OrderId OrderId); // generation 1 (prior)
Older style, still supported: you can instead give
OrderPlacedV1its own[EventType("order-placed", generation: 1)], as long as the id string is explicit and identical to the id onOrderPlaced. This is not deprecated, but prefer[EventTypeGenerationFor<T>]for anything new — an omitted or mismatched id on this older style silently defaults to the CLR type name, which Chronicle then treats as a wholly unrelated event type, and the migration never applies. Two analyzers (CHR0037,CHR0049) and a constructor-time exception (MigrationGenerationsMustShareEventTypeId) catch this mistake for both styles.
2. Write the migration
Implement EventTypeMigration<TUpgrade, TPrevious> — TUpgrade is the current shape, TPrevious the prior. Chronicle extracts the generations, validates they're consecutive, and discovers the migration automatically (no registration).
Upcast and Downcast are both public abstract void and take an IEventMigrationBuilder<TTarget, TSource> — you describe the change declaratively through builder.Properties(...), you do not construct the record by hand:
public class OrderPlacedV1ToV2 : EventTypeMigration<OrderPlaced, OrderPlacedV1>
{
public override void Upcast(IEventMigrationBuilder<OrderPlaced, OrderPlacedV1> builder) =>
builder.Properties(p => p.DefaultValue(_ => _.Currency, Currency.From("NOK"))); // new field's default
public override void Downcast(IEventMigrationBuilder<OrderPlacedV1, OrderPlaced> builder) =>
builder.Properties(_ => { }); // map back for any consumers still on gen 1
}
The property builder exposes DefaultValue, RenamedFrom, Split, and Combine — use them to express the change declaratively. Both Upcast and Downcast are abstract on the base, so both must be implemented (Downcast may be a no-op builder.Properties(_ => { }) when no consumer needs the gen-1 shape).
2b. When the values changed meaning, declare a value map
The operations above move values between properties. When a value itself means something different in the new generation — an enum renumbered, a status code set replaced — override MapValues on the migration instead. It is declared once and applied forward when upcasting and inverted when downcasting:
public override void MapValues(IEventValueMapBuilder<OrderStatusChanged, OrderStatusChangedV1> builder) =>
builder.For(current => current.Status, previous => previous.Status, map => map
.Map(OrderStatusV1.Pending, OrderStatus.Awaiting)
.Map(OrderStatusV1.Done, OrderStatus.Completed));
Values the map doesn't mention are carried across unchanged. Two values collapsing onto one take the first pair declared for that value on the way back. MapValues runs before Upcast/Downcast, so a direction that states its own transformation for the property keeps it — that's also how you express a deliberately one-way translation (builder.Properties(pb => pb.MapValues(...))).
An enum gaining a member or having a member renamed needs no migration at all — Chronicle accepts both in place and updates the registered schema, because neither changes what an already stored value means. Only a removed or renumbered member needs a new generation plus a value map.
3. Chain across generations
For three generations, write two migrations (1→2, 2→3) — each only knows its adjacent pair; Chronicle chains them.
Common pitfalls
| Pitfall | Why it breaks |
|---|---|
Editing the stored event record without bumping generation |
Old events still carry the old schema; Chronicle won't migrate them |
Giving the prior record its own [EventType] with no id, or an id that doesn't exactly match the current generation's |
Chronicle treats the two as unrelated event types and the migration never applies; use [EventTypeGenerationFor<T>] so there's no id to mismatch |
| Adding a nullable value type to handle "missing old data" | Analyzer-flagged anti-pattern; use a migration default |
| A migration that throws on a null/missing old field | Old events may lack fields entirely — null-coalesce / default |
Splitting one event into two inside Upcast |
Upcast returns one event; model a split as a reactor/command, not a schema migration |
Quality gate
- Build is clean.
- Old-generation events upcast to the current shape when replayed through a
ReadModelScenario<T>. - No nullable value types introduced on
[EventType]records.
See also
- vertical-slices.md — event type rules (non-nullable, naming).
event-modeling— deciding when a fact is a new event vs a migration.