Zero-downtime migrations from legacy services to Go and Kotlin
By Tarun Nahak
At Mercedes-Benz R&D India I drove the migration of several legacy services to Go and Kotlin. The rule for all of them was the same: users must not notice. No maintenance window, no “please try again later”, no big-bang release night.
That rule shapes everything about how you migrate. You can’t replace a service that is serving traffic in one move, so you replace it in many small ones, each of which you can undo. This post is the sequence I follow.
1. Put a routing layer in front
Before writing any new code, make sure every request to the old service passes through something you control: an API gateway, a reverse proxy, or a thin routing service. Clients keep calling the same address; the routing layer decides which implementation answers.
This is the strangler fig pattern. The new service grows around the old one, route by route, until the old one has nothing left to do. The routing layer is what makes each step reversible, so it’s worth getting in place first, even though it changes nothing visible.
2. Write down what the old service actually does
Legacy services do things nobody remembers asking for: a field that is always returned in a certain order, an error code a client parses, a trailing slash that is accepted on one route and not another. Clients depend on these.
So before rebuilding a route, capture its behaviour. Contract tests built from real requests and responses work well, with sensitive data scrubbed. They become the definition of done for the new implementation.
3. Rebuild route by route
Pick a route that is low risk and well understood, and build it in the new service. Resist the urge to fix its design while you’re there. A migration that also changes behaviour is two projects, and when something breaks you won’t know which one caused it. Improve the design after the cutover, when the old implementation is gone.
4. Shadow traffic before real traffic
Once a route passes its contract tests, have the routing layer mirror real requests to the new implementation while the old one keeps answering. The client gets the old response; the new response is compared with it in the background and then discarded.
The comparisons find what the tests missed. Expect noise: timestamps, generated IDs and field ordering will differ legitimately, so the comparer needs rules for what to ignore. Everything else is either a bug in the new service or a behaviour of the old one you didn’t know about, and both are worth knowing before any user sees a response from the new code.
Shadowing only works for reads, or for writes you can make safely twice. For anything with side effects, like sending an email or charging a card, the shadow copy has to stop short of the side effect.
5. Decide where the data lives
Data is usually the hardest part. The simplest option is for old and new services to share the same database during the migration, so there is one source of truth and nothing to reconcile. If the new service needs its own store, don’t write to both from application code; a crash between the two writes leaves them disagreeing. Write to one and propagate changes to the other, through an outbox table or change data capture, then backfill history and check the two stores match before switching reads.
6. Cut over gradually
Move real traffic in steps: one percent, then ten, then half, then all of it, with a flag per route in the routing layer. At each step, watch the numbers that matter to users: error rate, 99th percentile latency, and whatever business metric the route drives. Move on only when they look the same as before.
7. Rehearse the rollback
Rolling back should be one configuration change in the routing layer, and you should have done it at least once on purpose before you need it. A rollback path that has never been exercised is a hope, not a plan.
8. Finish the job
When a route has run on the new service for long enough to trust it, delete the old code for it, remove its flag and its shadowing rules. Migrations that stall at 95 percent are common, and they leave you running two systems indefinitely, which is worse than either one alone.
Why Go and Kotlin
Each target language suited different services. Go fits small, network-heavy services well: fast start-up, low memory use, a strong standard library for HTTP, and goroutines that make concurrent I/O straightforward. Kotlin made sense where teams depended on JVM libraries and frameworks: it keeps all of that and adds null safety, concise syntax and coroutines.
The language mattered less than the sequence, though. Routing layer, recorded behaviour, route-by-route rebuilds, shadow traffic, gradual cutover and a rehearsed rollback would have worked just as well for any other target. They’re what turn a risky rewrite into a series of small, boring changes, and boring is exactly what you want from a migration.