← Back to the library
ArchitectureApplied · 8 min

The rewrite never catches up with the old system

“The new system has been 'three months from launch' for a year. The legacy app keeps getting features and fixes that the rewrite has to copy, and nothing new is in production yet.”

THE PRINCIPLE

Ship the migration in slices that each reach real users. Every slice that goes live shrinks the legacy system; a rewrite that isn't live shrinks nothing.

FIRST MOVES

  1. Stop the parity chase: list legacy behaviour actually used (logs, analytics, support tickets) and drop what nobody needs instead of rebuilding it.
  2. Introduce a seam: a facade or proxy at the edge for request-level slices, or branch by abstraction inside the code for a library or subsystem.
  3. Pick the first slice for learning and value, not difficulty: small, well-understood, with clear traffic. Route it to the new system behind a flag, with rollback.
  4. Freeze new features in migrated areas, or build them only in the new system, so the target stops moving.
  5. Be honest about scale: for a small system with a stable spec, a short wholesale replacement can be cheaper than building a seam, and the facade itself must not become a bottleneck or single point of failure.
  6. Plan data per slice: who owns writes during the transition, how the other side stays in sync (CDC, events), and when the legacy tables are removed.

TOOLS: THEN → NOW

Big-bang cutover after parityStrangler fig: a facade routes slice by slice to the new system
Long-lived rewrite branchBranch by abstraction on trunk, with feature flags to switch implementations
One-off data migration at cutoverPer-domain sync with CDC, validation, then cutover and legacy removal

PATTERN SNAPSHOT

// Strangler facade: migrated paths go to the new system, everything else to legacy.
const NEW_ORIGIN = "https://orders-v2.internal";
const LEGACY_ORIGIN = "https://legacy.internal";
const migrated = [/^\/api\/invoices(\/|$)/, /^\/api\/customers\/[^/]+\/statements$/];

export default {
  async fetch(req: Request): Promise<Response> {
    const url = new URL(req.url);
    const origin = migrated.some((r) => r.test(url.pathname)) ? NEW_ORIGIN : LEGACY_ORIGIN;
    return fetch(new Request(new URL(url.pathname + url.search, origin), req));
  },
};

CLOSE THE AI. EXPLAIN THIS.

When would you use branch by abstraction instead of an edge facade, and what stops the old system from remaining a moving target?

SOURCES

Guide reviewed