Event Sourcing as a Migration Backbone for Legacy Monoliths
Model a migration on immutable business facts instead of a static state transfer, and the implicit logic of twenty years survives the move.
Francesco Bilotta · · 5 min read
Almost every migration I am called in to fix made the same mistake at the start: it treated the old system as a set of rows and set out to move the rows. That is the intuitive thing to do. You look at the current database, build a new schema that holds the same state, and it feels like the job. It is not. The state you are copying is the effect of twenty years of decisions, and the decisions are exactly what you are throwing away. Migrating state is like photographing a chess position and calling it a record of the game: you have the pieces where they stand, but you have lost every move that put them there, and with it every reason.
The counter-intuitive move is to migrate the causes instead of the effect. Do not model the migration on where the rows are today, model it on the immutable business facts that produced them: OrderPlaced, PriceOverridden, ContractRenewed. Once you hold the facts, every state you could want is a function of replaying them, and the twenty years of implicit logic that lived nowhere but in the accumulated mutations comes across with the move.
The state you migrate is a lossy compression
A twenty-year-old management system is a machine for turning events into current state and then forgetting the events. A price gets overridden for one customer because of a deal struck on a phone call in 2011. The override lands in a column. The reason does not. Ten years and four maintainers later, that column holds a number nobody can explain, and everyone is too frightened to normalise it because something, somewhere, might depend on it being wrong in exactly that way. That is the real content of a legacy system: thousands of these little decisions, compressed lossily into current state.
The row tells you the what and has deleted the why, the when, the who decided. The logic that matters is in the sequence of changes the row is the sum of, and if you never captured the sequence, you never had the logic to migrate.
Model the facts, rebuild the state
Event sourcing inverts the storage contract. The source of truth stops being the current state and becomes an immutable log: an append-only record of business facts, in the order they happened, that you never edit and never delete. PriceOverridden { customer, oldPrice, newPrice, reason, actor, at } is written once and stands forever. Current state is no longer something you store, it is something you derive, by folding the log from the beginning.
That fold is where CQRS earns its place. You separate the write model, which only ever appends facts to the log, from the read models, which are projections you build by replaying those facts into whatever shape a screen or report needs. The projections are disposable by design. You found a bug in how a read model aggregates, or you need a report the original authors never imagined: you do not migrate anything, you write a new projection, replay the log through it, and the state materialises. Write side and read side evolve on separate clocks, and neither is the bottleneck for the other.
For a migration this changes the physics of the work. Instead of one irreversible state transfer under a maintenance window, you build a log and rebuild state from it as often as you like, until the projections match the old system row for row. The old system becomes your test oracle: replay the facts, project the state, diff against production, and when the diff is empty you have proven the migration, not hoped it.
Auditability and the strangler-fig fall out for free
Two of the things clients pay most for arrive here as side effects rather than features you built.
The first is auditability. When the log is the source of truth, the log is the audit trail. There is no separate history table to maintain, no updated_by column that lies half the time, no reconstruction after the fact. Every state the system was ever in is reachable by replaying the log to a point in time, and every change carries its own who, when and why because those were facts you recorded, not metadata you bolted on. In a regulated domain this is often the whole reason the modernisation was funded, and event sourcing hands it to you at no marginal cost.
The second is a natural strangler-fig seam. The event log is a boundary the old and new systems can share. You emit facts from the legacy system, or reconstruct them from its change records, and the log becomes the spine the new system grows along: new modules subscribe to the facts and build their own projections while the old code keeps running against its own tables. Traffic moves a slice at a time, each slice verified against the log. The migration stops being a cutover and becomes a vine growing along a backbone.
Use it where history is the product, and nowhere else
Now the honest part, because the failure mode of a good pattern is applying it everywhere. Event sourcing has real costs and most of your tables should never pay them. You take on event schema versioning: facts written five years ago must still replay under today's code, so every change to an event's shape is a compatibility problem you own forever. You take on projection rebuild time: a log with tens of millions of facts does not fold in a second, and you will need snapshots and thought about cold-start latency. You take on discipline: the log is append-only, and the day someone "just fixes" a bad event in place with an UPDATE is the day the whole guarantee collapses. All of it is dead weight on a table whose history nobody will ever ask about.
So the rule I hold to is narrow. Event sourcing goes only where the history of changes is itself the product: a ledger, an audit trail, a schedule people negotiate over time. On the last such rescue that meant one module, the calendar and scheduling core, where the sequence of who booked, moved and cancelled what was the actual thing of value. That module became event-sourced, with read-model projections driving the UI. Everywhere else, where current state was all anyone would query, it stayed plain JDBC rows with Flyway migrations, a normal table doing a normal job. Appropriate complexity is not a slogan here. It is the difference between a backbone and a cage.
This is the whole of what I do when I modernise a legacy system: get the twenty years of implicit logic out of the frozen state it is trapped in and into a form that survives the move. Software shows its worth when things go wrong, and the system you can replay is the one you can still reason about at 2 a.m. Model the facts, and the state takes care of itself.