|
| 1 | +--- |
| 2 | +created_at: 2025-09-12 12:26:59 +0200 |
| 3 | +author: Jakub Rozmiarek |
| 4 | +tags: ["event store", "event sourcing", "ddd", "rails event store"] |
| 5 | +publish: false |
| 6 | +--- |
| 7 | + |
| 8 | +# Evolving event names and payloads in Rails Event Store without breaking history |
| 9 | + |
| 10 | +Your product team just decided that 'Refunds' should be called 'Returns' across the entire system. In a traditional CRUD application, this might be a simple find-and-replace operation. But in an Event Sourced system with thousands of historical events, this becomes a migration challenge that could break your entire application. |
| 11 | + |
| 12 | +Event sourced systems store immutable events as the source of truth. When business terminology evolves, you can't just update the database - you need to maintain backward compatibility with all historical events while allowing new code to use updated terminology. |
| 13 | + |
| 14 | +<!-- more --> |
| 15 | + |
| 16 | +## The problem we faced |
| 17 | + |
| 18 | +In an event sourced ecommerce app a decision was made to rename legacy events to new terminology: |
| 19 | +- `Ordering::DraftRefundCreated` -> `Ordering::DraftReturnCreated` |
| 20 | +- `Ordering::ItemAddedToRefund`-> `Ordering::ItemAddedToReturn` |
| 21 | +- `Ordering::ItemRemovedFromRefund` -> `Ordering::ItemRemovedFromReturn` |
| 22 | + |
| 23 | +What is more also a payload transformation was needed: |
| 24 | +- `refund_id` -> `return_id` |
| 25 | +- `refundable_products` -> `returnable_products` |
| 26 | + |
| 27 | +## Simple solutions don't work |
| 28 | + |
| 29 | +In event sourced systems: |
| 30 | +- we can't modify historical events because it breaks Event Sourcing fundamentals |
| 31 | +- if we ignore old events it results in business-critical historical data loss |
| 32 | +- data migrations don't apply as events are immutable by design |
| 33 | + |
| 34 | +## The Rails Event Store context |
| 35 | + |
| 36 | +### How RES handles event serialization/deserialization |
| 37 | + |
| 38 | +Rails Event Store uses a two-phase process for event persistence. When events are published, RES serializes domain events into Record objects containing event_type, data, metadata, and timestamps, then stores them in the database. During reads, it deserializes these records back into domain events that your application code can work with. This process involves multiple transformation layers that can modify both the structure and content of events as they move between your domain model and storage. |
| 39 | + |
| 40 | +### The role of mappers in the event pipeline |
| 41 | + |
| 42 | +Mappers are the transformation engine of RES, sitting between your domain events and the database. They form a pipeline where each mapper can transform events during serialization (dump) and deserialization (load). Common transformations include DomainEvent (converts plain objects to domain events), SymbolizeMetadataKeys (normalizes metadata keys), and PreserveTypes (maintains data types like timestamps). This pipeline architecture allows you to compose multiple transformations, making it possible to evolve event schemas while maintaining backward compatibility. |
| 43 | + |
| 44 | +## Our solution: custom event transformation pipeline |
| 45 | + |
| 46 | +As described [here](https://railseventstore.org/docs/advanced-topics/mappers/#custom-mapperRES) RES allows to create custom mappers and plug them into the transformation pipeline. |
| 47 | + |
| 48 | +We decided to create a custom `Transformations::RefundToReturnEventMapper`and include it in the transformation pipeline of our RES client: |
| 49 | + |
| 50 | + |
| 51 | +```ruby |
| 52 | + mapper = RubyEventStore::Mappers::PipelineMapper.new( |
| 53 | + RubyEventStore::Mappers::Pipeline.new( |
| 54 | + Transformations::RefundToReturnEventMapper.new( |
| 55 | + 'Ordering::DraftRefundCreated' => 'Ordering::DraftReturnCreated', |
| 56 | + 'Ordering::ItemAddedToRefund' => 'Ordering::ItemAddedToReturn', |
| 57 | + 'Ordering::ItemRemovedFromRefund' => 'Ordering::ItemRemovedFromReturn' |
| 58 | + ), |
| 59 | + RubyEventStore::Mappers::Transformation::DomainEvent.new, |
| 60 | + RubyEventStore::Mappers::Transformation::SymbolizeMetadataKeys.new, |
| 61 | + RubyEventStore::Mappers::Transformation::PreserveTypes.new |
| 62 | + ) |
| 63 | + ) |
| 64 | + client = RailsEventStore::JSONClient.new(mapper: mapper) |
| 65 | +``` |
| 66 | + |
| 67 | +### Key components |
| 68 | + |
| 69 | +1. Event class name transformation |
| 70 | + |
| 71 | +```ruby |
| 72 | + class_map = { |
| 73 | + 'Ordering::DraftRefundCreated' => 'Ordering::DraftReturnCreated', |
| 74 | + 'Ordering::ItemAddedToRefund' => 'Ordering::ItemAddedToReturn', |
| 75 | + 'Ordering::ItemRemovedFromRefund' => 'Ordering::ItemRemovedFromReturn' |
| 76 | + } |
| 77 | +``` |
| 78 | + |
| 79 | +it's passed to our custom mapper and it is then used to map old classes to new ones during deserialization: |
| 80 | + |
| 81 | +```ruby |
| 82 | +module Transformations |
| 83 | + class RefundToReturnEventMapper |
| 84 | + def initialize(class_map) |
| 85 | + @class_map = class_map |
| 86 | + end |
| 87 | + |
| 88 | + def load(record) |
| 89 | + old_class_name = record.event_type |
| 90 | + new_class_name = @class_map.fetch(old_class_name, old_class_name) |
| 91 | + |
| 92 | + if old_class_name != new_class_name |
| 93 | + transformed_data = transform_payload(record.data, old_class_name) |
| 94 | + record.class.new( |
| 95 | + event_id: record.event_id, |
| 96 | + event_type: new_class_name, |
| 97 | + data: transformed_data, |
| 98 | + metadata: record.metadata, |
| 99 | + timestamp: record.timestamp || Time.now.utc, |
| 100 | + valid_at: record.valid_at || Time.now.utc |
| 101 | + ) |
| 102 | + else |
| 103 | + record |
| 104 | + end |
| 105 | + end |
| 106 | + |
| 107 | + def dump(record) |
| 108 | + record |
| 109 | + end |
| 110 | + end |
| 111 | +end |
| 112 | +``` |
| 113 | + |
| 114 | +2. Payload data transformation |
| 115 | + |
| 116 | +As you probably notice there's also a call to `transform_payload`, here's how it works: |
| 117 | + |
| 118 | +```ruby |
| 119 | +module Transformations |
| 120 | + class RefundToReturnEventMapper |
| 121 | + private |
| 122 | + |
| 123 | + def transform_payload(data, old_class_name) |
| 124 | + case old_class_name |
| 125 | + when 'Ordering::DraftRefundCreated' |
| 126 | + data = transform_key(data, :refund_id, :return_id) |
| 127 | + transform_key(data, :refundable_products, :returnable_products) |
| 128 | + when 'Ordering::ItemAddedToRefund', 'Ordering::ItemRemovedFromRefund' |
| 129 | + transform_key(data, :refund_id, :return_id) |
| 130 | + else |
| 131 | + data |
| 132 | + end |
| 133 | + end |
| 134 | + |
| 135 | + def transform_refund_to_return_payload(data, old_key, new_key) |
| 136 | + if data.key?(old_key) |
| 137 | + data_copy = data.dup |
| 138 | + data_copy[new_key] = data_copy.delete(old_key) |
| 139 | + data_copy |
| 140 | + else |
| 141 | + data |
| 142 | + end |
| 143 | + end |
| 144 | + end |
| 145 | +end |
| 146 | +``` |
| 147 | + |
| 148 | +### Other considerations |
| 149 | + |
| 150 | +1. We use load-time transformation only: load() transforms when reading, dump() preserves original format |
| 151 | +2. our pipeline relies on three RubyEventStore transformations: |
| 152 | + - `DomainEvent` hydrates objects and normalizes data structure |
| 153 | + - `SymbolizeMetadataKeys` ensures metadata follows Ruby conventions |
| 154 | + - `PreserveTypes` prevents data type corruption during the transformation process |
| 155 | + |
| 156 | +## Why not use RES upcast feature? |
| 157 | + |
| 158 | +As described in [this post](https://blog.arkency.com/4-strategies-when-you-need-to-change-a-published-event/#2__upcasting_the_event_on_the_fly__) RES provides `Transformation::Upcast` mapper. |
| 159 | + |
| 160 | +After investigating its capabilities, we discovered that upcast can indeed handle both event class name changes and payload transformation through lambda functions. However, we chose to stick with our custom mapper approach for several practical reasons: |
| 161 | + |
| 162 | +1. Pipeline integration complexity |
| 163 | + |
| 164 | +RES upcast works beautifully as a standalone solution, but doesn't integrate cleanly with the transformation pipeline we needed: |
| 165 | + |
| 166 | +```ruby |
| 167 | + # This doesn't work - Default mapper isn't pipeline-compatible |
| 168 | + RubyEventStore::Mappers::PipelineMapper.new( |
| 169 | + RubyEventStore::Mappers::Pipeline.new( |
| 170 | + RubyEventStore::Mappers::Default.new(events_class_remapping: upcast_map), # No dump() method |
| 171 | + RubyEventStore::Mappers::Transformation::DomainEvent.new, |
| 172 | + RubyEventStore::Mappers::Transformation::PreserveTypes.new |
| 173 | + ) |
| 174 | + ) |
| 175 | +``` |
| 176 | + |
| 177 | +We needed `DomainEvent.new`, `SymbolizeMetadataKeys.new`, and `PreserveTypes.new` transformations, but upcast's `Default` mapper isn't designed to work within a transformation pipeline. |
| 178 | + |
| 179 | +2. Excessive boilerplate when using lambdas |
| 180 | + |
| 181 | +Lambdas could be used to handle paload transformation, however using upcast with lambdas required significant boilerplate code for each event type: |
| 182 | + |
| 183 | +```ruby |
| 184 | + 'Ordering::DraftRefundCreated' => lambda { |record| |
| 185 | + new_data = symbolize_keys(record.data.dup) # Manual key conversion |
| 186 | + new_data = transform_payload(new_data) # Our transformation logic |
| 187 | + |
| 188 | + record.class.new( # Manual object creation |
| 189 | + event_id: record.event_id, # Boilerplate |
| 190 | + event_type: 'Ordering::DraftReturnCreated', |
| 191 | + data: new_data, |
| 192 | + metadata: symbolize_metadata_keys(record.metadata), # Manual metadata handling |
| 193 | + timestamp: record.timestamp, # Manual preservation |
| 194 | + valid_at: record.valid_at # Manual preservation |
| 195 | + ) |
| 196 | + } |
| 197 | +``` |
| 198 | + |
| 199 | +This approach would require us to manually implement what DomainEvent.new and PreserveTypes.new handle automatically. |
| 200 | + |
| 201 | +Without the transformation pipeline, we'd lose the automatic benefits of: |
| 202 | +- type preservation for timestamps and other complex objects |
| 203 | +- metadata key symbolization |
| 204 | +- domain event hydration |
| 205 | + |
| 206 | +We'd need to reimplement these features manually in each lambda. |
| 207 | + |
| 208 | +3. Code organization and maintainability |
| 209 | + |
| 210 | +Our custom mapper provides better separation of concerns: |
| 211 | +- single responsibility: one class handles all transformation logic |
| 212 | +- easier testing: clear interface for unit tests |
| 213 | +- better debugging: stack traces point to specific transformation methods |
| 214 | +- DRY principle: Shared transformation logic across all event types |
0 commit comments