Skip to content
HomeAt Splunk, that means pairing React product work with Python services, operational data, permissions, evaluation, and release controls. Earlier at Deloitte, I built extraction, storage, API, and visualization systems; two removed more than 36,000 hours of annual manual work and cut investment-processing time by 66%.WritingTechnical writing on software engineering, AI engineering, system design, and the trade-offs behind production systems.ProjectsA growing collection of systems and products I’ve built—from current infrastructure work to earlier projects that shaped how I build today.Abdul AhadI’m an AI full-stack engineer with more than five years of experience building data-intensive enterprise software and production AI systems. At Splunk, I drove AI Service and KPI Discovery from an early prototype through technical design, validation, and production delivery. The capability analyzes operational data and recommends service models and health signals within Splunk IT Service Intelligence.Let’s talk.If you’re hiring, building something interesting, or want to compare notes on a difficult engineering problem, send me a message. A few lines is plenty.ColophonHow this portfolio handles static publishing, search, contact delivery, analytics, and releases.Capacity Planning Before Architecture DiagramsA practical method for turning workload assumptions into request, storage, bandwidth, concurrency, and failure-capacity budgets before choosing an architecture.Design Modules Around Change, Not LayersA practical guide to information hiding, volatile decisions, coupling, cohesion, dependency direction, and avoiding abstractions that preserve the wrong boundaries.From Model Call to Reliable AI WorkflowHow one model request grows into retrieval, tools, deterministic workflows, agents, evaluation, observability, and human escalation.Queues, Backpressure, and Idempotency: Designing for FailureHow bounded queues, admission control, idempotency keys, retry budgets, and dead-letter handling turn asynchronous delivery into a controlled reliability system.Refactoring a Legacy Backend Without a Big-Bang RewriteA staged method for understanding, testing, isolating, replacing, observing, and finally deleting legacy backend behavior without one irreversible cutover.MCP in Production: Trust Boundaries, Permissions, and Tool DesignA production-focused guide to MCP hosts, clients, servers, capability discovery, authorization, consent, token audiences, and safely designed tools.How this portfolio worksA walkthrough of how an edit becomes a published page, what happens after someone presses Send, and how the site avoids losing or saving the same message twice.ThreadsPosts, nested replies, profiles, and communities share the same records. This follows one conversation from creation to deletion.Prompt NexusGoogle sign-in leads to a local user, then one saved prompt powers the feed, profile, search, and edit screens.SeedEditors publish articles in HyGraph, readers receive prepared pages, and new comments remain private until they are approved.CactaOne published video creates a media asset, a post record, and temporary browser state. This follows that upload across the product.System DesignCapacity, queues, failure handling, and distributed-system trade-offs.AI EngineeringLLM workflows, tools, evaluation, permissions, and operations.Software EngineeringModules, testing, refactoring, interfaces, and maintainability.System Design from First PrinciplesCapacity planning, queues, backpressure, retries, and failure handling.Production AI EngineeringModel calls, workflows, tools, evaluation, permissions, and operations.

← Back to Writing
Software Engineeringarticleintermediate10 min read

Refactoring a Legacy Backend Without a Big-Bang Rewrite

A staged method for understanding, testing, isolating, replacing, observing, and finally deleting legacy backend behavior without one irreversible cutover.

Legacy CodeRefactoringTestingBackendArchitecture
On this page

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:

BoundaryQuestions
HTTP/APIWhich routes, fields, status codes, headers, and error bodies do consumers rely on?
EventsWhich topics, keys, ordering assumptions, and retry semantics exist?
DatabaseWho owns each table? Which jobs or reports read it directly?
External systemsWhich providers receive calls, and how are ambiguous outcomes reconciled?
OperationsWhich 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.

ts
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 --> O

The 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:

  1. New code writes through the legacy interface.
  2. Legacy code remains the writer while events populate a new read model.
  3. Ownership moves for a clearly partitioned tenant or entity cohort.
  4. 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:

ts
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:

ts
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:

  1. Add backward-compatible fields or tables.
  2. Deploy code that can read old and new representations.
  3. Backfill with checkpoints and validation.
  4. Switch the writer.
  5. Observe through a full business cycle.
  6. 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#

LayerPurposeExample
CharacterizationPreserve current externally visible behaviorLegacy endpoint returns established error code
UnitProve new rules and invariantsRounding and eligibility cases
ContractKeep old and new boundaries compatibleSame request/response schema and idempotency
IntegrationVerify storage and provider adaptersTransaction and outbox behavior
Shadow comparisonExercise production input without authorityNormalized output divergence
End-to-endProtect critical business flowsCreate, pay, deliver, and reconcile invoice
Migration rehearsalProve operational sequenceBackfill, 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.

Sources and further reading#

Related writing