Skip to content

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:

csharp
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:

csharp
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:

csharp
// 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:

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
// 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:

csharp
[JsonDerivedType(typeof(BookAdded), "book-added")]      // current
// Old tags are NOT listed here — they're handled by upcasters

When you release a breaking change:

  1. Change the current event's tag to book-added (or keep it the same if the tag is still accurate)
  2. Change the old tag from book-added to book-added-v1 by deploying an upcaster
  3. New events are written with the current tag
  4. 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:

csharp
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:

sql
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

  1. 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.

  2. Reserve upcasters for breaking changes. Renames, type changes, structural reorganization.

  3. Keep upcasters simple. They transform JSON. If the transformation needs database access or complex logic, reconsider the approach.

  4. Test upcasters with your Given-When-Then harness. Feed old-format events into the harness and verify the aggregate handles them correctly.

  5. Document the chain. When you have V1 → V2 → V3, a comment explaining what changed at each version saves the next person time.


Next: Concurrency

流れ — flow.