A stored event never changes. Its payload was written by the code of its day, and a year later
that shape may no longer be what evolve, your projections and your policies expect. Bounda’s
answer is an upcaster: a pure function that takes the payload as one version stored it and
returns the payload of the next version. Upcasters live next to the event, in
<event>.upcast.ts, and the runtime applies them every time an old event is read.
app/domain/order/ order-placed.ts the event, as it is today order-placed.upcast.ts how older payloads become today'sA chain of versions
Section titled “A chain of versions”Say OrderPlaced has changed twice since the first orders were stored. Version 1 had a price
per line; version 2 renamed it to unitPrice; version 3, today’s, added the currency that
multi-currency pricing needed. The upcast module holds one upcaster per change, oldest first:
import type { Event } from "./+types/order-placed";
interface PayloadV1 { readonly customerId: string; readonly lines: readonly { readonly sku: string; readonly quantity: number; readonly price: number }[];}
interface PayloadV2 { readonly customerId: string; readonly lines: readonly { readonly sku: string; readonly quantity: number; readonly unitPrice: number }[];}
export const upcasts = [ // v1 → v2: `price` became `unitPrice` (payload: PayloadV1): PayloadV2 => ({ customerId: payload.customerId, lines: payload.lines.map(({ sku, quantity, price }) => ({ sku, quantity, unitPrice: price })), }), // v2 → v3: prices gained a currency; everything sold before multi-currency was in euros (payload: PayloadV2) => ({ ...payload, currency: "EUR" }),] satisfies Event.Upcasts;Each upcaster turns the payload of version n into the payload of version n + 1, and only
knows those two shapes. The last one produces the payload the event has today, and
Event.Upcasts checks that it does. Run bounda generate after adding the file, as for any
module.
What an old event goes through depends on the version it was stored with:
| Stored with | Upcasters applied when it is read | Reaches evolve as |
|---|---|---|
schemaVersion: 1 |
both, in order | version 3 |
schemaVersion: 2 |
the second one | version 3 |
schemaVersion: 3 |
none | version 3 |
The next change to the payload is one more upcaster at the end of the array, from version 3 to
version 4. The existing ones stay as they are: they describe history, and history does not change.
Keep the old payload interfaces (PayloadV1, PayloadV2) in the upcast module for the same
reason, rather than deriving them from today’s event.
What the runtime does
Section titled “What the runtime does”- Every stored event carries
metadata.schemaVersion. An event withnupcasters is written with versionn + 1; an event that never changed shape stays at1. - When an event is read, for a command, a policy, a process, a projection or a rebuild, the
upcasters from its stored version on are applied to the payload. What reaches
evolveand the handlers is always the current shape, stamped with the current version. Nothing is rewritten in the store. - An event written with a version this code does not know, because a newer deploy wrote it, is refused with an error naming the event. Roll forward, never back, once new events exist.
The types keep you honest in one direction: the last upcaster must return the current payload, so
a change to payload in the event module fails to compile until the upcast module catches up.
They do not check the links in between: Event.Upcasts does not verify that each upcaster accepts
what the previous one returns. Annotating every step’s parameter and return type, as above, is
what keeps the chain straight.
When to write one, and when not to
Section titled “When to write one, and when not to”- A field renamed, split, merged or re-typed: an upcaster.
- A new optional field: nothing. Old payloads simply lack it;
beginorevolvereadsundefined. - A new required field: an upcaster that fills it with the value the old world implied, like
currency: "EUR"above. - Something that was never in the payload and cannot be derived: not an upcaster. Introduce a new event type and keep handling the old one; upcasters transform, they do not invent.
Upcasters are cheap but not free: they run on every read of an old event. An aggregate with thousands of old events re-runs the chain on every command, since Bounda has no snapshots yet (what is not there yet), and a rebuild runs it over the whole global stream once.
What is not covered yet
Section titled “What is not covered yet”- Renaming or removing an event type. Upcasters change the payload, not the type. A stored
event whose module is gone still fails to fold; keep the module, even if its
evolvechanges nothing (() => ({})). - Process state. A process keeps its state in its own lifecycle events. Changing that
state’s shape has the same problem and no upcaster yet; a
state.upcast.tsin the process directory is the natural extension when it is needed.