Ventyd Logo

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');
  });
});

On this page