Stories · Season 2 — Frontrow · Episode 07
The Field We Forgot to Add
In which a perfectly good event from three years ago refuses to admit which sales channel it came from.

The story
Frontrow wanted to know, for every ticket ever sold, which channel it came
from — web, mobile app, or box office. Marketing needed it. The board wanted it.
It seemed like a five-minute job: add a channel field to the TicketSold
event and start recording it.
The five-minute job worked great for every ticket sold from that day forward. The
problem was the three years of TicketSold events that already existed and knew
nothing about channels. When the system tried to load an old event into the new
shape, it choked — there was no channel in the data, and the code now insisted
there should be.
The team’s options all looked grim. Rewrite three years of historical events to
add the field? But events are supposed to be immutable — rewriting history is
exactly the thing event sourcing promises not to do, and it would invalidate
every audit guarantee they’d built on top of it. Add if (channel == null)
checks in forty places? That’s how a clean codebase rots into a museum of
past-version special cases.
Mara stared at the immutable event log. “The old events are correct. They’re just old. I need to teach the system how to read them — not change what they say.”
Why this is hard the traditional way
Data outlives code. Any system that keeps history will, sooner or later, try to read old data with new code, and the shapes won’t match. In a state-based system you “fix” this with a migration that rewrites the rows — destroying the old shape in the process. In an event-sourced system you can’t do that without betraying the whole point: the events are an immutable record of what happened, and “what happened” three years ago genuinely did not include a channel.
So you’re caught between two bad options: rewrite immutable history (and lose your
guarantees), or scatter version-handling ifs through your domain logic (and lose
your sanity). Neither scales past a couple of schema changes.
How Relay changes the ending
Relay solves this with upcasters: small, isolated functions that translate an old event shape into the current one as it’s read, without touching what’s stored. The event on disk stays exactly as it was written; the upcaster fills in the gap on the way in.
// The stored event is never modified. This translates v1 -> current on read.
public sealed class TicketSoldUpcaster : IEventUpcaster<TicketSoldV1, TicketSold>
{
public TicketSold Upcast(TicketSoldV1 old) =>
new(old.TicketId, old.Price, Channel: "unknown"); // honest default for the past
}
Now your domain code only ever sees the current shape — no null checks, no
special cases. The history stays immutable and auditable. And because each schema
change is just another small upcaster in a chain, the system can evolve for years
without the codebase turning into a museum. The old events keep saying exactly
what they always said; the system just got better at listening.
What it costs you to ignore this
- You stop being able to change anything. Fear of breaking old data freezes the schema, and a frozen schema slowly strangles the product.
- History gets rewritten — or pretends to be. Migrating immutable events away destroys the audit trail you adopted event sourcing to get.
- Version checks metastasize.
if (oldFormat)spreads through the domain until nobody can read it. - “Just add a field” becomes a project. A trivial change turns into a multi-day migration with real risk attached.