This is the engineering breakdown of a migration I wrote about from a different angle in AI Can Build, It Can't Own; that piece was about where AI fit into this project and where it didn't. This one's about how the migration itself actually got built. No philosophy here, just the architecture.
The Constraints That Shaped Every Decision
Before any design decision, three constraints were non-negotiable:
- Zero downtime. Not "minimal" downtime. None.
- 500+ live cards, untouched behavior. Every existing integration had to keep responding exactly as it always had.
- No mutating existing schemas. Whatever the new design looked like, it couldn't risk the data already in production.
Everything below is a direct consequence of taking those three seriously instead of treating them as aspirational.
Step 1: Blueprint Before Code
The first thing I built wasn't a feature. It was a comprehensive architectural blueprint: a side-by-side map of both providers' functionality: endpoints, auth flows, card lifecycle states, webhook events, failure modes. Not because documentation is a nice-to-have, but because it's the only way to find the mismatches before they show up as bugs in staging.
That blueprint is what surfaced the two hardest problems early: the providers modeled a card's lifecycle differently at a conceptual level, and one had a data shape (transaction history) that structurally couldn't be mapped one-to-one onto the other. More on that below. Finding these on paper instead of in code review saved weeks.
Step 2: Versioning Instead of Rewriting
Existing routes stayed exactly where they were, just formally namespaced:
GET /card/:cardId/balance → GET /v1/card/:cardId/balance
The old logic remained entirely untouched. v1 became a frozen contract—explicitly versioned rather than implicitly assumed.
The new, provider-aware logic lived entirely under a new namespace:
GET /v2/card/:cardId/balance
New routes, new services, new controllers.This wasn't caution for its own sake; it decoupled the two runtimes so they could be developed, tested, and rolled back independently.
Step 3: New Models Instead of Mutated Ones
Same principle, applied to data. Instead of altering existing models to accommodate the new provider's shape, I created entirely new models and wrote migration scripts to reshape existing data into them.
Mutating a live schema under migration pressure is how "zero downtime" quietly becomes "downtime, just delayed until the worst possible moment." New models cost more up front. They cost far less than a corrupted production table.
This also meant I could test the entire reshaped dataset in an isolated staging environment, running it against both providers' expectations, without a single existing production record ever being at risk. If something in the new model was wrong, I found out in staging, not in an incident channel.
Step 4: Making the Core Service Provider-Agnostic
This is the piece that actually eliminated the vendor lock-in, not just accommodated a second vendor. I implemented a strategy pattern in the core card service, so the rest of the application never needs to know which provider is behind a given card:
Internally, cardServiceV2 picks the correct provider strategy based on the card's metadata and delegates the call. Every consumer of this service—the admin dashboard, the mobile application, and internal tooling—interacts with the exact same interface regardless of which provider is actually fulfilling the request. Add a third provider later, and it's a new strategy implementation, not a rewrite of everything that calls it.
Step 5: Standardizing the Unstandardizable
Most fields between the two providers could be normalized into one response shape without much drama — different field names, same underlying concept. Transaction history was not one of those fields.
One provider returned transactions in a single bulk payload mapped to a time range; the other enforced offset-based server pagination. You cannot gracefully force a server-paginated API to behave like a synchronous bulk endpoint without introducing heavy caching or scheduled polling—both of which violate a fintech user’s psychological expectation of seeing transactional data update in real time.
Instead of forcing false equivalence, I let the shape reflect the actual capability:
For the bulk provider, meta comes back as null. The frontend has one simple rule: if meta is present, use server-side pagination; if it's null, paginate client-side over the full bulk result. One response contract, two honest behaviors underneath it — instead of a fake standardization that would've broken the moment someone paginated past page one on the "wrong" provider.
Step 6: Fund Routing Through Smart Contracts
This is the part I can go the least deep on, for confidentiality reasons — but structurally, card recharge involved smart contract payments that needed their own provider-aware routing, sitting behind the same abstraction boundary as everything else. It wasn't exempt from the "no assumptions about a single provider" rule just because it touched the chain instead of a REST API.
Step 7: Testing on a Production Replica, Then Cutting Over
Once the versioned routes, the new models with migrated data, the strategy-based service layer, and the standardized-where-possible response contracts were all in place, everything ran together against a full production replica — not just unit tests in isolation, but the entire revamped system under realistic load and realistic data.
Only after that held up did the actual cutover happen: existing API integrations pointed at the new infrastructure, old behavior fully preserved, new provider fully live. No maintenance window. No downtime.
The Patterns Worth Taking With You
If you're facing a similar dual-provider or vendor-migration problem, the specifics above are less important than the shape of the decisions:
- Version instead of mutating. Old contracts stay frozen; new logic gets its own namespace.
- New models over altered ones, with migration scripts doing the reshaping — so staging can validate the new shape without touching production data.
- Push provider-specific logic behind a strategy pattern, so the rest of the system depends on an interface, not a vendor.
- Let your response contract be honest about divergent capabilities instead of forcing false equivalence — a nullable
metafield beats a fake pagination layer that breaks under load. - Validate the whole system together on a production replica before cutover, not just its parts in isolation.
None of this required exotic technology. It required discipline about what was allowed to change and what wasn't — and that discipline is the actual reason zero downtime was possible at all.
