Event schema evolution for Event<TData> pattern. Covers backward-compatible changes, upcasting, versioned event data classes, migration strategies, and snapshot compatibility. Trigger: event versioning, event migration, schema evolution, upcaster, event version.
From dotnet-ai-kitnpx claudepluginhub faysilalshareef/dotnet-ai-kit --plugin dotnet-ai-kitThis skill uses the workspace's default tool permissions.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Compares coding agents like Claude Code and Aider on custom YAML-defined codebase tasks using git worktrees, measuring pass rate, cost, time, and consistency.
Event<TData> tracks the schema versionAdding new nullable fields with defaults is always safe:
// V1 — original event data
public record OrderCreatedDataV1(
string CustomerName,
decimal Total,
OrderStatus Status,
List<Guid> Items
) : IEventData
{
public EventType Type => EventType.OrderCreated;
}
// V2 — added nullable fields (backward compatible)
public record OrderCreatedDataV2(
string CustomerName,
decimal Total,
OrderStatus Status,
List<Guid> Items,
string? CustomerEmail = null,
string? ShippingAddress = null,
decimal? DiscountAmount = null
) : IEventData
{
public EventType Type => EventType.OrderCreated;
}
Key details:
nullable with default nullnullVersion field on the event distinguishes which schema was usedFor structural changes that cannot be handled by nullable defaults:
namespace {Company}.{Domain}.Commands.Application.EventUpcast;
public interface IEventUpcaster<in TOld, out TNew>
where TOld : IEventData
where TNew : IEventData
{
TNew Upcast(TOld oldData);
}
public class OrderCreatedV1ToV2Upcaster
: IEventUpcaster<OrderCreatedDataV1, OrderCreatedDataV2>
{
public OrderCreatedDataV2 Upcast(OrderCreatedDataV1 oldData)
{
return new OrderCreatedDataV2(
CustomerName: oldData.CustomerName,
Total: oldData.Total,
Status: oldData.Status,
Items: oldData.Items,
CustomerEmail: null,
ShippingAddress: null,
DiscountAmount: 0m
);
}
}
// In DI container
builder.Services.AddSingleton<
IEventUpcaster<OrderCreatedDataV1, OrderCreatedDataV2>,
OrderCreatedV1ToV2Upcaster>();
Maintain version suffixes when schemas diverge significantly:
// V1 — single winner
public record WinnerSelectedDataV1(
Guid WinnerId,
decimal PrizeAmount
) : IEventData
{
public EventType Type => EventType.WinnerSelected;
}
// V2 — multiple winners with ranking
public record WinnerSelectedDataV2(
List<WinnerEntry> Winners,
decimal TotalPrizePool
) : IEventData
{
public EventType Type => EventType.WinnerSelected;
}
public record WinnerEntry(
Guid WinnerId,
int Rank,
decimal PrizeAmount);
Use EventType enum combined with Version for deserialization:
public Event DeserializeEvent(string json, EventType type, int version)
{
return (type, version) switch
{
(EventType.OrderCreated, 1) => Deserialize<OrderCreatedDataV1>(json),
(EventType.OrderCreated, 2) => Deserialize<OrderCreatedDataV2>(json),
(EventType.WinnerSelected, 1) => Deserialize<WinnerSelectedDataV1>(json),
(EventType.WinnerSelected, 2) => Deserialize<WinnerSelectedDataV2>(json),
_ => throw new InvalidOperationException(
$"Unknown event type/version: {type}/{version}")
};
}
When replaying events for aggregate rehydration, apply upcasters:
public class OrderAggregate
{
public void Apply(Event @event)
{
switch (@event)
{
case Event<OrderCreatedDataV2> e:
ApplyCreated(e.Data);
break;
case Event<OrderCreatedDataV1> e:
// Upcast V1 to V2, then apply
var upcast = _upcaster.Upcast(e.Data);
ApplyCreated(upcast);
break;
}
}
private void ApplyCreated(OrderCreatedDataV2 data)
{
CustomerName = data.CustomerName;
Total = data.Total;
Status = data.Status;
}
}
public TNew UpcastChain<TOld, TMid, TNew>(
TOld data,
IEventUpcaster<TOld, TMid> first,
IEventUpcaster<TMid, TNew> second)
where TOld : IEventData
where TMid : IEventData
where TNew : IEventData
{
var mid = first.Upcast(data);
return second.Upcast(mid);
}
Version field after migrationSnapshots must account for event versioning:
public class AggregateSnapshot
{
public Guid AggregateId { get; init; }
public int Version { get; init; } // Snapshot schema version
public int LastSequence { get; init; } // Last event sequence included
public string StateJson { get; init; } // Serialized aggregate state
}
Key rules:
Apply logic changes due to event versioning, invalidate old snapshotsVersion field on snapshots to detect stale schemas| Change Type | Breaking? | Strategy |
|---|---|---|
| Add nullable field with default | No | Direct deserialization |
| Add required field | Yes | New version + upcaster |
| Remove field | Yes | New event type |
| Change field type | Yes | New event type |
| Rename field | Yes | New version + upcaster |
| Change enum values | Yes | New version + mapping |
| Add new event type | No | Add to EventType enum |
| Anti-Pattern | Correct Approach |
|---|---|
| Modifying existing event data records | Create a new versioned record (V2) |
| Removing fields from event data | Add new event type, deprecate old |
Changing field types (e.g., string to int) | New version with upcaster |
| Batch-migrating all events on every schema change | Lazy upcast on read |
| Ignoring Version field on events | Always set and check Version |
| Snapshots without version tracking | Include Version, invalidate on schema change |
# Find versioned event data classes
grep -r "DataV[0-9]" --include="*.cs" Domain/Events/
# Find upcasters
grep -r "IEventUpcaster" --include="*.cs" Application/
# Find event version handling
grep -r "\.Version" --include="*.cs" Domain/Events/
# Find snapshot classes
grep -r "Snapshot" --include="*.cs" Domain/
OrderCreatedDataV2) with new fieldsIEventUpcaster<V1, V2>)