Recommended resources
The freely available resources this lesson will direct you to.
Hello! Welcome back to our module on State Management with Event Sourcing.
In our last lesson, we introduced snapshots as a performance optimization for rehydrating long-lived aggregates. This is crucial, but it also brings a new challenge to the forefront. The very aggregates that live long enough to need snapshots are also the ones most likely to evolve as business requirements change over their lifetime. This raises a fundamental question: since events are immutable, how do we handle changes to the structure, or schema, of our events?
This lesson directly addresses that question, focusing on the learning outcome: Explain common strategies for event schema evolution (e.g., upcasting, lazy migration) and their trade-offs. We will explore the spectrum of solutions, from simple run-time transformations to full-scale data migrations.
1. The Challenge of Immutable History
In a traditional database, if you need to add a column or rename one, you run a migration script (ALTER TABLE). The data is changed in place. In event sourcing, the "source of truth" is an append-only log of historical facts. We cannot—and should not—go back and alter history. This immutability is a core strength, providing a perfect audit log, but it means we must handle schema changes differently.
The problem isn't about changing the stored data; it's about evolving the application's ability to interpret that data.
A Taxonomy of Changes
Before exploring solutions, it's useful to categorize the types of changes we might encounter. A formal way to think about this is to classify the operations you might perform on your event structures.
The Dark Side of Event Sourcing: Managing Data Conversion
The paper 'The Dark Side of Event Sourcing: Managing Data Conversion' provides an excellent, structured breakdown of potential event store upgrade operations. This gives us a formal language to discuss the problem.
Please read Section IV, 'EVENT STORE UPGRADE OPERATIONS'. Focus on Table I, which categorizes changes at the event, stream, and store level. This will help you build a mental model of the different kinds of transformations we might need to support.
As the paper shows, changes can range from simple (adding a new optional attribute to an event) to complex (splitting one event into two distinct events). For example, in your FX trading domain, an initial TradeExecuted event might later be split into TradeMatched and TradeConfirmed to better reflect the lifecycle.
The Golden Rule: Don't Change Semantic Meaning
Across all evolution strategies, there is one paramount rule: You must not change the semantic meaning of an event or its properties.
Read Versioning in an Event Sourced System
Greg Young's book 'Read Versioning in an Event Sourced System' offers a clear explanation of this critical principle.
Please read the short section 'Changing Semantic Meaning'. The thermostat example is a classic illustration of why this is so dangerous.
If a property amount on a PaymentSent event originally meant the gross amount in USD, you cannot change it to mean the net amount in EUR. Such a change would corrupt the historical record upon replay. Instead, you must introduce new properties (e.g., netAmount, currency) and deprecate the old one. If the change is too fundamental, it's a sign that you need a completely new event type, not just a new version of an old one.
2. Core Strategies for Event Evolution
Now, let's dive into the common strategies for handling these changes. We'll use the framework from the "Dark Side of Event Sourcing" paper, which categorizes the techniques and analyzes their trade-offs.
The Dark Side of Event Sourcing: Managing Data Conversion
This paper is the most systematic overview of the topic. We will use it as our primary guide for this section.
Please read Section V, 'EVENT STORE UPGRADE TECHNIQUES'. This section details five key strategies and evaluates them against quality attributes like maintainability and performance. Pay close attention to Table II, which summarizes the trade-offs.
Let's review and expand on these five techniques. They broadly fall into two categories: run-time transformations (handling different versions on the fly) and data migrations (transforming the data store itself).
Run-time Transformation Strategies
These strategies involve transforming events as they are read from the event store, before they reach the aggregate. The stored events are never modified.
-
Multiple Versions in Code:
- How it works: The application code, particularly the aggregate's
_applymethods, contains conditional logic to handle different event versions.if event.version == 1: ... elif event.version == 2: .... - Trade-offs:
- Pro: Conceptually simple to start with.
- Con: Leads to high code complexity and poor maintainability. The business logic becomes cluttered with historical concerns. This is the "method explosion" problem.
- How it works: The application code, particularly the aggregate's
-
Upcasting:
- How it works: A dedicated component, an "upcaster," intercepts events as they are read. It contains logic to transform older event versions into the current version. The aggregate and other business logic components only ever see the latest version of an event.
- Trade-offs:
- Pro: Centralizes all transformation logic, keeping the domain model clean. This is a huge win for maintainability.
- Con: Adds latency to the read path, as transformations happen on the fly. The upcaster itself can become a complex component that needs careful maintenance ("job security," as one of the videos jokes).
- This is arguably the most common and balanced run-time approach.
-
Lazy Transformation:
- How it works: This is a variation of upcasting. When an event is upcast, the newly transformed version is written back to the event store. Subsequent reads can use the new version directly.
- Trade-offs:
- Pro: Amortizes the cost of transformation. It's only done once per event, not every time it's read.
- Con: Adds write operations to the read path, which can be complex. It also makes the event stream "messy," with both old and new versions co-existing, though this can be managed by writing to a new stream.
Data Migration Strategies
These strategies involve creating a new, clean version of the event store through a batch process.
-
In-Place Transformation:
- How it works: A batch job runs that reads events, transforms them, and writes them back to the same store, effectively overwriting or replacing the old events. This is analogous to a traditional
ALTER TABLE+UPDATEscript. - Trade-offs:
- Pro: Results in a clean event store with only the latest versions. No run-time overhead.
- Con: This is a high-risk operation. It permanently alters your source of truth and typically requires system downtime. A failure during the process could lead to data corruption.
- How it works: A batch job runs that reads events, transforms them, and writes them back to the same store, effectively overwriting or replacing the old events. This is analogous to a traditional
-
Copy and Transformation:
- How it works: This is the "nuclear option," but also the safest migration strategy. A process reads the entire original event store, applies all necessary transformations, and writes the results to a completely new event store. Once the copy is complete and has caught up to live traffic, the application is switched over to the new store in a "big flip."
- Trade-offs:
- Pro: Extremely safe, as the original event store is never modified and can serve as a backup. Allows for complex transformations, like splitting or merging streams.
- Con: Requires significant infrastructure (e.g., double the storage for the duration of the migration) and a complex, carefully orchestrated deployment process.
This video from Greg Young, a key architect of the event sourcing pattern, provides more color on the "Copy and Transformation" approach.
Greg Young answers your Event Sourcing questions!
In 'Greg Young answers your Event Sourcing questions!', he discusses the difference between simple versioning and the more complex transformations that require a full store migration.
Please watch from 23:58 to 27:45. He explains when you might need to do a full 'event store to event store' transformation and the 'big flip' process.
3. Architectural and Practical Considerations
Choosing a strategy depends on more than just the technical details; it's an architectural decision with wide-ranging implications.
The Role of Data Format
Your choice of serialization format has a massive impact.
- Strong, Type-Based Schema (e.g., Java/C# binary serialization): This tightly couples your events to your code. Any change, even adding an optional field, can become a breaking change that requires a new type and a formal versioning strategy.
- Weak Schema (e.g., JSON, Protobuf): These formats are far more flexible. If you use JSON, adding a new field to an event is often a non-issue. Old code deserializing a new event will simply ignore the field. New code deserializing an old event will see the field is missing and can apply a default value. This is often called the Tolerant Reader pattern.
This flexibility is why most modern distributed systems favor formats like JSON or Protobuf.
Event Sourcing - You are doing it wrong by David Schmitz
The video 'Event Sourcing - You are doing it wrong' makes a strong, opinionated case for using flexible, text-based formats like JSON to avoid versioning headaches.
Watch the segment from 22:00 to 27:58. The speaker discusses the problems with class-based versioning, the concept of an upcaster, and advocates for JSON with 'schema-on-read'.
A Financial System Example
Let's apply this to your experience. Imagine the FXTradeBooked event in Revolut's system initially has a clientID. Later, a new compliance rule requires capturing the client's Legal Entity Identifier (LEI).
- Change: Add an optional
clientLEIstring field to theFXTradeBookedevent. - Strategy Choice: Since this is an additive change and the field can be optional (or nullable), an Upcasting strategy is a great fit.
- Implementation:
- Modify the
FXTradeBookeddata class to includeclientLEI: Optional[str] = None. - Create an upcaster function for
FXTradeBooked. - When the application reads an old event without
clientLEI, the upcaster is invoked. - The upcaster might have logic like: "If
clientLEIis missing, use theclientIDto perform a lookup in the client master database. If an LEI is found, add it to the event object. If not, leave it asNone." - The
FXTradeaggregate's_applymethod now receives an event that always has theclientLEIfield (even if its value isNone), so the domain logic remains clean.
- Modify the
This avoids a costly and risky data migration for a simple, forward-compatible change.
Conclusion
Schema evolution is an inevitable and complex aspect of long-term system maintenance, especially with event sourcing. Unlike traditional databases, we can't simply change data in place. Instead, we must choose a strategy to manage the interpretation of our immutable history.
Key Takeaways:
- Event evolution strategies fall into two main camps: run-time transformations (like upcasting) and data migrations (like copy-and-transform).
- Upcasting is a common, balanced approach that centralizes transformation logic and keeps the domain model clean, at the cost of some run-time overhead.
- Copy-and-transform is the safest but most resource-intensive migration strategy, suitable for complex, breaking changes.
- The choice of a flexible data format like JSON can eliminate many versioning problems by default, especially for simple additive changes (the Tolerant Reader pattern).
- The golden rule is to never change the semantic meaning of an event. If the meaning changes, create a new event type.
Preview of the Next Lesson:
We've covered the theory and trade-offs of the most common strategies. In our next lesson, we will get practical and implement an upcaster function in Python. We'll take our FXTrade aggregate, introduce a versioned change, and write the code to transform an old event into a new one on the fly.