Writing to both
Moving one service to a new database is a project. Moving a platform is a programme, and it cannot happen in one step, because the services do not all move on the same day and the old ones keep running while the new ones appear around them.
For a while — months, realistically — the same business facts have to exist in two data models at once. Dual writing is the mechanism for that, and although the idea is simple, almost everything interesting is in the details of how it fails.
The idea
Every write that lands in the old model also gets written to the new one. Reads keep coming from the old model until you are confident the new one is correct, then you switch reads over, then eventually you stop writing to the old one.
The value is that it decouples the schema migration from the service migration. Services move when they are ready rather than when the database is, and the cutover for any given read path becomes a small, reversible decision rather than a date in a calendar with everybody watching.
It is a translation, not a copy
The thing that makes this more than a `save()` call twice is that the two models are not the same shape. That was the whole point of the new one.
Integer primary keys became UUIDs. Wide tables were split into separate concerns. Timestamps changed type. Fields that were nullable for historical reasons acquired constraints. A row in the old model does not correspond to a row in the new one — it corresponds to a couple of rows, with identifiers that have to be resolved rather than copied.
So each entity needs a mapper: a deliberate, tested translation between the legacy shape and the new one. That mapper is where the migration's real risk lives, and giving it its own class and its own tests — rather than letting it sprawl through the persistence layer — is the single most useful structural decision in the whole arrangement.
The second write must never break the first
Here is the constraint that shapes everything else: during the migration, the old model is still the truth. Customers are served from it. If the new model is unreachable, misconfigured, or rejects a row because a constraint is stricter than it used to be, that must not fail the operation the user asked for.
Which means the secondary write is best-effort, and its failures need somewhere to go that is not an exception propagating back to the caller. A dedicated failure service — at first, simply recording what failed and why — turns a class of incident into a queue of things to investigate.
This is the part people underestimate. A naive dual write wraps both writes in one transaction, feels correct, and converts every problem in the new stack into an outage in the old one. You have taken a system that worked and made its availability the product of two systems, one of which is by definition not finished.
Modes, not a switch
Dual writing is not on or off. Different entities are at different stages at any moment, so it needs to be configurable per entity: not yet, writing to both, reading from the new one, finished.
Making that a property of each entity rather than a global flag is what lets the migration proceed as a series of small independent decisions. One entity can be fully cut over while another has not started, and a problem with one does not hold up the rest.
In practice that means a registry of per-entity writers, configuration that selects the mode, and enough wiring that adding a new entity to the scheme is a small, obvious piece of work rather than a redesign. We started the framework with a couple of billing entities, deliberately — real enough to prove the approach, small enough that being wrong was cheap.
Drift is the part you cannot skip
Dual writing keeps the two models in step going forward. It does nothing about records written before you started, and nothing about the ones where the secondary write quietly failed.
So the scheme is incomplete without two more things: a backfill to migrate what already existed, and a drift check that compares the two models and reports where they disagree. Until the drift check exists and reads clean, "the new model is correct" is a belief rather than a measurement — and the decision to switch reads over is exactly the decision you should not be making on a belief.
We shipped the framework first and deferred both, which I think is right ordering: the drift check needs something to check, and building it against a real dual-write path is easier than designing it in the abstract. But it does mean the framework landing is the start of the work rather than the end of it, and it is worth being honest with yourself about that rather than treating the merge as the milestone.
What I took from it
The mapper is the migration. Everything else — modes, registries, configuration — is scaffolding. The translation between old and new shapes is where correctness is won or lost, and it deserves to be an explicit, individually tested unit rather than logic smeared across a repository class.
Best-effort means having a plan for the effort failing. Swallowing secondary write failures silently is as bad as letting them propagate. The point is not that they do not matter — it is that they matter later, to you, rather than now, to the customer.
Per-entity state beats a global switch. A migration that has to move as one unit is a migration that moves on its slowest component. Independent progression is what keeps it from becoming a big bang wearing a disguise.
You do not know the models agree until you have measured it. Dual writing feels like it guarantees consistency. It guarantees an attempt at consistency. The difference between those two is precisely the set of rows you will find out about later, and a drift check is how you choose when.
Also in this series
- The hard part was the devices already out there — the rewrite and cutover
- The whitespace was load-bearing — valid XML, truncated anyway
- Nonces don't fit in one process — auth state after horizontal scaling
- Six vendors, one API — the abstraction tax of other people's firmware
- Cheaper for the server, dearer for the phone — trading memory for latency
- Everyone checks in at once — fixed intervals and thundering herds
- Expected failures are not errors — rolling out tracing and useful logs