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 · · updated Aug 6, 2026 · 6 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.
Why I now reach for it by default
I used to hold a narrow rule: event sourcing only where the history of changes is itself the product, and plain CRUD everywhere else. I have moved off that position. My default now is event-sourced, and CRUD is the exception I have to justify.
Two things changed my mind. The first is that a CRUD system is a corporate black box: fast to write, but it forgets the why, which is the exact loss this whole essay is about. The second is that writing the extra structure is no longer the cost it was. With a model assisting the code, the events, the projections and the fold are cheap to produce, and most of what used to make event sourcing "expensive" was really just typing.
It also dissolves a false choice. A CRUD table you only ever append to, and read the latest row of, is already a degenerate event-sourced system: you have simply hidden the log. And you do not lose CRUD by going event-sourced, you keep all of it, create, update and delete are events, and a read is a projection. That is the whole model. It is simpler, to me, to hold one chain of events as the source of truth and backfill state from it than to keep mutable state and bolt history on afterwards.
The costs are real and worth naming. Event schema versioning: facts written years ago must still replay under today's code. Projection rebuild time: a log of tens of millions of facts needs snapshots and some thought about cold-start latency. Discipline: the log is append-only, and the day someone "just fixes" an event in place with an UPDATE is the day the guarantee collapses. None of these is a reason to keep a history-losing default, they are the engineering you do to keep the history you decided was worth having. The honest exception runs the other way: a genuinely disposable table, a cache, a lookup nobody will ever ask the past of, can stay plain JDBC rows with Flyway migrations. 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.