Skip to content

Understanding event evolution

Here’s the question that makes immutability sound like a trap: “Events are immutable — so what happens when I need to rename or split a field a year from now?” You can’t go back and edit history. If you wrote Name two years ago and now want FirstName and LastName, you can’t rewrite two years of events.

Chronicle’s answer is generations. This page is the mental model; the full API and the recipe are linked at the end.

Each version of an event type is a generation, and you describe how to move between adjacent generations in both directions — upcast (old → new, e.g. split Name) and downcast (new → old, e.g. recombine). You never edit the old events; you teach the kernel how to translate them.

upcast

downcast

stored

stored

UserRegistered (gen 1, as written)

gen 2 — FirstName · LastName

Both kept

What that buys you is unusual, and worth sitting with:

  • Current backfills preserve stored generations. They add missing representations without overwriting what is there. Earlier backfills may already have changed historical content, and SQL revisions replace content in place; upgrading cannot recover those originals.
  • The kernel stores every generation. Append a gen-1 event with a 1→2 migration and the kernel keeps both gen 1 and the upcasted gen 2.
  • Two versions of your software can share one store. Service A on the new code reads gen 2; Service B on the old code reads gen 1 — the same physical event, each getting the generation it understands, with no upgrade coordination between them.
  • Backfilling is automatic, with a defined scope. Register a new generation and Chronicle starts a background job for that event type in the default namespace’s event log only. Other namespaces and sequences are not covered.

Backfill starts from the known appended generation’s base content, not a separate revision. For legacy events whose appended generation is unknown, it uses the highest stored base generation and leaves the appended generation unknown. Each added representation records its source and the migration-definition version that produced it, alongside its content and hash. A concurrent revision or redaction prevents a stale write: the job re-reads and retries once, then logs and skips if the conflict persists. Existing representations do not change when you change a migration alone.

Register new generations only after every kernel silo has been upgraded. During a rolling upgrade, old workers can still run destructive replace-all backfills.

Migrations run inside the kernel — the server — not in your application. That matters more than it first appears: once a migrator is registered, it applies to every event that passes through, whether it came from a .NET client, a REST call, or an integration written long after the migration shipped. Clients that are offline or still running older code are unaffected; they keep reading the generation they understand.

This is the same principle that answers the other question that makes people nervous about event sourcing — enforcing uniqueness without a unique index. The hard part lives in the kernel, is declared close to the event, and applies to every client with no coordination. See Understanding constraints for that half of the story.

The full story — declaring migrators, the C# API, and generation validation — is in Migrations; the step-by-step recipe is Evolve an event. Once the translate-don’t-edit model clicks, schema change stops being the obstacle it first appears to be.