Skip to content

Commit 3885aeb

Browse files
committed
Transforming events initial version.
1 parent 8938103 commit 3885aeb

1 file changed

Lines changed: 214 additions & 0 deletions

File tree

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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

Comments
 (0)