Event Versioning and Snapshots: Changing Event Schemas Without Breaking History
Written events are not changed; new code reads old shapes through tolerant deserialization, fixed JSON names, and upcasters, while snapshots are turned on based on measurement and together with a rebuild. A property rename that silently turns into zero, RespectRequiredConstructorParameters, a Marten upcaster, a Turkish-culture alias breaking on a namespace move, the cost of in-place migration, a snapshot serialization trap, and closing the books.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
The stock ledger has been in production for six months. In a code review someone says the name StockAdjusted.Delta is vague; the team decides to make it QuantityChange. A small, innocent refactoring:
// v2: Delta is now QuantityChange
public sealed record StockAdjusted(StockItemId StockItemId, int QuantityChange, AdjustmentReason Reason, DateTimeOffset At)
: StockEvent(StockItemId, At);
An Emma stock item opened in March has four events: registration, 10 copies received, 2 damaged (Delta = -2), and 1 copy found in a count (Delta = 1). With the old code the stock came out as 9. The new code reads the same stream like this:
int onHand = 0;
foreach (IEvent e in await query.Events.FetchStreamAsync(Guid.Parse(id)))
{
onHand += e.Data switch { StockReceived r => r.Quantity, StockAdjusted a => a.QuantityChange, _ => 0 };
Console.WriteLine($"v{e.Version} {e.EventTypeName,-21} {Describe(e.Data)}");
}
Console.WriteLine($"OnHand={onHand}");
What happens? Does a deserialization exception get thrown, does OnHand come out as 9, or something else?
Why Can't Events Be Changed?
The real output:
v1 stock_item_registered StockItemRegistered
v2 stock_received StockReceived
v3 stock_adjusted StockAdjusted { QuantityChange = 0, Reason = Damaged }
v4 stock_adjusted StockAdjusted { QuantityChange = 0, Reason = CountCorrection }
OnHand=10
No exception, no warning, wrong stock. The JSON (JavaScript Object Notation) in the database still says "Delta": -2; System.Text.Json could not find the QuantityChange parameter of the new record's constructor and silently gave it 0. The decider now thinks a copy that is no longer in the warehouse can be sold.
This example sums up why versioning is a topic of its own in event sourcing. When you rename a column in a relational table, you write a migration and move the data; the old shape is left behind. In an event store the past is permanent in the shape it was written, and every rehydration and every projection rebuild reads it again with today's code. Today's code has to be able to read every event shape ever written.
"Then let's update the old events too" is the first idea that comes to mind, and we will discuss below why it is the last resort. Greg Young's book Versioning in an Event Sourced System devotes a separate chapter to the question "Why can't I update an event?" and builds its techniques on that constraint: versioned types, weak schema (reading an event through a lenient mapping), transforming old events while reading, and finally copy-replace. Marten's documentation starts from the same place: do not change the past, write the correction as a new event.
Tolerant Deserialization: Add, Don't Remove, Don't Rename
The first way to protect yourself is to turn the silent bug into a loud one. The RespectRequiredConstructorParameters setting that came with .NET 9 makes deserialization fail instead of filling constructor parameters that have no counterpart in the JSON with defaults:
// Fail instead of silently defaulting a missing constructor parameter (.NET 9+)
if (args.Contains("strict"))
opts.UseSystemTextJsonForSerialization(configure: json => json.RespectRequiredConstructorParameters = true);
EventDeserializationFailureException: Event deserialization error on sequence = 3 for event type stock_adjusted
JsonException: JSON deserialization for type 'Bookstore.Inventory.StockAdjusted' was missing required properties including: 'QuantityChange'.
Now the rename blows up before deployment, in tests or on staging. It has a cost: adding a field to an event now also requires a decision. Make the added field nullable (string? Note) or give it a default in the constructor (string Note = ""); neither fails when reading old events. The rule is simple and is the essence of what Greg Young calls weak schema: adding a field is safe, removing a field does not break the reader but can make written data meaningless, and renaming is really "remove and add".
If a rename is really needed, keep the name in the JSON fixed and change the C# name:
// The name in JSON stays; only the C# name changes
public sealed record StockAdjusted(
StockItemId StockItemId,
[property: JsonPropertyName("Delta")] int QuantityChange,
AdjustmentReason Reason,
DateTimeOffset At) : StockEvent(StockItemId, At);
v1 stock_item_registered StockItemRegistered
v2 stock_received StockReceived
v3 stock_adjusted StockAdjusted { QuantityChange = -2, Reason = Damaged }
v4 stock_adjusted StockAdjusted { QuantityChange = 1, Reason = CountCorrection }
OnHand=9
New events keep being written with the name "Delta". Marten's documentation points out a side effect: when you query event data with LINQ (Language Integrated Query), Marten uses the C# name while the JSON holds the old one; the query returns empty without an error.
A New Version of an Event Type and Upcasting
Sometimes the change is bigger than a field name. In post 05 we produced an OutOfStock event when stock was insufficient. When reservations also need to be rejected for a discontinued item, the team decides to generalize this event: its new name is ReservationRejected and it carries the rejection reason explicitly.
// v2: the new name of OutOfStock; the rejection reason is now explicit
public enum RejectionReason { OutOfStock, Discontinued }
public sealed record ReservationRejected(
StockItemId StockItemId, OrderId OrderId, int Requested, int Available, RejectionReason Reason, DateTimeOffset At)
: StockEvent(StockItemId, At);
The old events sit in the database under the name out_of_stock and without a reason field. Upcasting is a layer that turns this old shape into the new type at read time. In Marten, if you do not want to keep the old C# class in your code, you write an upcaster that works on the JSON:
// Turns the old "out_of_stock" JSON into the new type while reading; the old class is not needed in code
public sealed class OutOfStockUpcaster : EventUpcaster<ReservationRejected>
{
public override string EventTypeName => "out_of_stock";
protected override ReservationRejected Upcast(JsonDocument oldEvent)
{
JsonElement e = oldEvent.RootElement;
return new ReservationRejected(
new StockItemId(e.GetProperty("StockItemId").GetProperty("Value").GetGuid()),
new OrderId(e.GetProperty("OrderId").GetProperty("Value").GetGuid()),
e.GetProperty("Requested").GetInt32(),
e.GetProperty("Available").GetInt32(),
RejectionReason.OutOfStock,
e.GetProperty("At").GetDateTimeOffset());
}
}
opts.Events.Upcast<OutOfStockUpcaster>();
After registering the upcaster, the new code writes one more ReservationRejected and we read the stream:
v1 stock_item_registered StockItemRegistered
v2 stock_received StockReceived
v3 out_of_stock ReservationRejected (Requested=2, Available=1, Reason=OutOfStock)
v4 reservation_rejected ReservationRejected (Requested=5, Available=1, Reason=OutOfStock)
Application code and projections only know ReservationRejected; there is no OutOfStock branch left in the switch statements. In the database nothing changed: v3 still sits there with the name out_of_stock, the old JSON, and the .NET type name Bookstore.Inventory.OutOfStock. That is also what is right for audit; on that day the system really did say "not enough stock".
Upcasters have rules too. They must be pure: Marten's documentation specifically warns that an upcaster runs on every deserialization and that going to the database or a service from inside it leads to the N+1 problem. Upcasters can be chained (v1 → v2 → v3), but every new version means one more link in the chain; an event type that changes version very often is usually a sign of a modeling problem.
Event Type Names and Moving Namespaces
An event's identity is its name as much as its JSON shape. Marten stores an event with the alias in the type column and the .NET type name in mt_dotnet_type. If you rename a class, you bind the old alias to the new class with opts.Events.MapEventType<NewName>("old_alias"). If the namespace or assembly changes, the way Marten documents is to register the event types with AddEventTypes; since the alias is derived from the class name, it does not change.
In post 06 we saw the stock_ıtem_registered alias produced under a Turkish culture. Now the two meet. The team moves the events to a separate contracts assembly:
// Refactoring: the events moved to a separate contracts assembly and namespace
namespace Bookstore.Inventory.Events;
public readonly record struct StockItemId(Guid Value);
public readonly record struct BookId(Guid Value);
public abstract record StockEvent(StockItemId StockItemId, DateTimeOffset At);
public sealed record StockItemRegistered(StockItemId StockItemId, BookId BookId, DateTimeOffset At) : StockEvent(StockItemId, At);
// Marten's documented way to move namespaces: register the types, the alias is derived from the class name
opts.Events.AddEventTypes([typeof(StockItemRegistered)]);
We read two streams written before the move, one from a process with the en-US culture and one with tr-TR:
en-US: 1 event(s), first is Bookstore.Inventory.Events.StockItemRegistered
tr-TR: UnknownEventTypeException
type | mt_dotnet_type
-----------------------+--------------------------------------------------------------
stock_item_registered | Bookstore.Inventory.StockItemRegistered, Bookstore.Inventory
stock_ıtem_registered | Bookstore.Inventory.StockItemRegistered, Bookstore.Inventory
The second row's alias matches no registered type, and its .NET type name no longer exists. Both fallback mechanisms failed at the same time. This time the fix really has to happen in the database:
UPDATE inventory_v.mt_events SET type = 'stock_item_registered' WHERE type = 'stock_ıtem_registered';
en-US: 1 event(s), first is Bookstore.Inventory.Events.StockItemRegistered
tr-TR: 1 event(s), first is Bookstore.Inventory.Events.StockItemRegistered
Note that what we changed is not the event's content but the label the store gives it; what happened stays the same. That is why it is an acceptable intervention. Better still is never needing it: give every event type a fixed ASCII (American Standard Code for Information Interchange) name with MapEventType from the start and run server processes with a fixed culture. Not tying an event's name to its C# class name turns refactorings like renames and moves into non-events for versioning.
Last Resort: In-Place Migration
In PostgreSQL mt_events.data is an ordinary jsonb column; turning Delta into QuantityChange with UPDATE ... SET data = jsonb_set(...) is a one-liner. Why is it the last resort? First, audit: the promise of an event store is "what happened that day is what is written"; the moment you change a payload that promise is broken, and the record of who changed what and why lives outside the event store. Second, consumers: async projections that read events do not reread events they have already processed, so read models never see the new shape; if you publish events to the outside, recipients already have the old shape. Third, operations: a bulk UPDATE on a large table holds locks for a long time and produces a lot of WAL (Write-Ahead Log). Marten's archiving documentation also states plainly that changing events with SQL (Structured Query Language) is possible but calls for caution.
If the payload really has to be transformed, Greg Young's copy-replace is a more honest path: copy the stream into a new stream while transforming it, and archive the old one; Marten's documentation has a "copy and transform stream" scenario for this. The history is still in place, and the new stream knows where it came from.
Snapshots: When Are They Needed?
In post 06 we measured that a Live aggregate reads the whole stream on every command. Now let's measure the same thing at the command level: a ReserveStock on a stream with 20,001 events. A snapshot is a stored copy of the aggregate at a given version; in Marten this means registering the write model projection as Inline instead of Live:
// Inline instead of Live: the StockItem document is updated on every SaveChanges too
opts.Projections.Add(new StockItemProjection(), snapshot ? ProjectionLifecycle.Inline : ProjectionLifecycle.Live);
if (args.Contains("converter"))
opts.UseSystemTextJsonForSerialization(configure: json => json.Converters.Add(new OrderIdJsonConverter()));
On the first attempt the command did not run at all:
Unhandled exception. System.NotSupportedException: The type 'Bookstore.Inventory.OrderId' is not a supported dictionary key using converter of type 'System.Text.Json.Serialization.Converters.ObjectDefaultConverter`1[Bookstore.Inventory.OrderId]'. Custom converters can add support for dictionary key serialization by overriding the 'ReadAsPropertyName' and 'WriteAsPropertyName' methods. The unsupported member type is located on type 'Bookstore.Inventory.Reservation'. Path: $.State.Reservations.
Because a Live aggregate is never serialized, keeping StockState's reservations in a dictionary keyed by OrderId had never been a problem. A snapshot, however, has to write the state as JSON, and System.Text.Json does not recognize a strongly typed id as a dictionary key. There are two fixes: make the key a Guid or write a converter. To leave the domain alone, I chose the converter:
// Lets OrderId be written as a dictionary key in the snapshot; as a value the old {"Value": ...} shape is kept
public sealed class OrderIdJsonConverter : JsonConverter<OrderId>
{
public override OrderId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
using JsonDocument document = JsonDocument.ParseValue(ref reader);
return new OrderId(document.RootElement.GetProperty("Value").GetGuid());
}
public override void Write(Utf8JsonWriter writer, OrderId value, JsonSerializerOptions options)
{
writer.WriteStartObject();
writer.WriteString("Value", value.Value);
writer.WriteEndObject();
}
public override OrderId ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
new(Guid.Parse(reader.GetString()!));
public override void WriteAsPropertyName(Utf8JsonWriter writer, OrderId value, JsonSerializerOptions options) =>
writer.WritePropertyName(value.Value.ToString());
}
After registering it with opts.UseSystemTextJsonForSerialization(configure: json => json.Converters.Add(new OrderIdJsonConverter())), the measurement (same machine, each line a separate command):
live: ReserveStock on a 20,001-event stream took 43 ms
live: ReserveStock on a 20,001-event stream took 44 ms
live: ReserveStock on a 20,001-event stream took 45 ms
snapshot: ReserveStock on a 20,001-event stream took 5 ms
snapshot: ReserveStock on a 20,001-event stream took 2 ms
snapshot: ReserveStock on a 20,001-event stream took 1 ms
The difference is large, but there is one more trap. When you turn snapshots on, streams opened in the Live days do not have a snapshot document yet:
// A stream opened in the Live days; the first command after switching to Inline
StockItemId old = new(Guid.Parse(args[1]));
if (args.Contains("rebuild"))
{
using IProjectionDaemon daemon = await store.BuildProjectionDaemonAsync();
await daemon.RebuildProjectionAsync<StockItemProjection>(CancellationToken.None);
}
await using (IQuerySession query = store.QuerySession())
Console.WriteLine($"Snapshot document exists: {await query.LoadAsync<StockItem>(old.Value) is not null}");
try
{
IReadOnlyList<StockEvent> events = await handler.HandleAsync(new ReserveStock(old, new OrderId(Guid.CreateVersion7()), 1, at));
Console.WriteLine($"Decided: {events.Single().GetType().Name}");
}
catch (DomainException ex)
{
Console.WriteLine($"{ex.Code}: {ex.Message}");
}
Snapshot document exists: False
stock.not-registered: Stock item is not registered.
Snapshot document exists: True
Decided: StockReserved
On the first run FetchForWriting found no document and returned a null aggregate; the decider saw the initial state and said the stock item was not registered. This time the command was rejected; with another rule a wrong event could have been written. On the second run the projection was rebuilt first and the command worked correctly. Changing a lifecycle has to be done together with a rebuild, as a deployment step.
Snapshot frequency is a trade-off too. An Inline snapshot rewrites the document on every command: reads get cheaper, writes get more expensive. An Async snapshot is updated by the daemon and does not slow the command down at all; according to Marten's documentation, FetchForWriting gives the command consistent state whatever the lifecycle. One more rule: a snapshot is not the source of truth. It can be deleted, regenerated, and rebuilt when its shape changes (like with the converter just now). The answer to "when" is measurement: if the vast majority of streams stay at a few hundred events, Live is enough; if there are streams reaching thousands of events as in post 06, think about shortening the stream first and snapshots second.
Keeping Streams Short: Closing the Books
In accounting the books are closed at year end and the new year opens with a balance carried forward; nobody adds up every receipt since 2019 to compute today's cash. In event sourcing, "closing the books" is the same idea: limiting a stream to a period that is meaningful to the business. In the stock ledger that period can be the annual stock count. On count day the old stream is closed with a StockCounted event and archived, and the new period's stream opens with the counted quantity. The time-sliced stream id strategy from post 06 (stock-978-0441172719-2026) exists for this.
What makes it different from a snapshot is that the shortening is a concept of the domain itself: a balance carried forward is not a technical optimization but something the business really does. In Marten you archive the closed stream with session.Events.ArchiveStream(id); events are not deleted, they are marked is_archived, and the async daemon skips them by default. CompactStreamAsync, which came with Marten 8, is more aggressive: it replaces old events with a single Compacted<T> snapshot event, and if you do not provide an archiver the old events really are deleted. Think twice before using it on a ledger where audit matters.
Common Pitfalls
Tying events tightly to C# classes
The common root of the three problems in this post: the event's permanent identity (its alias and JSON field names) was derived from the C# class name and its properties. Renaming a class, changing a property, or moving a namespace are ordinary refactorings; on event types each one is a versioning event. Keep events in a separate file or contracts folder, pin their names with MapEventType, and flag changes in that folder separately in code review.
Deleting the old type and forgetting the upcaster
If you delete the OutOfStock class without writing an upcaster, the first request that reads the stream gets an UnknownEventTypeException; the async daemon, by default, skips the unknown event and the read model silently ends up incomplete (SkipUnknownEvents, one of the error-skipping defaults we saw in post 07). Before deleting an old event type, check how many events with that alias are in the database.
Treating a snapshot as the source of truth
Fixing a snapshot document by hand, or another service writing to it directly, produces state that contradicts the events and disappears on the first rebuild. A correction is always a new event.
A Small Experiment
★☆☆ Add a string? DeliveryNote field to StockReceived and read old streams with RespectRequiredConstructorParameters = true. Do you get an error? What changes when you make the field a non-nullable string?
★★☆ Rewrite OutOfStockUpcaster as an EventUpcaster<OutOfStock, ReservationRejected> that uses the old OutOfStock record instead of JSON. Which would you prefer in production, and why?
★★★ Implement the closing the books flow for the annual count: a command adds StockCounted to the old stream and archives it, and opens the new year's stream with the counted quantity in the same transaction. Where will you find a book's current stream id?
We have learned to manage the shape of events and the length of streams. But some problems do not fit within a stream's boundary at all: what happens to immutable events when a customer asks for their data to be deleted, and how is a uniqueness rule that spans two streams protected? The next post takes on these hard parts of event sourcing and the Dynamic Consistency Boundary.