Skip to content

Legacy migration

The hard part of the strangler fig is deciding what stays the system of record

Replacing a state tourism corporation's Java and Oracle platform taught us that the pattern's real difficulty is not routing traffic. It is deciding what remains authoritative, and fronting a reporting schema you can never safely rewrite.

· 8 min read

Every article about the strangler fig pattern spends itself on the routing layer, the proxy that decides whether a request goes to the old system or the new one. We wired that up in a weekend. The engagement was replacing the digital platform of a state tourism corporation, and the year that followed had almost nothing to do with routing. It went on the thing the diagrams never draw: which system stays authoritative while you are mid-flight, and what on earth you do with a reporting schema that has grown past five hundred tables and that nobody left in the building can fully describe.

The incumbent was a Java and Spring Boot application on Oracle. It held every booking, every payment, every line of the revenue record, and it worked. It also could not be switched off for an afternoon, because a boat ride booked online at 9am is a family standing at a jetty at 11am, and there is no graceful degradation for a family at a jetty. The new layer is NestJS with an Apollo GraphQL API, TypeORM over Postgres, Redis for caching, and one React codebase wrapped with Capacitor for the native apps. None of that is the interesting part. The interesting part was refusing to let the new Postgres become the system of record on day one.

The decision that actually matters

A big-bang migration fails because it asks you to be correct about everything at once. The strangler fig lets you be correct about one thing at a time, but only if you stay honest about where truth lives during the transition. So we made a rule and held it. The legacy database remained the system of record for bookings and revenue for the whole life of the project. The new platform owned the read path, the public experience, the aggregation, and new data the old system had never modelled. It did not own the booking ledger. Every booking mutation still terminated in the old Oracle database, through the legacy API, even when the customer never saw a page the old system had rendered.

We did not arrive at that rule cleanly. Early on, someone cached booking status in our own tables to make one screen faster, and inside a fortnight we had a handful of bookings that our database called confirmed and that Oracle had simply never heard of. That was the education. The moment two systems both believe they own the booking record, the disagreement is a fact on disk and no amount of engineering discipline pulls it back. We ripped the cache out. Keeping a single writer meant the new GraphQL layer was, on the write path, an elaborate and well-typed client of a legacy REST API, and we spent real effort making sure it never quietly started persisting its own copy of anything financial.

Fronting a schema you cannot rewrite

The reporting schema had accreted over years of hotel and tour operations into more than five hundred tables. Nobody could describe all of it. Some of it was certainly dead. You cannot rewrite that, and you cannot even safely read all of it, so the only move left is to refuse to let any of it leak into the new system's contract. The GraphQL schema was designed as the interface we wished the corporation had, and the adapters underneath did the ugly work of mapping it onto whatever the legacy tables and endpoints actually returned. Five hundred tables became an implementation detail behind a resolver, a shape the front end never had to learn.

A reporting schema that accreted over a decade is a liability, not a codebase you migrate. You put an adapter in front of it and never look at it directly again.

Three sources of truth, one contract

The aggregation layer had to reconcile three upstreams that agree on nothing. The first was the legacy REST API in front of Oracle. Its responses came wrapped in an envelope of data, success, status, errorCode and meta, which was fine. Individual endpoints were flaky and a few were simply broken, which was less fine. And a list endpoint would return its Response field as an array when rows existed, and as a plain string when they did not, so every adapter had to defend against that.

// The incumbent REST layer returns `Response` as an array when rows exist,
// and as a plain string when they do not.
const asArray = <T>(r: T[] | string | null | undefined): T[] =>
  Array.isArray(r) ? r : [];

The second upstream was a third-party hotel property-management system. It did not offer a query API. It pushed. It spoke an OTA XML dialect, sending inventory-count and rate notifications as namespaced XML that we parsed, authenticated on a message password, and upserted into local tables. Pull versus push changes what you can promise: the new system's view of hotel availability is only ever as fresh as the last notification, and the reconciliation logic has to treat "I have not heard from the property system recently" as a genuinely different state from "the property system says zero". We chose to fail loudly on a missing credential rather than default to an allow-all path, because a silent auth bypass on an inventory feed is how you oversell a hotel and find out at the front desk.

The third was the corporation's accounting system, whose figures feed the executive analytics surface. We did not integrate with it live. We ingest exports as immutable snapshots into a small star schema: a facts table stamped with a snapshot id and an ingestion timestamp, dimension tables for the operating units, and reconciliation that records which unit names it could not match rather than silently dropping them. Snapshots, never in-place updates. A management dashboard that quietly rewrites last month's number when a late correction lands is worse than one that is a day stale, because at least the stale one can tell you exactly what it ingested and when.

// Each ingest writes an immutable snapshot; nothing is updated in place.
// Chunk the insert to stay under Postgres's 65,535 bind-parameter ceiling.
await snapshotRepo.save(rows.map(toFact(snapshotId, ingestedAt)), { chunk: 500 });

Keeping the public journey correct over a split brain

The public booking journey (search, time slots, cart, entrance and parking add-ons, payment, cancellation, reschedule) ran entirely on the new stack while its writes landed in the old database. That split brain is the risky bit. A user finishing a payment on a modern React screen is, three hops down, creating a row through a REST call whose failure modes include a plain string where you expected an array. We handled it by treating the legacy call as the authority on whether a booking existed. The new layer never told a user their booking was confirmed until the old system said so, even though reading our own database would have been faster. Optimistic confirmation is exactly the shortcut that hands you a paid customer with no booking.

Offline tickets, because the network is not a given

A visitor who has paid does not care about your architecture when they are standing at a jetty with no signal, holding a phone that cannot reach your GraphQL API. So the issued ticket is cached on the device, keyed by boat house and booking, and read back whenever the live request fails. It is deliberately dumb: last-known-good in local storage, no clever sync.

// Cache the last good ticket payload per (boatHouseId, bookingId).
// Read the snapshot back when the live GraphQL request fails offline.
localStorage.setItem(
  ticketKey(boatHouseId, bookingId),
  JSON.stringify({ data: liveData, cachedAt: Date.now() })
);

I spent a morning at one of the boat houses watching a clerk scan tickets, and the only second the software mattered all morning was the second the signal dropped and the cached ticket came straight back. This is a small hook. It carried more real-world weight than most of the backend, because it is the difference between a working ticket and an argument with a boatman. Two of us still disagree about whether that cache should expire. One says a stale ticket is worse than no ticket; the other says a paid visitor at a jetty always wants the last thing we had. We shipped it without a TTL. I am still not sure we were right.

What we would do differently

  • Version the legacy adapters from the start. We only learned the incumbent endpoints could change shape without warning after we had written contract-shape tests and were running them by hand, which is to say too late. Make them continuous from day one: gating nothing, alerting always.
  • Model the property system's "stale" state in the schema rather than inferring it. Had the absence of a recent push been its own value, surfaced to staff, we would have skipped several genuinely baffling conversations about why a room showed as available.
  • Put the snapshot id in front of the analytics users sooner. Executives trusted the dashboard more, not less, once they could see it read "as of this export" instead of a live figure that might quietly disagree with itself between two refreshes.
  • Resist the pull to absorb "just one" financial write into the new database. We resisted it in the end, but it was proposed more than once, always in the name of convenience, and each time it was the same bad trade wearing a new hat.

The pattern gets sold as a routing trick. In practice it is an argument about ownership that you have to win over and over, usually against your own team, usually the week someone wants to make one screen faster. We lost that argument once, for about a fortnight, and it cost us a set of bookings that existed on one side of the split and not the other. That was enough.

Tell us what you're trying to build

Send us the problem, not a specification. We'll tell you honestly whether we're the right people for it, and if we aren't, we'll say so.