Event Versioning Strategy
Handle event schema evolution and migrations
Overview
Event schemas evolve over time. This guide covers strategies for managing event versioning, schema migrations, and backward compatibility.
The Versioning Challenge
Once an event is in your event store, you can't change its structure. You need strategies to handle evolution without breaking existing events.
// Old events still exist:
{ eventName: "user:created", body: { email: "..." } }
// But new code expects:
{ eventName: "user:created", body: { email: "...", name: "..." } }Versioning Strategies
Migration Scenarios
Adding Optional Fields
// Old: email only
// New: email + optional name
// ✅ Make field optional in state
state: v.object({
email: v.string(),
name: v.optional(v.string())
})
// ✅ Reducer provides default
case "user:created":
return { email: body.email, name: body.name || undefined };Removing Fields
// Old: email + deprecated field
// New: email only
// ✅ Keep field optional for old events
state: v.object({
email: v.string(),
deprecatedField: v.optional(v.string())
})
// ✅ Don't include in new events
event: {
created: v.object({ email: v.string() }) // No deprecated field
}Renaming Fields
// Old: "userName"
// New: "name"
// Use the migrate option to transform on load
const userRepository = createRepository(User, {
adapter,
migrate(rawEvent) {
if (rawEvent.eventName === "user:created") {
return {
...rawEvent,
body: {
...rawEvent.body,
name: rawEvent.body.userName, // Rename
userName: undefined, // Remove old
},
};
}
return rawEvent;
},
});Renaming Event Names
Use migrate to upcast a legacy event name to the current one, so the old name can be removed from the schema.
// Old event stored in DB: "user:profile_updated_v1"
// Current schema only has: "user:profile_updated"
const userRepository = createRepository(User, {
adapter,
migrate(rawEvent) {
if (rawEvent.eventName === "user:profile_updated_v1") {
return { ...rawEvent, eventName: "user:profile_updated" };
}
return rawEvent;
},
});Best Practices
- Version only when necessary - Don't version for every small change
- Document migrations - Keep a changelog of schema changes
- Test with old data - Ensure reducers handle all event versions
- Monitor performance - Upcasting can impact load times
- Plan for rollback - New versions should be backwards compatible
When to Migrate
Consider migrating when:
- Event versions proliferate (>3 versions)
- Performance degrades from upcasts
- Old events are no longer accessed
- Major refactoring is needed
Testing
describe('Event versioning', () => {
it('handles old event format', () => {
const oldEvent = {
eventName: 'user:created',
body: { email: 'test@example.com' }
};
const state = reducer(undefined, oldEvent);
expect(state.email).toBe('test@example.com');
expect(state.name).toBe(undefined); // Missing in old format
});
it('handles new event format', () => {
const newEvent = {
eventName: 'user:created_v2',
body: { email: 'test@example.com', name: 'Test' }
};
const state = reducer(undefined, newEvent);
expect(state.email).toBe('test@example.com');
expect(state.name).toBe('Test');
});
it('upcasts legacy events via migrate option', async () => {
// Without migrate: schema validation throws on the unknown event name
const repoWithoutMigrate = createRepository(User, { adapter });
await expect(
repoWithoutMigrate.findOne({ entityId: 'user-with-legacy-events' }),
).rejects.toThrow();
// With migrate: upcast old event name before validation
const repoWithMigrate = createRepository(User, {
adapter,
migrate(rawEvent) {
if (rawEvent.eventName === 'user:profile_updated_v1') {
return { ...rawEvent, eventName: 'user:profile_updated' };
}
return rawEvent;
},
});
const user = await repoWithMigrate.findOne({ entityId: 'user-with-legacy-events' });
expect(user?.nickname).toBe('Updated');
});
});