A three‑part series · Part 3
Cutover, and Proving You Didn't Break Anything
Choosing a cutover strategy, moving to a new platform, and proving byte‑for‑byte compatibility before any real traffic moves.
A rewrite isn't done when the code compiles. It's done when production traffic is flowing through it and nobody noticed the change. Getting there safely came down to three questions: how do we switch over, where does the new API run, and how do we know it's truly identical before we commit?
Choosing the cutover: why we skipped the strangler
The fashionable answer to "how do you migrate a live system" is the strangler fig pattern: stand up the new system alongside the old, route traffic endpoint‑by‑endpoint through a proxy, and gradually starve the legacy system until it's gone. It's a great pattern — when new and old behave differently and you need to de‑risk each swap independently.
We seriously considered it (including a reverse proxy in front of both), and then chose a big‑bang cutover instead. The reasoning is the whole point of the series:
The strangler pattern earns its complexity when each migrated endpoint is a behavioral risk. Our entire design goal was that each migrated endpoint be behaviorally identical. If parity holds, gradual routing isn't buying down risk — it's just adding a proxy, two live systems, and split‑brain state to worry about.
In other words, the same discipline that made the migration hard — byte‑for‑byte parity — is exactly what made the cutover simple. When the new system is provably a drop‑in replacement, "replace it all at once" stops being reckless and becomes the simpler, safer option. The lesson generalizes: the right cutover strategy is downstream of how confident you are in parity. Earn the confidence first, and you often earn a simpler cutover as a result.
The platform move: Windows/IIS → Linux
The new API is cross‑platform .NET, so we took the opportunity to move off Windows/IIS onto a small Linux VM. The runtime shape is standard and worth stating plainly for anyone doing the same:
- The app runs as a systemd service (auto‑start on boot, auto‑restart on failure), listening only on localhost.
- nginx sits in front as a reverse proxy and terminates HTTPS.
- TLS is a free, auto‑renewing Let's Encrypt certificate.
- Secrets (connection string, API keys, SMTP credentials) live in a root‑only environment file that systemd injects — never in source, never in the deployed build.
One DNS decision is worth calling out, because it bit us and the fix is a useful generic lesson. We fronted the new host through a CDN/DNS provider in its default proxied mode — and immediately, non‑browser clients started getting a bot‑challenge interstitial instead of JSON. That's fine for a website; it's fatal for an API, whose clients are apps, not browsers, and can't solve a JavaScript challenge. The fix was to run the API's DNS record in DNS‑only mode (resolve straight to the origin, no edge interception). If you put an API behind a CDN, make sure its bot‑mitigation and challenge features are scoped to human‑facing routes only — or kept off the API entirely.
To keep the old system serving while we tested, we brought the new server up on a parallel hostname and left the legacy production domain untouched. Cutover then becomes a single, instantly‑reversible DNS change: point the production domain at the new server when — and only when — verification is complete.
Proving parity: verify against the client, not your assumptions
This is the part that matters most, and the part most migrations underinvest in. It is not enough to test that your new endpoints "work." You have to prove they behave exactly as the shipped clients expect — and the only authority on that is the client's own code.
Our verification had three layers:
1. Reconcile against real call sites. We went through the mobile client's source and cataloged every call it makes: the exact URL, HTTP method, every parameter name (with its exact casing), and — critically — every response field the client actually reads. That catalog, not our own mental model of the API, became the checklist. It's the only way to catch the quirks from Part 1: the per‑endpoint casing differences, the write/response field asymmetry, the load‑bearing client typo. If you verify against your assumptions, you'll confirm your assumptions. Verify against the client.
2. Check response field casing against the source of truth. Because JSON is read case‑sensitively on the client, response shape is where a "clean" rewrite silently breaks things. We diffed the exact serialized field names — including the deliberately irregular ones — against the client's read sites, and scanned for any well‑intentioned casing "corrections" that would have broken parsing. (Serializer choice matters here too: we used the same JSON library and settings as the legacy stack to keep property naming and null‑handling identical.)
3. Exercise every endpoint live, with the client's exact inputs. Finally, we hit the running API over its real hostname with the precise parameter names and casings the client sends — including reversible write cycles (create a record, read it back, delete it) to confirm the round trip end‑to‑end, and edge cases like empty and malformed inputs where the legacy system had specific behavior. An endpoint that returns the right data for your test parameters but not the client's actual casing is a latent outage.
A useful side effect of reconciling against the live client: we found endpoints the current app no longer calls at all — superseded versions, features that had moved to third‑party services, an internal utility page. Rather than delete them on the spot (older app versions in the field may still depend on them), we cataloged them and deferred the decision to after cutover, when real traffic logs would show what's actually still being hit. "Unused by the newest client" is not the same as "unused." With a long‑tail install base, retirement is a data‑driven decision, not a code‑reading one.
What we'd tell the next team
Pulling the whole series together, the lessons that generalize beyond this one project:
- Treat the legacy behavior as the specification — quirks, odd casing, and all. In a compatibility migration, "improving" the contract is how you break clients.
- Modernize the layer that's actually the problem. For us that was the tangled application tier; the stored procedures already worked, so we kept them and pointed a modern data stack at them.
- Put every external dependency behind an interface. It's what let a nasty cross‑platform email bug become a one‑file swap.
- Never let an exception die in a silent
catch. It turns a loud failure into an invisible one, and invisible failures ship. - Test in the target environment, not just the convenient one. Some bugs only exist on the platform you're deploying to.
- Let parity dictate the cutover. Provable drop‑in compatibility is what makes a big‑bang switch the safe choice rather than the risky one.
- Verify against the client's real code, then against live traffic — and let data, not assumptions, drive what you keep and what you retire.
The best outcome of a migration like this is anticlimax. You flip one DNS record, traffic moves to a modern, testable, cross‑platform codebase, and the millions of requests from apps old and new keep getting exactly the responses they always did. Nobody outside the team notices a thing. That silence is the whole deliverable — and it's earned, endpoint by endpoint, in the discipline of changing everything on the inside while changing nothing on the outside.
Let's Talk About Your Project
A quick 30‑minute call is all it takes to find out if we're a good fit for each other. Book a time and we'll take it from there.