An event store is append-only by design, which is exactly what makes schema change hard: you cannot go back and edit the ten million events already written under the old shape. Every consumer that replays history — projections, audit reports, integration bridges — has to keep understanding events it wrote years ago, even as the producer's model of the world moves on. Versioning is how you make that promise without freezing the schema forever.
Why in-place schema changes don't work
A relational table lets you run ALTER TABLE and move on, because the table only represents current state. An event stream represents history, and history doesn't get to change. If OrderPlaced gained a currency field last year, every OrderPlaced event written before that change is still missing it. You cannot backfill the field into the stored event without lying about what actually happened at that point in time — and any consumer that persisted a projection from the old events already baked the absence of that field into its own state.
The practical consequence: schema evolution in an event-sourced system is additive and versioned, not destructive and in-place. You never mutate a stored event. You either make new fields tolerable to old readers, or you introduce a new event version and teach your read path to translate between them.
Versioned event types
The simplest scheme is to put a version number directly in the event's type name or metadata — OrderPlaced.v1, OrderPlaced.v2 — and treat each version as a distinct, immutable contract. When the business needs a field that genuinely changes the meaning of the event (not just adds an optional attribute), you cut a new version rather than overload the old one. Consumers that only need current behavior read the latest version; consumers replaying history see whichever version was actually persisted at that point in time, unaltered.
This works well for large, meaningful changes — splitting one event into two, renaming a field whose semantics changed, adding a required field that didn't previously exist. It's overkill for additive, backward-compatible changes, which is where upcasting earns its keep.
Upcasting on read
Upcasting means transforming an old event shape into the current shape at read time, before it reaches application code. The stored event never changes — a translation layer sits between the event store and the deserializer, and every event, regardless of when it was written, arrives at the consumer looking like the latest schema. New optional fields get sensible defaults; renamed fields get mapped; removed fields get dropped or ignored.
public interface IEventUpcaster
{
string SourceType { get; } // e.g. "OrderPlaced.v1"
JObject Upcast(JObject raw); // returns v2-shaped payload
}
public class OrderPlacedV1ToV2 : IEventUpcaster
{
public string SourceType => "OrderPlaced.v1";
public JObject Upcast(JObject raw)
{
// v2 added an explicit currency code; v1 events
// were all USD, so default rather than reject.
if (raw["currency"] == null)
raw["currency"] = "USD";
raw["schemaVersion"] = 2;
return raw;
}
}
// Read-path pipeline: apply every upcaster whose
// SourceType matches, in order, before deserializing
// into the current CLR type.
public JObject ApplyUpcasters(string eventType, JObject raw, IEnumerable chain)
{
foreach (var upcaster in chain.Where(u => u.SourceType == eventType))
raw = upcaster.Upcast(raw);
return raw;
}
Chaining upcasters (v1→v2, v2→v3) rather than writing every version straight to the latest keeps each transform small and testable, and it means adding v4 later doesn't require touching the v1→v2 logic at all.
Backward and forward compatibility rules
Most of the pain disappears if you enforce a small set of rules on every event schema change. Additive changes — new optional fields with defaults — are backward compatible and need no upcaster at all if your deserializer ignores unknown fields and defaults missing ones. Removing a field is forward compatible for old consumers (they just never read it) but breaks anything that treated it as required, so removals should go through a deprecation window where the field is written-but-ignored before it disappears. Renaming a field or changing its type is neither compatible on its own; it always needs an explicit upcaster or a new event version.
If a field's unit, precision, or semantics change — cents to decimal, local time to UTC, nullable to required — treat it as a new field or a new event version. Silently reinterpreting old data through new code produces numbers that are wrong in ways nobody notices until reconciliation fails months later.
Rebuilding projections after a schema change
Projections built from the event stream are derived state, which means they're disposable — that's the whole point of event sourcing. When an upcaster changes how history is interpreted, the safest move is to drop the affected projection and rebuild it from the beginning of the stream through the new upcasting pipeline, rather than trying to patch the projection's existing rows. This is slower than an in-place update but eliminates an entire class of bugs where the projection reflects a mix of old and new interpretation logic depending on when each row was last touched.
Hand-written test fixtures tend to be shaped like the current schema with fields removed, which isn't the same as what actually got persisted three schema versions ago — optional fields that were never populated, enum values that were later renamed, null handling that changed. Pull a sample of real events from each historical version and run the upcaster chain against them before trusting a projection rebuild.
Keeping the event catalog honest
None of this holds together without a single source of truth for which event versions exist, what each upcaster does, and which consumers still depend on which version. A schema registry — even a lightweight one that's just versioned JSON Schema files in the repo with the upcaster code next to them — stops the quiet drift where a producer ships a breaking change and three consuming services find out when their projections stop matching finance's numbers.
| Change type | Compatible without upcaster? | Action |
|---|---|---|
| New optional field, sensible default | Yes, if readers ignore unknowns | Document it; no upcaster needed |
| New required field | No | New event version; upcaster fills default for old events |
| Field rename | No | Upcaster maps old name to new |
| Field removed | Forward-compatible only | Deprecate first, remove after a window |
| Semantic/unit change | No | New event type or explicit version bump |
The discipline that makes this sustainable is small: never mutate a stored event, treat every schema change as additive or explicitly versioned, and keep upcasters as the only place old and new shapes ever meet. Do that consistently and a five-year-old event store stays replayable instead of becoming an archive nobody trusts.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.