Zero-downtime migrations from legacy services to Go and Kotlin

By

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.

Hand-drawn diagram of the strangler fig pattern. Clients keep calling the same address. A routing layer with a flag per route sends /users and /search to the legacy service, splits /cart 90 percent legacy and 10 percent new, and sends /orders and /accounts to the new Go or Kotlin service.
Clients keep the same address. The routing layer moves one route at a time, and each move can be undone.

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.

Hand-drawn diagram of shadow traffic. The routing layer sends each request to the legacy service, whose response goes back to the client, and mirrors a copy to the new service. Both responses go to a comparer that ignores timestamps, generated IDs and field order. The new response is then discarded; every remaining difference is a bug or an undocumented behaviour.
Shadowing: the client always gets the old response, and the new one is only compared.

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.

Hand-drawn staircase of a gradual cutover: 1 percent, 10 percent, 50 percent, then 100 percent of traffic on the new service, checking error rate, 99th percentile latency and the business metric before each step. A dashed red arrow shows rollback as one configuration change at any step. At 100 percent, the old code is deleted.
Move traffic in steps, check the numbers users feel at each one, and keep a rehearsed one-line rollback.

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.