Skip to content

Event Versioning and Maintaining Backwards Compatibility

This guide explains how to version, rename, or retire an event in an event-sourced system without breaking replay, projections, local environments, or production.

It is based on the LBS-1134 change where CurrentRoundSetEvent was renamed to CurrentGroupSetEvent, while historical data still contained the original event type.

Why event renames are risky

In LBS Foundry, event types are serialized using the event [DataContract] name and namespace, not only the C# type name.

That means this change:

  • renaming a C# event class
  • changing [DataContract(Name = ...)]

is a storage change, not just a refactor.

Historical rows in events.mt_events.type still contain the old value. If the code no longer knows how to deserialize or apply that old event, the following can break:

  • aggregate replay
  • projections and read model rebuilds
  • local developer databases
  • staging or production environments that have not yet been migrated

Use a two-phase approach.

Phase 1: Add compatibility first

Deploy code that can handle both the old and new event names.

This phase should:

  1. Introduce the new event type for all future writes.
  2. Keep a legacy event type with the old [DataContract] name.
  3. Mark the legacy type as obsolete so new code does not use it accidentally.
  4. Teach aggregates and projections to apply both old and new events.
  5. Add an automated database migration to rewrite old event type names.

Phase 2: Remove compatibility later

After every environment has applied the migration and old events are no longer present in storage, remove the legacy compatibility code in a later change.

Do not combine compatibility removal with the initial rename release.

Example from LBS-1134

Old event:

  • C# type: CurrentRoundSetEvent
  • Stored type: LBS.Foundry/CurrentRoundSet

New event:

  • C# type: CurrentGroupSetEvent
  • Stored type: LBS.Foundry/CurrentGroupSet

The aggregate now raises only CurrentGroupSetEvent for new writes, but replay must still understand CurrentRoundSetEvent until storage is migrated.

Implementation steps

1. Introduce the new event

Create the correctly named event and make the aggregate emit only that event going forward.

Guidelines:

  • keep the new event shape aligned with the real domain meaning
  • use the correct [DataContract(Name = ...)]
  • update command handlers, aggregates, tests, and SDK generation as needed

2. Reintroduce the old event as a compatibility type

Keep a legacy event class with the old [DataContract] metadata so historical rows can still deserialize.

Use this pattern:

  • same namespace as before
  • same [DataContract(Name = ...)] as the stored historical event
  • [Obsolete(..., false)] to discourage new usage without breaking compilation
  • no new code should raise the legacy event

Example:

[DataContract(Name = "CurrentRoundSet", Namespace = LbsNamespace.Default)]
[Obsolete("Use CurrentGroupSetEvent instead. This remains for backwards compatibility in the event store only", false)]
public sealed record CurrentRoundSetEvent : DomainEvent<SeasonId>
{
    [DataMember]
    public required string GroupName { get; set; }
}

3. Make aggregates replay both versions

If the aggregate can be rebuilt from historical events, it must be able to apply both the legacy event and the new event.

Example:

public void Apply(CurrentGroupSetEvent domainEvent)
{
    this.currentGroupName = domainEvent.GroupName;
}

#pragma warning disable CS0618
public void Apply(CurrentRoundSetEvent domainEvent)
{
    this.currentGroupName = domainEvent.GroupName;
}
#pragma warning restore CS0618

Rule:

  • Execute(...) methods should emit only the new event
  • Apply(...) methods should support both old and new events during the compatibility window

4. Make projections handle both versions

Any projection or contract builder that consumes the renamed event must include and apply both event types during the transition.

Example:

this.IncludeType<CurrentGroupSetEvent>();
#pragma warning disable CS0618
this.IncludeType<CurrentRoundSetEvent>();
#pragma warning restore CS0618

#pragma warning disable CS0618
public static SeasonContract Apply(CurrentRoundSetEvent domainEvent, SeasonContract contract)
{
    return contract with
    {
        CurrentGroupName = domainEvent.GroupName
    };
}
#pragma warning restore CS0618

public static SeasonContract Apply(CurrentGroupSetEvent domainEvent, SeasonContract contract)
{
    return contract with
    {
        CurrentGroupName = domainEvent.GroupName
    };
}

Check all consumers, including:

  • aggregate Apply(...) methods
  • Marten projections and contract builders
  • read model rebuild paths
  • integration or export processes that deserialize event types directly
  • event-history UI or tooling if it relies on event type names

5. Add a tracked database migration

Because the stored event type is based on the data contract name, historical rows must be updated.

For the LBS-1134 example:

UPDATE events.mt_events
SET type = 'LBS.Foundry/CurrentGroupSet'
WHERE type = 'LBS.Foundry/CurrentRoundSet';

Requirements:

  • make the script idempotent
  • run it through the normal migration path, not manually
  • ensure it runs in local, staging, and production environments
  • block deployment if the migration step fails

If a migration history table exists, record the migration there. If not, introduce one as part of the database migration process.

6. Automate the migration in the deployment pipeline

Manual SQL is not enough for an event rename.

The deployment pipeline should:

  1. run the database migration before application deployment
  2. fail the release if migration fails
  3. deploy application code only after the migration step succeeds

The same migration flow should also be available to local developers so startup and replay do not fail against older local databases.

7. Regenerate downstream contracts

If commands, events, or contracts are exposed through the generated SDK, regenerate them after the backend change.

For this repository:

pnpm sdk:refresh

Do not manually edit generated files under sdk/typescript/.

8. Remove legacy support in a later release

Once all environments have been migrated and replay has been verified, remove:

  • the obsolete legacy event class
  • legacy Apply(...) overloads
  • legacy projection IncludeType(...) registrations
  • temporary warning suppression related to the legacy type

Treat this as a separate cleanup change.

Checklist

Use this checklist for any event versioning, deprecation, or rename.

  • [ ] New event introduced with the correct name and data contract
  • [ ] Aggregate emits only the new event
  • [ ] Legacy event type retained with old data contract metadata
  • [ ] Legacy event marked with [Obsolete]
  • [ ] Aggregate replay supports both event versions
  • [ ] Projections and contract builders support both event versions
  • [ ] Historical event type migration script added
  • [ ] Migration is automated in pipeline and local setup
  • [ ] Tests cover replay and projection compatibility
  • [ ] SDK regenerated if contracts changed
  • [ ] Legacy compatibility removal planned for a later release

Testing guidance

At minimum, verify:

  1. new commands write the new event type
  2. aggregates can replay streams containing the old event type
  3. projections can rebuild from streams containing the old event type
  4. mixed streams containing old and new event types still produce the correct state
  5. the migration updates existing rows as expected

For this repository, use:

dotnet build LBS.slnx
dotnet run --project src/Tests/LBS.UnitTests/LBS.UnitTests.csproj

If the event rename affects generated contracts, also run:

pnpm sdk:refresh

Common mistakes to avoid

Do not:

  • delete the old event type in the same release as the rename
  • change the data contract name and assume it is a harmless refactor
  • update production manually but forget local developer databases
  • support the legacy event in the aggregate but forget the projection
  • keep writing the old event after the new type has been introduced
  • remove compatibility code before all environments are migrated

Rule of thumb

If an event already exists in the event store, its [DataContract] identity is part of the persisted schema.

Treat changes to that identity like a database migration, not a code rename.