skip to content

An event type OrderPlaced has been in production for two years with a fixed set of fields. The business now needs to add a required 'currency' field to how orders are represented, but the event store's old OrderPlaced events don't have it. How do you evolve the event schema without breaking existing projections, and what does this have to do with rebuilding read models?

level: seniorimportance: nice to knowfreq 35%

answer

  1. events are immutable, never rewrite history in the store
  2. upcasting = translate old shape to new shape on read, centralized
  3. explicit versioned event types as the alternative approach
  4. purely additive optional fields usually need no formal versioning
  5. schema change implies replay/rebuild of affected projections, not patching rows in place

basics

~20 s

You never rewrite old events; they stay exactly as they were recorded. Instead you translate old events into the new shape on the fly when they're read, or version the event type, and any projector reading them handles both old and new shapes.

solid answer

~40 s

Events are immutable and are never edited in place, since they're the permanent source of truth and possibly already consumed elsewhere. The two standard techniques are upcasting, a translation layer that detects an old-version event and transforms it into the current shape before any consumer sees it, for example stamping a default currency onto every pre-migration OrderPlaced event; and explicit event versioning, publishing a distinct OrderPlacedV2 type going forward while keeping the old handler logic around to interpret V1 events. Purely additive optional fields with a safe default usually need neither technique. Because every projector reads events through the same translation layer, changing that mapping means replaying affected read models, ideally with a blue-green cutover, to stay consistent with what a full replay would produce.

go deeper

for a junior

Knows you shouldn't edit old events after the fact.

for a middle

Can describe upcasting or explicit versioned event types as ways to add a new field safely.

for a senior

Can design the upcasting layer itself, decide whether a change is additive or breaking, and tie the change to a rebuild or blue-green cutover plan.

for a principal

Sets org-wide policy for event schema evolution, including versioning strategy and ownership of upcasters, plus backward-compatibility review gates, so many teams' projectors don't each invent divergent interpretations of the same historical events.

## Why stored history is never rewritten Events in Event Sourcing are **immutable by design**: they are the permanent, append-only source of truth and the audit log, and other consumers may already have processed the original bytes, so rewriting stored history in place breaks the guarantee that replay reproduces exactly what happened and can silently desynchronize any read model or external system built from the unaltered original. Schema evolution therefore has to happen at read time, not by mutating the store. ## Upcasting The primary technique is **upcasting**: a versioning layer sits between the raw stored event and the deserialized object that projector or aggregate code actually consumes. It inspects a schema-version tag on the stored event and, if it's an old version, transforms it into the current shape before handing it onward. For the `OrderPlaced` example, the upcaster would detect any event stored before the migration date and stamp a default currency, say 'USD', onto it, based on the business fact that all orders before that date were placed in US dollars. Every consumer, current and future, sees the same, already-translated shape and never has to special-case the old format itself. ## Explicit event versioning The alternative is **explicit event versioning**: publish a new `OrderPlacedV2` event type going forward, while every piece of consuming code retains the ability to interpret the older `OrderPlaced` type indefinitely. This is simpler to reason about in the sense that nothing is silently rewritten, but it accumulates code over time as more versions pile up, since every consumer, present and future, must keep explicit handling for every historical version it might ever encounter, whereas upcasting centralizes that translation in one place. ## When you need neither Not every change needs either technique. A purely additive change, adding a new optional field with a sensible default when it's absent, such as a nullable 'giftMessage' field, usually requires no formal versioning at all, since old events simply lack the field and consumers treat its absence as the default. Versioning and upcasting become necessary specifically for breaking changes: - removing a field, - renaming one, - changing its type, - or, as in this scenario, adding a field that's semantically required going forward but genuinely absent from history. ## Why the default is centralized There's a real trade-off inside upcasting itself, not just a mechanical one: - **centralizing the 'default to USD' assumption in one place** means it's applied consistently everywhere and is easy to audit and later correct if it's ever found to be wrong for some historical orders, for example ones that were actually entered manually in another currency; - **the alternative, letting each projector independently guess its own default,** risks different read models silently disagreeing about the same historical fact, which is a subtler and harder-to-detect form of inconsistency than a single, deliberately reviewed default. This is also why the choice of default deserves explicit product or business sign-off rather than being treated as a purely technical decision. ## The tie to rebuilding read models The connection to rebuilding read models is direct: every projector deserializes events through the very same versioning or upcasting layer, so once a new mapping is defined, replaying the full historical stream through it produces a read model consistent with the new interpretation, exactly the same replay machinery used generally for adding new projections or recovering from corruption. This is why schema evolution is normally executed as a full or partial rebuild of the affected read models rather than as an in-place patch of existing rows: patching rows by hand risks drifting out of sync with what an actual replay through the corrected logic would produce, especially if the patch script's logic doesn't exactly match the upcaster's. ## A failure mode worth naming A production failure mode worth naming: teams that skip a centralized upcasting layer and instead let each projector special-case 'if the field is missing, assume X' scatter that interpretation inconsistently across many independent projectors, so two read models can end up disagreeing about the same historical order's currency. The standard fix is centralizing the translation once, upstream of every consumer, rather than downstream in each one. This pattern, sometimes called event upcasting, is documented in the broader Event Sourcing community, including in tooling such as **EventStoreDB's** projection and transformation features, and is conceptually similar to how API versioning or database migrations handle backward compatibility, just applied to an immutable append-only log instead of mutable rows. ## What upcasting cannot fix One more subtlety worth flagging: upcasting only changes what a consumer sees when it reads an event, not what was actually recorded at the time, so it's a poor tool for correcting a genuine business-logic bug rather than a schema gap. If the original `OrderPlaced` handler had a real defect, for instance silently dropping a discount that should have applied, an upcaster can't retroactively know what discount should have been applied for each historical order; that class of problem usually needs a separate, explicit backfill process informed by other historical data, distinct from the schema-shape translation upcasting is meant to solve.

  • Why not just run a one-time migration script that adds the missing currency field directly to the old events in the event store?
    That mutates history that's supposed to be immutable, breaking the audit-log guarantee, potentially desynchronizing any external consumer that already read the original event, and undermining the ability to trust replay as reproducing exactly what actually happened. An upcasting layer achieves the same practical outcome, a currency value present on every event when read, without altering the recorded source of truth.
  • If defaulting old OrderPlaced events to currency USD turns out to be wrong for some historical orders, what's the risk of centralizing that assumption in an upcaster versus letting each projector guess independently?
    Centralizing means the assumption is applied consistently everywhere, with one clear place to audit and later correct the decision if it's found wrong. Independent guessing per projector risks different read models silently disagreeing with each other about the same historical fact, which is a worse and much harder-to-detect inconsistency to track down later.

Like reprinting an old census record in today's form by filling in a best-guess default for a field that didn't exist back then, rather than going back and altering the original paper record: the original stays untouched forever, but every modern report generated from it uses the translated version.

saying these in an interview costs you the question

  • proposes editing or rewriting stored historical events in place
  • doesn't distinguish purely additive, optional schema changes from breaking changes that need versioning
  • assumes every projector can independently invent its own default with no coordination
  • forgets that changing how an event is interpreted implies rebuilding already-built projections to stay consistent
  • has no concept of an event schema version or type tag at all

context