Event Versioning
Events are immutable. Once written, they never change. But requirements change all the time. A field gets renamed, a new field is required, an event gets split into two.
Nagare provides upcasters to bridge the gap between old event formats and your current code.
The principle
Never modify stored events. Instead, transform them at read time. The database keeps the original bytes. Your application sees the current format.
This means old and new events coexist in the same stream, rolling back a deployment doesn't corrupt your data, and different consumers can read at different schema versions.
Writing an upcaster
An upcaster transforms raw JSON from one event tag to another:
public class BookAddedV1Upcaster : IEventUpcaster
{
public string FromEventTag => "book-added-v1";
public string ToEventTag => "book-added";
public string Upcast(string jsonPayload)
{
var node = JsonNode.Parse(jsonPayload)!;
// V1 had "Name", V2 renamed it to "Title"
node["Title"] = node["Name"]!.GetValue<string>();
((JsonObject)node).Remove("Name");
return node.ToJsonString();
}
}Register it at startup:
builder.Services.AddEventUpcaster<BookAddedV1Upcaster>();The upcaster runs every time an event with tag book-added-v1 is read from the store. The aggregate and projections only see the current BookAdded type.
Common migration scenarios
Rename a field
The Name field was renamed to Title in V2:
// V1 event (stored in the database with tag "book-added-v1")
// { "$type": "book-added-v1", "Name": "Dune", "Author": "Herbert" }
// V2 event (current code)
public record BookAdded(string Title, string Author, string Isbn) : BookEvent;The upcaster copies the old field to the new name:
public string Upcast(string jsonPayload)
{
var node = JsonNode.Parse(jsonPayload)!;
node["Title"] = node["Name"]!.GetValue<string>();
((JsonObject)node).Remove("Name");
node["Isbn"] ??= ""; // V1 didn't have Isbn, default to empty
return node.ToJsonString();
}Add a required field
V1 of BookBorrowed didn't track the borrow timestamp. V2 requires it:
public class BookBorrowedV1Upcaster : IEventUpcaster
{
public string FromEventTag => "book-borrowed-v1";
public string ToEventTag => "book-borrowed";
public string Upcast(string jsonPayload)
{
var node = JsonNode.Parse(jsonPayload)!;
// V1 events don't have a timestamp. Use a sentinel value.
node["BorrowedAt"] ??= "1970-01-01T00:00:00Z";
return node.ToJsonString();
}
}Merge two fields into one
V1 had separate FirstName and LastName fields. V2 has a single FullName:
public string Upcast(string jsonPayload)
{
var node = JsonNode.Parse(jsonPayload)!;
var first = node["FirstName"]!.GetValue<string>();
var last = node["LastName"]!.GetValue<string>();
node["FullName"] = $"{first} {last}";
((JsonObject)node).Remove("FirstName");
((JsonObject)node).Remove("LastName");
return node.ToJsonString();
}Compute a derived field
V1 stored Quantity and UnitPrice separately. V2 also needs TotalPrice:
public string Upcast(string jsonPayload)
{
var node = JsonNode.Parse(jsonPayload)!;
var qty = node["Quantity"]!.GetValue<int>();
var price = node["UnitPrice"]!.GetValue<decimal>();
node["TotalPrice"] = qty * price;
return node.ToJsonString();
}Chaining upcasters
When an event evolves through multiple versions, chain the upcasters:
// V1 → V2: renamed "Name" to "Title"
builder.Services.AddEventUpcaster<BookAddedV1ToV2Upcaster>();
// V2 → V3 (current): added "Isbn" field
builder.Services.AddEventUpcaster<BookAddedV2ToV3Upcaster>();Nagare applies them in sequence. A V1 event passes through both upcasters before reaching your code. A V2 event passes through only the second one. A V3 event needs no upcasting.
The EventUpcasterChain handles the routing automatically based on the FromEventTag and ToEventTag of each upcaster.
Discriminator tag strategy
Tags are the anchor for the entire versioning system. Plan for them:
[JsonDerivedType(typeof(BookAdded), "book-added")] // current
// Old tags are NOT listed here — they're handled by upcastersWhen you release a breaking change:
- Change the current event's tag to
book-added(or keep it the same if the tag is still accurate) - Change the old tag from
book-addedtobook-added-v1by deploying an upcaster - New events are written with the current tag
- Old events are read through the upcaster
Tags are permanent
Never delete or reuse a discriminator tag. Old events in the store still reference it. The upcaster is the bridge between the old tag and your current code.
When upcasting is not enough
Upcasters work at the individual event level. They can't:
- Split one event into two (the store has a 1:1 relationship between position and event)
- Merge two events into one
- Reorder events
- Access external state during transformation
For these cases, you have two options:
Add a new event type. Keep the old event and its handler. Add a new event type for the new behavior. The aggregate handles both:
events
.On<BookAdded>((state, e) => state with { Exists = true, Title = e.Title })
.On<BookAddedV2>((state, e) => state with { Exists = true, Title = e.Title, Genre = e.Genre });Rebuild the stream. In extreme cases, you can create a new event store table, replay events through a migration script that emits new events, and switch over. This is a deployment operation, not a framework feature.
Rebuilding projections after schema changes
When an event's structure changes, projections that depend on it may need rebuilding. Delete the projection's checkpoint and restart the service:
DELETE FROM nagare_checkpoints WHERE SubscriptionId = 'book-projection';On next startup, the projection replays every event from the beginning, with upcasters applied, and reconstructs the read model from scratch.
This is safe because projections are disposable. They're derived data. The event stream is the source of truth.
Design guidelines
Default to adding optional fields. If the new field can have a sensible default, just add it to the event record with a default value. No upcaster needed.
Reserve upcasters for breaking changes. Renames, type changes, structural reorganization.
Keep upcasters simple. They transform JSON. If the transformation needs database access or complex logic, reconsider the approach.
Test upcasters with your Given-When-Then harness. Feed old-format events into the harness and verify the aggregate handles them correctly.
Document the chain. When you have V1 → V2 → V3, a comment explaining what changed at each version saves the next person time.
Next: Concurrency