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
The recommended rollout¶
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:
- Introduce the new event type for all future writes.
- Keep a legacy event type with the old
[DataContract]name. - Mark the legacy type as obsolete so new code does not use it accidentally.
- Teach aggregates and projections to apply both old and new events.
- 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 eventApply(...)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:
- run the database migration before application deployment
- fail the release if migration fails
- 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:
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:
- new commands write the new event type
- aggregates can replay streams containing the old event type
- projections can rebuild from streams containing the old event type
- mixed streams containing old and new event types still produce the correct state
- the migration updates existing rows as expected
For this repository, use:
If the event rename affects generated contracts, also run:
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.