Event Versioning Strategy
Event Versioning Strategy
Section titled “Event Versioning Strategy”Each persisted event row/document records SchemaVersion, CorrelationId, CausationId, and UserId separately
from its payload. IdempotencyId, aggregate version, timestamp, event name, and aggregate identity are likewise
first-class metadata. This allows Admin and history consumers to inspect an event envelope without deserializing
sensitive or obsolete payload JSON.
Legacy SQL rows are assigned schema version 1 by the metadata migration. Document and table providers also treat a missing schema-version field as version 1. Correlation, causation, and user identifiers remain null when they were not recorded by the original write; they are never inferred during a migration.
This document codifies the product-wide approach to event versioning and schema evolution across all Purview EventSourcing providers.
Core Principles
Section titled “Core Principles”- Events are append-only immutable facts. Never change the meaning of persisted event data.
- SchemaVersion is the versioning contract. Track breaking payload changes through the
SchemaVersionproperty on event classes. - Upcasting bridges payload versions. When old events must hydrate into new event shapes, implement
IEventUpcaster<TSource, TTarget>. - Unknown events fail safely. Providers return
UnknownEventwhen event types cannot be resolved or deserialized. - All providers implement consistent replay semantics. Replay-time upcasting is applied uniformly across SQL Server, Azure Storage, and MongoDB.
When to Version vs. When to Rename
Section titled “When to Version vs. When to Rename”Add a new property without versioning (additive change)
Section titled “Add a new property without versioning (additive change)”- Property is optional (nullable or has a default).
- Backward compatibility is preserved: old events deserialize successfully without the new field.
- Example:
CustomerRegisteredgains an optionalPhoneNumberfield; old events hydrate withnullorstring.Empty. - Action: No
SchemaVersionbump needed; no upcaster required.
Increment SchemaVersion (breaking payload change)
Section titled “Increment SchemaVersion (breaking payload change)”- Property is required and has no safe default (e.g., changes meaning or becomes non-nullable).
- Property is removed or renamed without a clear mapping.
- Example:
OrderCreatedv1 has optionalCurrency; v2 makes it required. OrPrice→UnitPricewith different semantics. - Action: Use
[Event(Version = 2)]or manually overrideSchemaVersion => 2. Implement an upcaster.
Create a new event type (semantic change)
Section titled “Create a new event type (semantic change)”- The event’s meaning fundamentally changes (e.g.,
UserRegistered→UserRegisteredWithEmailVerification). - The domain concept is distinct and should have its own event class.
- Example: A new workflow requires user email verification at registration; instead of changing
UserRegistered, defineUserRegisteredAndVerificationSent. - Action: Define a new event class. Optionally define an upcaster if the new event should apply the old event’s data.
SchemaVersion Details
Section titled “SchemaVersion Details”SchemaVersionis per-event-class, not per-aggregate.- Multiple events on one aggregate can have different versions.
Numbering
Section titled “Numbering”- Starts at 1 (default).
- Increment by 1 for each breaking change.
- Never decrease; version numbers are immutable markers.
Declaration
Section titled “Declaration”Via the source generator:
[Aggregate]public partial class OrderAggregate : AggregateBase{ public string OrderId { get; private set; } = default!; public string Currency { get; private set; } = "USD"; // Added in v2
// Version 1: original (Currency not present in old events) // public partial void Create(string orderId);
// Version 2: Currency is now part of the event [Event(Version = 2)] public partial void Create(string orderId, string currency);}Manually:
public sealed class OrderCreated : EventBase{ public string OrderId { get; set; } = default!; public string Currency { get; set; } = default!;
public override int SchemaVersion => 2;
protected override void BuildEventHash(ref HashCode hash) { hash.Add(OrderId); hash.Add(Currency); }}Upcasting Chains
Section titled “Upcasting Chains”Purpose
Section titled “Purpose”Upcasters convert old event payloads (deserialized from storage) into current event shapes so aggregates can apply them during replay.
Implementation
Section titled “Implementation”Single-hop upcaster (v1 → v2):
public sealed class OrderCreatedV1ToV2Upcaster : IEventUpcaster<OrderCreatedV1, OrderCreated>{ public OrderCreated Upcast(OrderCreatedV1 source) => new() { Details = source.Details, // Always preserve metadata OrderId = source.OrderId, Currency = "USD", // Default for legacy events };}Multi-hop chain (v1 → v2 → v3):
// Register both upcasters; the registry applies them in sequence.services.AddEventUpcaster<OrderCreatedV1, OrderCreatedV2, OrderCreatedV1ToV2Upcaster>();services.AddEventUpcaster<OrderCreatedV2, OrderCreated, OrderCreatedV2ToV3Upcaster>();
// On replay, events automatically: v1 → v2 → v3 (final) → aggregate.Apply()Upcaster Rules
Section titled “Upcaster Rules”- Direction: Forward only (v1 → v2 → v3 → …). Downgrading events is not supported.
- Metadata: Always copy
EventDetailsto the target event (idempotency, correlation, user, timestamp). - Legacy type resolution: Legacy (source) event types are registered automatically from the upcaster registry when an aggregate is initialized, so stored legacy event names resolve back to CLR types during replay. No extra registration is required.
- Same-type upcasters: An upcaster whose source and target types are the same (an in-place transform) is applied exactly once; it is not treated as a cycle.
- Cycle detection: The registry detects and rejects circular upcaster chains (for example v1 → v2 → v1) when it is constructed.
- Unknown target: If an old event has no upcaster path to a known type, it remains
UnknownEvent.
Detecting Partial Replay
Section titled “Detecting Partial Replay”Because old consumers reading newer events skip what they cannot apply, a replayed aggregate can be partially stale without an error being thrown. Replay records every skipped event on the aggregate instance:
aggregate.SkippedEvents(IReadOnlyList<SkippedEventRecord>) lists the versions, persisted event names, and whether each was unresolvable (UnknownEvent) or simply not applicable.- Callers that must not act on stale state should check
SkippedEventsafter a load and fail closed or rehydrate through a different path when it is non-empty.
SkippedEvents is populated only while an aggregate is rehydrated from an event stream. It is not persisted in SQL Server/PostgreSQL EF-backed snapshot payloads, so always check it on the aggregate returned by an event-stream load.
Downgrading (downcasting newer events into older shapes) remains unsupported; this signal exists so applications can detect and react to the mixed-version-fleet case explicitly.
Replay Semantics (All Providers)
Section titled “Replay Semantics (All Providers)”When replaying an aggregate from the event stream:
- Deserialize the event from JSON. If the event type cannot be resolved, return
UnknownEvent. - Apply upcasting chain (if a registry is present). Follow all registered upcasters in sequence until no further upcaster is found.
- Call aggregate.ApplyEvent() with the (possibly upcast) event.
- Handle unknown events gracefully. The aggregate’s
CanApplyEvent()should return false forUnknownEvent; the store logs and continues replay.
Provider Implementation Checklist
Section titled “Provider Implementation Checklist”-
GetEventRangeAsync()applies the upcaster registry after deserializing. -
GetAsync()(single aggregate load) applies the upcaster registry during replay. - Unknown event types return
UnknownEventwith metadata populated. - Upcasting errors are logged and surfaced (not silently swallowed).
- Multi-hop upcasting chains are tested end-to-end.
Documentation and Contracts
Section titled “Documentation and Contracts”EventDetails Preservation
Section titled “EventDetails Preservation”- Always copy
EventDetailsin upcasters. These fields are critical for causation, correlation, and audit trails. - Fields:
IdempotencyId,AggregateVersion,When,UserId,CausationId,CorrelationId.
Event Type Naming
Section titled “Event Type Naming”- Event type names are persisted in the event store. Renaming an event type breaks deserialization without a migration step.
- If renaming is necessary, define the old event type alongside the new one and create an upcaster.
Version Boundaries
Section titled “Version Boundaries”SchemaVersionon the event itself is serialized in the JSON payload.- Consumers can inspect
event.SchemaVersionto make conditional decisions during replay (fallback values, feature flags, etc.).
Test Coverage
Section titled “Test Coverage”All providers must verify:
- Additive changes – Old events deserialize and apply without upcasters.
- Versioned events – New events with
SchemaVersion > 1deserialize correctly. - Single-hop upcasting – V1 events are upcast to V2 during replay.
- Multi-hop upcasting – V1 → V2 → V3 chains work end-to-end.
- Unknown events – Missing event types produce
UnknownEventand replay continues. - EventDetails preservation – Metadata (idempotency, correlation, user) is copied in upcasters.
- Cycle detection – Circular upcaster chains are rejected at registry construction.
Related Files
Section titled “Related Files”- Core abstractions:
src/src/EventSourcing/Aggregates/Events/EventBase.cs,IEventUpcaster.cs,EventUpcasterRegistry.cs - SQL Server replay:
src/src/SqlServer/Events/SqlServerEventStore.GetEventRangeAsync.cs(reference implementation) - Sample: Event-Versioning-Examples.md
- Tests: Provider-specific replay tests (to be harmonized)
Last Updated: 2026-07-30