Event Sourcing from the Ground Up, Part 4: Production Realities
Schema evolution and upcasting, the right to be forgotten, why Kafka isn't an event store, dynamic consistency boundaries, and the 2026 JVM library landscape.
Parts 1–3 built the model: events as truth, a Kotlin write side, CQRS read models. This closing part is about what introductions skip — the second year of an event-sourced system: schema evolution, the right to be forgotten, the Kafka trap, the debate about aggregates, and what to actually run in production.
1. Your events will outlive your code
Every event you’ve ever written is still in the store, serialized in the shape your domain had on the day it happened. Your code, meanwhile, refactors weekly. This mismatch — old facts, new code — is the defining maintenance problem of event sourcing; the empirical literature ranks schema evolution among the top challenges practitioners actually report (Overeem et al.).
The ground rules come from Greg Young’s short, free book on the subject, Versioning in an Event Sourced System, and they’re stricter than they look:
- Never mutate a stored event. Not to fix a bug, not to fix a typo. The log’s value is its immutability; edit it once and every replay, audit, and projection becomes unverifiable.
- Additive changes are safe. New event types, new optional fields — fine.
- A new version must be convertible from the old one. If you can’t write a pure function
v1 -> v2, it isn’t a new version of the event — it’s a different event, and deserves a new name. - Don’t rename, don’t change a field’s meaning. With weak-schema serialization (JSON), renaming is deletion-plus-addition, and old events silently lose data.
The standard mechanism is the upcaster: a function applied on read that lifts old shapes to the current one, so domain code only ever sees the latest version:
// v1 had no currency; v2 added it. Old facts get an explicit default on read.
fun upcastWithdrawn(json: Map<String, Any?>): AccountEvent.Withdrawn =
AccountEvent.Withdrawn(
amountCents = (json["amountCents"] as Number).toLong(),
currency = json["currency"] as? String ?: "EUR", // the v1->v2 decision, in one place
)
Upcasters compose (v1->v2->v3), so handlers stay clean even after years of drift — though each chain is code you maintain forever, which is a good reason to prefer additive changes that don’t need one (Dudycz, How to (not) do the events versioning?; Marten’s versioning docs show the same patterns in another stack). For wholesale migrations — splitting a stream, merging two, removing an event type — Young’s prescription is a copy-and-transform: write a new stream from the old one and retire the original. Heavyweight, deliberate, and auditable, exactly as rewriting history should be.
One organizational habit beats every technique here: review event schemas like public APIs, because that’s what they are — contracts with your own future.
2. The right to be forgotten vs. the log that never forgets
GDPR Article 17 grants erasure; the event log’s whole design premise is never erase. Two patterns reconcile them:
Crypto-shredding. Encrypt personal fields with a per-person key kept in a separate key store. Erasure request? Delete the key. Every copy of the data — store, backups, projections — becomes undecryptable ciphertext in place (Verraes, Event Sourcing Patterns: Crypto-Shredding).
Forgettable payloads. Don’t put personal data in events at all. Events carry an opaque reference (ownerRef = "person-7f3a"); the actual name and email live in a mutable, deletable store. Erasure deletes the referenced record; the log keeps only meaningless pointers (Dudycz, How to deal with privacy and GDPR in event-driven systems).
A caveat worth taking seriously: whether deleting a key legally equals deleting data is debated — encrypted personal data is arguably still personal data, and regulator guidance varies (Otar’s notes on GDPR-compliant event sourcing). Forgettable payloads is the more conservative pattern. Either way, the design lesson is identical to versioning’s: decide which fields are personal data before you write event one, because retrofitting either pattern means migrating the entire log. I’m not a lawyer; for a real system, involve someone who is.
3. The Kafka trap
The most common architectural mistake in this space: “Kafka is an append-only log, event sourcing needs an append-only log, therefore Kafka is my event store.” The syllogism fails on the two operations Part 2 showed the write path cannot live without:
- Read one entity’s history. Kafka has no “give me all events for key X.” Entity streams don’t exist; a partition holds thousands of entities interleaved, and reconstructing one means scanning everything (Cassisi, Why is Kafka not ideal for Event Sourcing?).
- Conditional append. There is no
expectedVersion. Two producers can both validate against stale state and both publish; Part 2’s double-withdrawal happens silently, and no consumer can tell (Hammarbäck, Apache Kafka is not for Event Sourcing).
The root error is a category confusion: event streaming moves events between systems; event sourcing stores state as events (Dudycz, Event streaming is not event sourcing!). Kafka is excellent at the first job — which is precisely the role Part 3 gave it: transporting integration events from your outbox to other services. Store in an event store; ship with a broker. Teams that conflate the two tend to write retrospectives (DNA Technology, Why we dropped event sourcing with Kafka Streams).
4. Do we even need aggregates? Dynamic consistency boundaries
Our Part 2 design followed DDD orthodoxy: one stream per entity, the stream as the consistency boundary. The freshest debate in the field asks whether that boundary is too rigid. Sara Pellegrini’s “Killing the Aggregate” argument: aggregates grow into god objects, and a per-stream version check serializes all writes to an entity even when the operations couldn’t possibly conflict — think two students enrolling in different courses, both forced through one Student stream.
Dynamic Consistency Boundaries (DCB) invert the design: events are stored with tags rather than into one fixed stream, and an append says “commit unless a conflicting event matching this query has appeared since I read.” The boundary is declared per-decision at runtime, not per-entity at design time (dcb.events; EventSourcingDB’s DCB guide). It generalizes Part 2’s expectedVersion from “stream unchanged” to “predicate still true” — strictly more expressive, at the cost of a more complex store. It’s early — a handful of stores and Axon’s new major version are building support — but worth understanding now, because it reframes the design question of event sourcing (“where are my boundaries?”) as something you can change your mind about.
5. What to actually run (the 2026 landscape, JVM-flavored)
Part 2’s InMemoryEventStore was a teaching device. The real options, from a Kotlin seat:
- KurrentDB (EventStoreDB, renamed when the company became Kurrent) — the purpose-built event store: streams,
expectedVersion, catch-up subscriptions, projections, with a JVM client usable from Kotlin. The closest production analogue to what we built. - Axon Framework — the heavyweight JVM option: aggregates, command bus, event store, sagas, plus dedicated Kotlin extensions. You buy the whole opinionated stack; in return, the Part 2/3 plumbing is handled.
- Occurrent — a deliberately unframework-ish JVM library (CloudEvents-based) whose API is close to this series’ decider style; pleasant from Kotlin if you want a library, not a lifestyle.
- Postgres, hand-rolled — an
eventstable, a unique index on(stream_id, version)for the concurrency check,LISTEN/NOTIFYor polling for subscriptions. Entirely viable at moderate scale, and you already operate it. The trade is building subscriptions and projection management yourself. - Akka / Apache Pekko Replicated Event Sourcing — the option that closes this blog’s loop: an entity’s journal replicated across regions, each replica accepting writes, converging via operation-based CRDT rules. When you need both the audit log and multi-region availability, the two patterns of these two series stop being alternatives and compose — with the caveat the CRDT series ends on: invariants like “balance never negative” are again off the table outside a single writer, because that’s the corner of CAP you chose.
When (not) to event source
After four parts, the honest summary. Reach for event sourcing when history is the domain: money movement, orders and fulfillment, insurance claims, anything audited, contested, or analyzed retroactively. Skip it when state is just state — profiles, settings, content — where CRUD’s simplicity wins and “how did it get here?” has never been asked. Within one system, mix freely: event source the core, CRUD the periphery. And if your actual problem is concurrent editing and offline merge rather than auditability and invariants — you wanted the other series all along.
The throughline of both: stop storing answers; store the things that happened, and derive the answers. CRDTs derive them commutatively so replicas can’t disagree; event sourcing derives them from a totally ordered log so the business can’t be wrong about its own past. Pick by which guarantee your domain can’t live without.
References
- Young, Versioning in an Event Sourced System (free online)
- Dudycz, How to (not) do the events versioning?
- Marten: Events versioning
- Verraes, Crypto-Shredding
- Dudycz, GDPR in event-driven architecture
- Cassisi, Why is Kafka not ideal for Event Sourcing?
- Hammarbäck, Apache Kafka is not for Event Sourcing
- Dudycz, Event streaming is not event sourcing!
- dcb.events
- EventSourcingDB: Dynamic Consistency Boundaries
- Overeem et al., empirical study
- Pekko: Replicated Event Sourcing