A legacy backend is not simply old code. It is code whose real contract is distributed across implementation details, production data, external consumers, operator habits, and bugs that other systems may now depend on. Rewriting it from a clean specification is difficult because the complete specification rarely exists.
A safer strategy is incremental displacement: observe the current behavior, create a seam, route a controlled slice through a new implementation, compare outcomes, and expand only when evidence supports it.
Define the migration outcome#
“Rewrite the backend” is too broad to guide engineering decisions. Define a measurable target for one capability:
Move invoice generation from the legacy service to a new module while preserving externally visible API and event contracts. The migration must support per-tenant rollback, detect output divergence before customer delivery, and leave one authoritative writer at every stage.
This states:
- The bounded capability being replaced.
- The compatibility boundary.
- The rollout unit.
- The comparison method.
- The rollback requirement.
- The single-writer invariant.
Without these constraints, a rewrite tends to accumulate new features and architectural ambitions until the cutover becomes too risky to rehearse.
Map behavior before code#
Begin with an inventory of inputs, outputs, state, and consumers:
| Boundary | Questions |
|---|---|
| HTTP/API | Which routes, fields, status codes, headers, and error bodies do consumers rely on? |
| Events | Which topics, keys, ordering assumptions, and retry semantics exist? |
| Database | Who owns each table? Which jobs or reports read it directly? |
| External systems | Which providers receive calls, and how are ambiguous outcomes reconciled? |
| Operations | Which dashboards, alerts, scripts, and manual repairs are part of the service? |
Search code, traffic logs, schemas, dashboards, runbooks, and consumer repositories. Interview operators. A batch repair script used twice a month is part of the system even if it is not represented in the request path.
Capture current behavior with tests#
Characterization tests document what the system does today. They are not an endorsement of every behavior. They create a safety net before structural change.
Prioritize:
- High-volume and high-value flows.
- Business invariants.
- Error behavior and boundary cases.
- Serialization and event contracts.
- Idempotency and retry behavior.
- Known incidents and historical regressions.
Test at the narrowest stable boundary available. If core logic cannot run without a database and network, first create a seam around one dependency.
interface ExchangeRateSource {
rate(base: string, quote: string, at: Date): Promise<number>;
}
class LegacyInvoiceService {
constructor(private readonly rates: ExchangeRateSource) {}
}The seam is valuable even before replacement. It allows a deterministic test double and makes dependency behavior explicit.
Identify a displacement seam#
A seam is a point where behavior can be selected without invasive change. Common seams include:
- HTTP routing.
- Message consumer dispatch.
- Repository or gateway interface.
- Domain command handler.
- Event interception.
- Tenant or feature-flag lookup.
Choose a seam that supports gradual routing and rapid rollback. Avoid splitting through a transaction or invariant that must remain atomic.
View diagram source
flowchart LR
C[Consumer] --> R[Compatibility router]
R -->|control cohort| L[Legacy implementation]
R -->|migration cohort| N[New implementation]
L --> D[(Authoritative state)]
N --> D
L --> O[Comparison and telemetry]
N --> OThe compatibility router preserves the old external contract while deciding which implementation handles a request.
Preserve one source of truth#
Dual writes are attractive because they seem to keep old and new systems synchronized. They create difficult partial failures:
- Old write succeeds; new write fails.
- New write succeeds; old write times out ambiguously.
- Retries apply one side twice.
- Writes arrive in different orders.
Prefer one authoritative writer at each phase. Options include:
- New code writes through the legacy interface.
- Legacy code remains the writer while events populate a new read model.
- Ownership moves for a clearly partitioned tenant or entity cohort.
- A transactional outbox transfers changes through a replayable log.
If dual writing is unavoidable, define reconciliation, idempotency, ordering, and recovery before enabling it.
Add contract tests at the boundary#
The migration must preserve behavior consumers rely on, not internal classes. Encode external contracts:
- Request and response schemas.
- Required headers and status behavior.
- Event schema and partition key.
- Error classification.
- Idempotency behavior.
- Authorization decisions.
- Latency or throughput limits where they are contractual.
Run the same contract suite against both implementations:
function invoiceApiContract(createClient: () => InvoiceClient) {
it("returns the original invoice for a repeated operation key", async () => {
const client = createClient();
const first = await client.create(request, "op-123");
const second = await client.create(request, "op-123");
expect(second.id).toBe(first.id);
expect(second.total).toEqual(first.total);
});
}Consumer-driven contract tests are useful when consumers depend on different subsets of a large API. They do not replace end-to-end tests for critical workflows.
Use shadow execution carefully#
Shadowing sends a copy of production input to the new implementation while the legacy result remains authoritative. It can reveal data-shape and performance problems before customer impact.
Shadow execution must not duplicate side effects. Replace outbound writes with recorders or sandbox providers. Redact data when the new environment has different authorization or retention controls.
Compare normalized outputs:
type Comparison = {
legacyDigest: string;
candidateDigest: string;
differences: Array<{ path: string; category: string }>;
legacyDurationMs: number;
candidateDurationMs: number;
};Normalization should ignore intentionally unstable fields such as generated IDs or timestamps while retaining business-significant differences.
Not every divergence is a defect. Classify it as:
- Candidate bug.
- Legacy bug intentionally preserved for compatibility.
- Approved behavior correction.
- Nondeterministic or irrelevant difference.
Roll out by a stable cohort#
Choose a unit that does not switch unpredictably between implementations:
- Tenant.
- Account.
- Geographic region.
- Entity partition.
- Explicit allowlist.
Random per-request percentages are dangerous for stateful workflows. One account may be written by both implementations across a sequence of requests.
A staged rollout might be:
View diagram source
flowchart LR
A[Characterize] --> B[Shadow reads]
B --> C[Internal tenant]
C --> D[1% stable cohort]
D --> E[10% cohort]
E --> F[50% cohort]
F --> G[All writes]
G --> H[Legacy read-only]
H --> I[Delete after observation window]Each gate has explicit promotion and rollback criteria.
Define rollback before rollout#
Rollback is not simply turning a feature flag off. Ask:
- Did the new path write state the legacy path can read?
- Can in-flight work complete safely after routing changes?
- Are events compatible in both directions?
- Will retrying through the old path duplicate an effect?
- Is schema migration backward compatible?
Use expand-and-contract schema changes:
- Add backward-compatible fields or tables.
- Deploy code that can read old and new representations.
- Backfill with checkpoints and validation.
- Switch the writer.
- Observe through a full business cycle.
- Remove old readers and fields later.
Rollback must remain possible until the old path is intentionally retired.
Observe business outcomes and mechanics#
Infrastructure health can remain green while the migration produces incorrect invoices. Monitor both:
Mechanical signals#
- Request rate, errors, and latency.
- Queue depth and retry rate.
- Database load and lock contention.
- Resource saturation.
- Candidate/legacy divergence.
Business invariants#
- Sum of invoice lines equals the invoice total.
- Every accepted operation reaches one terminal state.
- No operation key produces multiple invoices.
- Currency and tax policy versions are recorded.
- Downstream event count matches committed operations.
Alert on invariant violations at the point where they can still be reconciled.
Use a test matrix#
| Layer | Purpose | Example |
|---|---|---|
| Characterization | Preserve current externally visible behavior | Legacy endpoint returns established error code |
| Unit | Prove new rules and invariants | Rounding and eligibility cases |
| Contract | Keep old and new boundaries compatible | Same request/response schema and idempotency |
| Integration | Verify storage and provider adapters | Transaction and outbox behavior |
| Shadow comparison | Exercise production input without authority | Normalized output divergence |
| End-to-end | Protect critical business flows | Create, pay, deliver, and reconcile invoice |
| Migration rehearsal | Prove operational sequence | Backfill, switch writer, rollback |
More tests are not automatically safer. Each layer should target a distinct risk and run at the cheapest useful scope.
Decide when the old system can be deleted#
Temporary coexistence becomes permanent unless deletion criteria are written early. Require:
- All intended cohorts use the new path.
- No legacy reads or writes appear in telemetry.
- Backfill and reconciliation are complete.
- A full business cycle has passed without unexplained divergence.
- Runbooks, alerts, and repair tools target the new system.
- Rollback window is formally closed.
- Data retention obligations are satisfied.
Then remove routing branches, flags, adapters, dashboards, credentials, jobs, and infrastructure. Dead migration code preserves cognitive and security cost.
Handle data ownership as a separate migration#
Routing requests to new code is often easier than moving authoritative data. If both implementations can write the same record, define conflict behavior before enabling either path. Application-level dual writes create a partial-failure problem: one commit can succeed while the other fails. A transactional outbox, change-data-capture stream, or single authoritative writer can make propagation recoverable, but each adds ordering and reconciliation concerns.
Prefer a phase with one source of truth. The new path can read a replicated view while writes still pass through the legacy owner, or the legacy path can call the new owner after authority moves. The extra hop may be acceptable during migration because it makes the transition explicit. Operators must be able to answer which system wins for every entity and phase.
Backfills need the same care as online traffic. Make them resumable, rate limited, and idempotent. Store a checkpoint, verify counts and domain invariants, and reserve capacity so bulk work cannot overwhelm production dependencies. A row-count match is weak evidence when transformations can preserve counts while changing meaning.
At cutover, reconcile a bounded overlap window using stable identifiers and business invariants, not volatile timestamps alone. If discrepancies appear, pause and classify them before replay. Continuing while unexplained divergence accumulates makes repair more expensive.
Data rollback is asymmetric. Repointing traffic to old code is unsafe if the new system accepted writes the old representation cannot understand. Decide whether rollback includes reverse replication, a compatibility transform, or temporary read-only mode before the first production cohort.
Migration checklist#
- Define one bounded capability and compatibility contract.
- Inventory consumers, state, jobs, events, and operational tools.
- Add characterization tests before structural change.
- Introduce a seam that supports stable cohorts and rollback.
- Maintain one authoritative writer where possible.
- Run shared contract tests against both implementations.
- Shadow without duplicating side effects.
- Compare normalized business outcomes.
- Roll out by tenant or entity, not random stateful requests.
- Rehearse schema changes, backfills, and rollback.
- Monitor business invariants as well as infrastructure.
- Delete the old path only after explicit exit criteria.
Incremental displacement can appear slower than a clean rewrite because transitional architecture is visible. It is usually faster at producing verified value because each stage can run, teach, and roll back independently.