Versioning Events Without Breaking Everything¶
Imagine a city library that has been collecting catalog cards for over a hundred years. In 1920, librarians recorded "Author" and "Title." In 1970, they added the ISBN. In 1990, "Author" became "Authors" (plural, to accommodate co-authors). In 2020, they introduced e-book formats and licensing information.
Here's the thing: the old cards are still there. You can't "update" a card from 1920. And yet, the modern library system must understand all of them, from the handwritten notes of a century ago to yesterday's digital acquisition.
Event sourcing faces the same challenge. Events are immutable facts. Once written, they stay forever. But requirements change, domains evolve, and mistakes get discovered. How do you version something that can't be changed?
Why Events Change¶
Events need to evolve for many reasons. Business requirements shift. You discover better terminology for your ubiquitous language. The original modeling had gaps. New compliance requirements demand removing or restructuring fields. External integrations need additional data.
But here's the constraint: events are immutable. The book-acquired event from 2020 is still sitting in your event store. It will be there in 2030. And every piece of code that reads events must be able to interpret it correctly.
This creates an apparent contradiction. Facts don't change, but our understanding of facts does. The book was acquired with exactly the data recorded at that time. That's historically accurate. Yet our current system needs richer or restructured information.
The solution isn't to change the past. It's to let different interpretations coexist.
Version in the Type, Not the Payload¶
EventSourcingDB follows the CloudEvents specification, which doesn't include a dedicated version field. Instead, versioning is expressed through the type field itself:
This approach makes versions explicit and visible. When you filter events, run EventQL queries, or debug your system, the version is right there in the type name. Different schemas can coexist. Event handlers can target specific versions. There's no ambiguity about which structure to expect.
The convention is simple: append .v1, .v2, and so on to your event type. Only increment the version when there's a breaking change. Not every refinement needs a new version: only changes that would cause existing consumers to fail or misinterpret the data.
What Counts as Breaking?¶
A change is breaking if it violates the expectations of existing consumers or projections. This includes:
- Removing or renaming fields. If your projection expects
authorand you rename it toauthors, the code breaks. If you removeisbnbecause "we don't need it anymore," every consumer that relied on it fails. - Changing field types. A string becoming an array, a number becoming a string, an object becoming a flat value: all of these cause parsing errors or silent misinterpretation.
- Making optional fields required. If
coverImageUrlwas optional and you make it required, existing events that lack this field will fail schema validation. - Changing semantics without changing names. This is the most dangerous kind. If
pricemeant net price and now means gross price, everything still parses correctly, but every calculation is wrong. Silent corruption.
Non-breaking changes, on the other hand, preserve compatibility:
- Adding optional fields. New events have
coverImageUrl, old events returnnullfor it. Consumers handle the absence gracefully. - Extending enumerations. Adding
"ebook"to the list of formats doesn't break consumers that only know"hardcover"and"paperback". - Refining internal behavior. If the change doesn't affect the event's structure or the data consumers receive, it's not breaking.
When in doubt, treat it as breaking. A version increment costs almost nothing. Silent failures cost days of debugging and data integrity.
A Concrete Example¶
Let's say you're building a library system. Your first event looks like this:
// io.eventsourcingdb.library.book-acquired.v1
{
source: 'https://library.eventsourcingdb.io',
subject: '/books/42',
type: 'io.eventsourcingdb.library.book-acquired.v1',
data: {
title: '2001 – A Space Odyssey',
author: 'Arthur C. Clarke',
isbn: '978-0756906788'
}
}
A year later, you discover a problem. "Good Omens" has two authors: Terry Pratchett and Neil Gaiman. Your author field is a string. You need an array.
You can't just change it. There might be thousands of V1 events in your store. If you've registered a JSON schema for V1, that schema is immutable: you literally cannot modify it. And even if you could, every projection and event handler that reads author as a string would break.
Instead, you create V2:
// io.eventsourcingdb.library.book-acquired.v2
{
source: 'https://library.eventsourcingdb.io',
subject: '/books/99',
type: 'io.eventsourcingdb.library.book-acquired.v2',
data: {
title: 'Good Omens',
authors: ['Terry Pratchett', 'Neil Gaiman'],
isbn: '978-0060853983'
}
}
From now on, new acquisitions use V2. Existing V1 events remain untouched. Both versions coexist in your event store, potentially forever. This is correct behavior, not a problem to solve.
Schema Registration: The Immutable Contract¶
EventSourcingDB allows you to register JSON schemas for event types. Once registered, a schema is strictly enforced and cannot be changed. Every event of that type (past and future) must conform to it.
This sounds restrictive, but it's actually protective. It prevents accidental breaking changes. If you try to add a required field to an existing schema, you can't: historical events would fail validation. The system forces you to create a new version instead. In fact, when you register a schema, EventSourcingDB validates all existing events of that type against it, ensuring complete consistency from day one.
Think of it as a contract. Once you've promised that book-acquired.v1 has a certain structure, that promise is permanent. Consumers can rely on it. If you need a different structure, you make a new promise with a new version.
Transformation Outside the Store¶
Your event store now contains both V1 and V2 events. Your projection wants a unified format. How do you handle this?
The answer is transformation in application code, not in the event store. The events remain historically accurate. Your code translates them as needed:
interface NormalizedBookAcquired {
title: string;
authors: string[];
isbn: string;
}
function normalizeBookAcquired(event: CloudEvent): NormalizedBookAcquired {
if (event.type === 'io.eventsourcingdb.library.book-acquired.v1') {
return {
title: event.data.title,
authors: [event.data.author],
isbn: event.data.isbn
};
}
if (event.type === 'io.eventsourcingdb.library.book-acquired.v2') {
return {
title: event.data.title,
authors: event.data.authors,
isbn: event.data.isbn
};
}
throw new Error(`Unknown version: ${event.type}`);
}
This pattern (often called upcasting, meaning older versions are transformed to the current format on read) keeps your event log pristine while letting application logic evolve. Each version gets explicit handling. The transformation is visible, testable, and maintainable.
Common Mistakes¶
"It's just a small field." Every structural change can break consumers. The size of the change doesn't correlate with its impact. Adding one required field breaks everything.
Changing semantics, keeping names. If price meant net and now means gross, nothing fails visibly. Your calculations are just wrong. By the time you notice, you've corrupted months of derived data. When semantics change, names must change too.
Version inflation. If you're on V17 of an event, something deeper is wrong. Maybe it's actually several different events that should be modeled separately. Maybe the domain understanding is still evolving too fast to stabilize. Step back and reconsider.
Big bang migration. "Let's rewrite all events to the new format" destroys the audit trail. It changes hashes, breaks signatures, and loses the historical accuracy that makes event sourcing valuable. Don't do it.
Everything optional. Making all fields optional to avoid validation is just hiding the problem. You lose all guarantees, and consumers must handle every possible combination of present and absent fields. That's not flexibility, it's chaos.
Designing for Evolution¶
When modeling a new event, think ahead:
Which fields are truly required? Could some be optional with sensible defaults? It's easier to make an optional field required in V2 than to remove a required field.
Is the name stable? The need to change author to authors was predictable. price without specifying net or gross was ambiguous from the start. Choose names that won't need clarification later.
What does V2 look like? Even before you ship V1, sketch what changes might come. If you can anticipate likely evolution, you can design V1 to accommodate it gracefully.
Is this one event or several? Sometimes what seems like "the same event with different data" is actually different business occurrences. A physical book acquisition and an e-book license might look similar, but they have different legal implications, different data needs, different downstream effects. Separate events might be cleaner than versions.
Accepting Divergence¶
Your event store is a historical document. It reflects what was known, decided, and recorded at different points in time. Some streams will contain only V1 events. Others will have only V2. Many will have both, marking the transition period when the system evolved.
This isn't a problem to solve. It's an accurate reflection of reality. The library still has cards from 1920, cards from 1960, and digital records from 2020. They don't all look the same. They don't need to. What matters is that the current system can read and interpret all of them correctly.
Embrace the divergence. Multiple versions coexisting is normal and healthy. It means your system evolved without destroying its history. It means you can always go back and understand what was true at any point in time. It means the audit trail is complete and honest.
The library's catalog cards tell the story of how cataloging practices evolved over a century. Your event store tells the story of how your domain understanding deepened over time. Both are valuable precisely because they preserve that history rather than overwriting it.
Events are facts. Facts don't change. But systems do, and versioning is how you let them grow without breaking everything that came before.
Further Reading¶
If you want to dive deeper into event versioning and related topics, the Versioning Events documentation covers the technical details, Event Types explains naming conventions, and Registering Event Schemas shows how schema enforcement works in practice.
And if you're new to EventSourcingDB entirely, the Getting Started guide will have you writing your first events in minutes, versioned or not.