<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ migration - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ migration - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Sun, 25 Oct 2026 18:36:11 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/migration/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Migrate a Legacy Monolith Incrementally Without a Big-Bang Rewrite ]]>
                </title>
                <description>
                    <![CDATA[ Large legacy migrations often fail long before the final cutover. The failure usually starts when the migration is framed as a single event. Move the application. Move the database. Move all the users ]]>
                </description>
                <link>https://www.freecodecamp.org/news/migrate-legacy-monolith-incrementally/</link>
                <guid isPermaLink="false">6aac7747d406d7c207351312</guid>
                
                    <category>
                        <![CDATA[ legacy code ]]>
                    </category>
                
                    <category>
                        <![CDATA[ software architecture ]]>
                    </category>
                
                    <category>
                        <![CDATA[ migration ]]>
                    </category>
                
                    <category>
                        <![CDATA[ refactoring ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Artificial Intelligence ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Hugo Teijiz ]]>
                </dc:creator>
                <pubDate>Thu, 17 Sep 2026 23:27:03 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/086653d6-268e-4d13-8f7d-b42c92847361.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Large legacy migrations often fail long before the final cutover.</p>
<p>The failure usually starts when the migration is framed as a single event. Move the application. Move the database. Move all the users. Switch the traffic. Turn the old system off.</p>
<p>That creates a dangerous assumption: the legacy system and the new system need to exchange places all at once.</p>
<p>They usually don't.</p>
<p>If you already understand the legacy behavior, protect it with characterization tests, create migration-friendly boundaries, and compare old and new implementations, you have another option.</p>
<p>You can migrate one capability at a time. That changes the problem completely.</p>
<p>Instead of:</p>
<pre><code class="language-text">legacy monolith
      ↓
complete rewrite
      ↓
big-bang cutover
</code></pre>
<p>you can move toward:</p>
<pre><code class="language-text">legacy monolith
      ↓
one capability extracted
      ↓
small percentage of traffic
      ↓
observe
      ↓
expand
      ↓
repeat
</code></pre>
<p>The goal isn't to make the migration slower. The goal is to make each change smaller, observable, and reversible.</p>
<p>In this tutorial, I'll show you how to migrate a legacy monolith incrementally by:</p>
<ul>
<li><p>choosing a safe first migration slice</p>
</li>
<li><p>defining a boundary between legacy and new code</p>
</li>
<li><p>routing requests between implementations</p>
</li>
<li><p>using the Strangler Fig pattern</p>
</li>
<li><p>migrating by business capability instead of technical layer</p>
</li>
<li><p>keeping old and new implementations running together</p>
</li>
<li><p>introducing progressive traffic</p>
</li>
<li><p>detecting failures before full cutover</p>
</li>
<li><p>designing rollback paths</p>
</li>
<li><p>handling data ownership carefully</p>
</li>
<li><p>removing migrated legacy behavior</p>
</li>
<li><p>using AI without turning an incremental migration into an automated rewrite</p>
</li>
</ul>
<p>The examples use TypeScript, but the approach applies to most languages, runtimes, and architectures.</p>
<p>The objective is simple: make migration a sequence of controlled changes instead of one irreversible event.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you should be comfortable with:</p>
<ul>
<li><p>TypeScript or a similar language</p>
</li>
<li><p>API and service boundaries</p>
</li>
<li><p>integration testing</p>
</li>
<li><p>dependency injection</p>
</li>
<li><p>routing and reverse proxies</p>
</li>
<li><p>database transactions</p>
</li>
<li><p>observability</p>
</li>
<li><p>incremental refactoring</p>
</li>
<li><p>legacy modernization</p>
</li>
</ul>
<p>You should also already understand the behavior of the capability you want to migrate.</p>
<p>Ideally, you know:</p>
<ul>
<li><p>its inputs</p>
</li>
<li><p>its outputs</p>
</li>
<li><p>its important business rules</p>
</li>
<li><p>its side effects</p>
</li>
<li><p>its dependencies</p>
</li>
<li><p>its external contracts</p>
</li>
<li><p>how you'll detect behavioral differences</p>
</li>
</ul>
<p>If you haven't reached that point yet, migration may be premature.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-why-big-bang-migrations-are-so-risky">Why Big-Bang Migrations Are So Risky</a></p>
</li>
<li><p><a href="#heading-think-in-migration-slices-not-applications">Think in Migration Slices, Not Applications</a></p>
</li>
<li><p><a href="#heading-choose-the-first-capability-carefully">Choose the First Capability Carefully</a></p>
</li>
<li><p><a href="#heading-create-a-boundary-between-legacy-and-new">Create a Boundary Between Legacy and New</a></p>
</li>
<li><p><a href="#heading-use-the-strangler-fig-pattern">Use the Strangler Fig Pattern</a></p>
</li>
<li><p><a href="#heading-migrate-capabilities-not-technical-layers">Migrate Capabilities, Not Technical Layers</a></p>
</li>
<li><p><a href="#heading-keep-legacy-and-new-implementations-running-together">Keep Legacy and New Implementations Running Together</a></p>
</li>
<li><p><a href="#heading-route-traffic-explicitly">Route Traffic Explicitly</a></p>
</li>
<li><p><a href="#heading-start-with-internal-or-low-risk-traffic">Start with Internal or Low-Risk Traffic</a></p>
</li>
<li><p><a href="#heading-progressively-increase-production-traffic">Progressively Increase Production Traffic</a></p>
</li>
<li><p><a href="#heading-use-differential-testing-before-and-during-rollout">Use Differential Testing Before and During Rollout</a></p>
</li>
<li><p><a href="#heading-a-small-end-to-end-invoice-migration-example">A Small End-to-End Invoice Migration Example</a></p>
</li>
<li><p><a href="#heading-design-rollback-before-you-need-it">Design Rollback Before You Need It</a></p>
</li>
<li><p><a href="#heading-treat-data-migration-as-a-separate-problem">Treat Data Migration as a Separate Problem</a></p>
</li>
<li><p><a href="#heading-be-careful-with-dual-writes">Be Careful with Dual Writes</a></p>
</li>
<li><p><a href="#heading-decide-who-owns-the-data">Decide Who Owns the Data</a></p>
</li>
<li><p><a href="#heading-observe-business-behavior-not-just-infrastructure">Observe Business Behavior, Not Just Infrastructure</a></p>
</li>
<li><p><a href="#heading-know-when-a-migration-slice-is-complete">Know When a Migration Slice Is Complete</a></p>
</li>
<li><p><a href="#heading-remove-the-legacy-path">Remove the Legacy Path</a></p>
</li>
<li><p><a href="#heading-how-to-use-ai-during-an-incremental-migration">How to Use AI During an Incremental Migration</a></p>
</li>
<li><p><a href="#heading-do-not-let-ai-turn-the-migration-into-a-rewrite">Do Not Let AI Turn the Migration into a Rewrite</a></p>
</li>
<li><p><a href="#heading-a-practical-incremental-migration-workflow">A Practical Incremental Migration Workflow</a></p>
</li>
<li><p><a href="#heading-what-incremental-migration-does-not-solve">What Incremental Migration Does Not Solve</a></p>
</li>
<li><p><a href="#heading-the-complete-legacy-modernization-workflow">The Complete Legacy Modernization Workflow</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-why-big-bang-migrations-are-so-risky">Why Big-Bang Migrations Are So Risky</h2>
<p>Imagine a legacy commerce application.</p>
<p>It contains:</p>
<pre><code class="language-text">customers
orders
payments
inventory
shipping
invoicing
notifications
reporting
</code></pre>
<p>The modernization plan says:</p>
<pre><code class="language-text">replace the monolith
</code></pre>
<p>That sounds like one project.</p>
<p>Operationally, it may mean changing:</p>
<pre><code class="language-text">runtime
framework
database
deployment model
API contracts
authentication
networking
observability
data model
business logic
external integrations
</code></pre>
<p>at the same time.</p>
<p>If the final cutover fails, the number of possible causes is enormous.</p>
<p>For example:</p>
<pre><code class="language-text">Did pricing change?

Did the database migration lose data?

Is the payment provider failing?

Did authentication behave differently?

Did the new runtime change date handling?

Did a timeout become shorter?

Did an event stop being published?

Did the new deployment configuration fail?
</code></pre>
<p>This is one of the central problems with big-bang migration: too many variables change together.</p>
<p>Incremental migration tries to reduce the number of changing variables at each step.</p>
<h2 id="heading-think-in-migration-slices-not-applications">Think in Migration Slices, Not Applications</h2>
<p>Instead of asking:</p>
<blockquote>
<p>How do we migrate this monolith?</p>
</blockquote>
<p>ask:</p>
<blockquote>
<p>What's the smallest meaningful business capability we can move independently?</p>
</blockquote>
<p>For example:</p>
<pre><code class="language-text">Calculate Order Total
Generate Invoice
Send Order Confirmation
Create Shipment
Renew Subscription
Approve Customer
</code></pre>
<p>A migration slice should ideally have:</p>
<pre><code class="language-text">clear input
clear output
known side effects
understood dependencies
observable behavior
a rollback path
</code></pre>
<p>That gives you something concrete to move.</p>
<p>For example:</p>
<pre><code class="language-text">Generate Invoice
</code></pre>
<p>might become:</p>
<pre><code class="language-text">input:
orderId

behavior:
load order
calculate taxes
generate invoice number
create invoice

side effects:
store invoice
publish invoice.created

output:
invoice
</code></pre>
<p>That's much easier to migrate than:</p>
<pre><code class="language-text">billing module
</code></pre>
<p>or:</p>
<pre><code class="language-text">src/services/
</code></pre>
<p>Business capabilities make better migration units than folders.</p>
<h2 id="heading-choose-the-first-capability-carefully">Choose the First Capability Carefully</h2>
<p>The first slice matters.</p>
<p>I would usually avoid starting with the most critical capability in the system.</p>
<p>You want something meaningful enough to validate the migration approach, but not so dangerous that a mistake creates catastrophic consequences.</p>
<p>A useful first slice often has:</p>
<pre><code class="language-text">moderate traffic
limited external dependencies
clear behavior
good test coverage
few transactional boundaries
low blast radius
</code></pre>
<p>For example:</p>
<pre><code class="language-text">Generate Customer Statement
</code></pre>
<p>may be a better first migration candidate than:</p>
<pre><code class="language-text">Authorize Payment
</code></pre>
<p>The first migration is partly technical work, but it's also a learning exercise.</p>
<p>You're validating:</p>
<pre><code class="language-text">routing
deployment
observability
rollback
data access
testing
team workflow
</code></pre>
<p>before applying the pattern to more critical capabilities.</p>
<h2 id="heading-create-a-boundary-between-legacy-and-new">Create a Boundary Between Legacy and New</h2>
<p>Suppose the legacy application has:</p>
<pre><code class="language-typescript">async function generateInvoice(
  orderId: string
) {
  // legacy implementation
}
</code></pre>
<p>Before migration, introduce a boundary:</p>
<pre><code class="language-typescript">interface InvoiceGenerator {
  generate(
    orderId: string
  ): Promise&lt;Invoice&gt;;
}
</code></pre>
<p>The legacy implementation becomes:</p>
<pre><code class="language-typescript">class LegacyInvoiceGenerator
  implements InvoiceGenerator {
  async generate(
    orderId: string
  ): Promise&lt;Invoice&gt; {
    // existing behavior
  }
}
</code></pre>
<p>The new implementation becomes:</p>
<pre><code class="language-typescript">class NewInvoiceGenerator
  implements InvoiceGenerator {
  async generate(
    orderId: string
  ): Promise&lt;Invoice&gt; {
    // migrated behavior
  }
}
</code></pre>
<p>Now the caller doesn't need to know which implementation is active.</p>
<p>That creates an important capability:</p>
<pre><code class="language-text">replace implementation
without replacing caller
</code></pre>
<p>which is one of the foundations of incremental migration.</p>
<h2 id="heading-use-the-strangler-fig-pattern">Use the Strangler Fig Pattern</h2>
<p>A common way to describe incremental replacement is the Strangler Fig pattern.</p>
<p>Instead of replacing the entire application at once, new behavior gradually grows around the old system.</p>
<p>Conceptually:</p>
<pre><code class="language-text">            incoming request
                   │
                   ↓
                router
              /        \
             /          \
      legacy path     new path
</code></pre>
<p>At first:</p>
<pre><code class="language-text">legacy: 100%
new:      0%
</code></pre>
<p>Later:</p>
<pre><code class="language-text">legacy: 95%
new:      5%
</code></pre>
<p>Then:</p>
<pre><code class="language-text">legacy: 50%
new:     50%
</code></pre>
<p>Eventually:</p>
<pre><code class="language-text">legacy:  0%
new:    100%
</code></pre>
<p>At that point, the old implementation for that capability can be removed.</p>
<p>The key is that the replacement happens gradually. The legacy application continues serving parts of the system while the new implementation takes over others.</p>
<h2 id="heading-migrate-capabilities-not-technical-layers">Migrate Capabilities, Not Technical Layers</h2>
<p>One tempting migration strategy is:</p>
<pre><code class="language-text">move database
then move services
then move APIs
then move UI
</code></pre>
<p>That can create long periods where every capability spans both old and new architecture.</p>
<p>For example:</p>
<pre><code class="language-text">new API
↓
legacy service
↓
new database
↓
legacy event publisher
</code></pre>
<p>This is sometimes unavoidable.</p>
<p>But whenever possible, I prefer vertical slices.</p>
<p>A vertical slice might be:</p>
<pre><code class="language-text">Generate Invoice

request
↓
application logic
↓
persistence
↓
events
↓
response
</code></pre>
<p>That capability can move as one coherent unit.</p>
<p>Then:</p>
<pre><code class="language-text">Create Shipment
</code></pre>
<p>can move separately.</p>
<p>Then:</p>
<pre><code class="language-text">Renew Subscription
</code></pre>
<p>and so on.</p>
<p>This gives you working migrated capabilities earlier. It also reduces the number of temporary cross-system dependencies.</p>
<h2 id="heading-keep-legacy-and-new-implementations-running-together">Keep Legacy and New Implementations Running Together</h2>
<p>During an incremental migration, coexistence is normal.</p>
<p>For some period of time, you may have:</p>
<pre><code class="language-text">LegacyInvoiceGenerator
NewInvoiceGenerator
</code></pre>
<p>both deployed.</p>
<p>That's not duplication by accident. It's part of the migration strategy.</p>
<p>The important question is how requests choose between them.</p>
<p>You may use:</p>
<pre><code class="language-text">feature flag
tenant
user group
request header
region
percentage rollout
specific account IDs
</code></pre>
<p>For example:</p>
<pre><code class="language-typescript">class InvoiceRouter {
  constructor(
    private readonly legacy:
      InvoiceGenerator,
    private readonly migrated:
      InvoiceGenerator
  ) {}

  async generate(
    orderId: string,
    useMigrated: boolean
  ) {
    if (useMigrated) {
      return this.migrated.generate(
        orderId
      );
    }

    return this.legacy.generate(
      orderId
    );
  }
}
</code></pre>
<p>This is deliberately simple. The important part is that routing is explicit. You know which implementation handled each request.</p>
<h2 id="heading-route-traffic-explicitly">Route Traffic Explicitly</h2>
<p>Avoid migration logic that's difficult to observe.</p>
<p>For example:</p>
<pre><code class="language-typescript">try {
  return await newService.call();
} catch {
  return legacyService.call();
}
</code></pre>
<p>This may look resilient, but it can hide failures.</p>
<p>Suppose the new implementation fails 40% of the time. If every failure silently falls back to legacy, users may see no problem. But the migration isn't healthy.</p>
<p>The problem is that the first version mixes two decisions together: <strong>which implementation should receive the request</strong> and <strong>what should happen when that implementation fails</strong>. Because the fallback happens inside the <code>catch</code>, the migrated path can fail repeatedly without producing an explicit routing signal that you can measure.</p>
<p>A better approach is to make the routing decision first, record it, and then call the selected implementation. That separates migration policy from error handling and gives you a clear record of how much traffic actually reached each path.</p>
<p>For example:</p>
<pre><code class="language-typescript">const route =
  migrationPolicy.route(request);

metrics.increment(
  `invoice.route.${route}`
);

if (route === "migrated") {
  return migrated.generate(
    request.orderId
  );
}

return legacy.generate(
  request.orderId
);
</code></pre>
<p>Now you can measure:</p>
<pre><code class="language-text">requests routed to legacy
requests routed to migrated
migration failures
fallback count
latency
business outcomes
</code></pre>
<p>Migration should be observable as a first-class system behavior.</p>
<h2 id="heading-start-with-internal-or-low-risk-traffic">Start with Internal or Low-Risk Traffic</h2>
<p>Before routing a large percentage of customers to the migrated path, start with safer traffic.</p>
<p>For example:</p>
<pre><code class="language-text">development
test environments
internal users
staff accounts
test tenants
specific low-risk customers
</code></pre>
<p>This lets you validate:</p>
<pre><code class="language-text">deployment
routing
observability
data access
external integrations
failure handling
</code></pre>
<p>with lower risk.</p>
<p>You can then expand.</p>
<p>For example:</p>
<pre><code class="language-text">internal users
↓
1% production
↓
5%
↓
10%
↓
25%
↓
50%
↓
100%
</code></pre>
<p>The exact percentages aren't important, but the principle is.</p>
<p>Each increase should happen because the previous stage produced enough evidence.</p>
<h2 id="heading-progressively-increase-production-traffic">Progressively Increase Production Traffic</h2>
<p>Suppose you have:</p>
<pre><code class="language-text">10,000 invoice requests/day
</code></pre>
<p>Instead of switching all requests:</p>
<pre><code class="language-text">legacy → new
</code></pre>
<p>at once, route:</p>
<pre><code class="language-text">1%
</code></pre>
<p>first.</p>
<p>That gives roughly:</p>
<pre><code class="language-text">100 real requests/day
</code></pre>
<p>through the migrated path.</p>
<p>Now monitor:</p>
<pre><code class="language-text">error rate
latency
output differences
side effects
customer-visible failures
business metrics
</code></pre>
<p>If the system behaves correctly, increase traffic. If it doesn't, reduce or disable migrated routing.</p>
<p>The migration becomes a controlled experiment. That's very different from a cutover event.</p>
<h2 id="heading-use-differential-testing-before-and-during-rollout">Use Differential Testing Before and During Rollout</h2>
<p>The <a href="https://www.freecodecamp.org/news/differential-testing-legacy-migration/">previous article in this series focused on differential testing</a>. That technique becomes especially useful here.</p>
<p>Differential testing means running the legacy and migrated implementations with the same input and comparing their observable behavior. Depending on the capability, that may include return values, errors, state changes, and side effects.</p>
<p>The goal isn't to prove that the implementations are internally identical. It's to detect meaningful behavioral differences before those differences reach all of your production traffic.</p>
<p>Before live routing, you can compare:</p>
<pre><code class="language-text">same input
↓
legacy result

same input
↓
new result
</code></pre>
<p>During rollout, you can also sample real traffic and compare behavior where it is safe to do so.</p>
<p>For example:</p>
<pre><code class="language-text">real request
      │
      ├────→ active implementation
      │
      └────→ shadow implementation
</code></pre>
<p>Then compare:</p>
<pre><code class="language-text">output
errors
side effects
business state
</code></pre>
<p>This gives you evidence before increasing traffic.</p>
<p>A rollout decision can then be based on:</p>
<pre><code class="language-text">divergence
error rate
latency
business outcomes
</code></pre>
<p>instead of:</p>
<blockquote>
<p>It seems fine.</p>
</blockquote>
<h2 id="heading-a-small-end-to-end-invoice-migration-example">A Small End-to-End Invoice Migration Example</h2>
<p>The individual pieces are easier to understand when you see them working together.</p>
<p>Here's a deliberately small, in-memory example based on the invoice capability we've been using throughout the article. It doesn't include a real database, reverse proxy, queue, or deployment platform. The point is to show the migration control flow in one place.</p>
<p>Start with a shared contract:</p>
<pre><code class="language-typescript">type InvoiceInput = {
  orderId: string;
  subtotal: number;
};

type Invoice = {
  orderId: string;
  total: number;
};

interface InvoiceGenerator {
  generate(
    input: InvoiceInput
  ): Promise&lt;Invoice&gt;;
}
</code></pre>
<p>The legacy implementation calculates the invoice total like this:</p>
<pre><code class="language-typescript">class LegacyInvoiceGenerator
  implements InvoiceGenerator {
  async generate(
    input: InvoiceInput
  ): Promise&lt;Invoice&gt; {
    return {
      orderId: input.orderId,
      total: input.subtotal * 1.21,
    };
  }
}
</code></pre>
<p>Now imagine we've migrated that capability into a new implementation:</p>
<pre><code class="language-typescript">class MigratedInvoiceGenerator
  implements InvoiceGenerator {
  async generate(
    input: InvoiceInput
  ): Promise&lt;Invoice&gt; {
    const tax =
      input.subtotal * 0.21;

    return {
      orderId: input.orderId,
      total: input.subtotal + tax,
    };
  }
}
</code></pre>
<p>The code is different, but the intended behavior is the same.</p>
<p>Next, define a deterministic rollout function. This example assigns each <code>orderId</code> to a bucket from 0 to 99 so the same order always follows the same route:</p>
<pre><code class="language-typescript">function bucketFor(
  value: string
): number {
  const sum = [...value].reduce(
    (total, char) =&gt;
      total + char.charCodeAt(0),
    0
  );

  return sum % 100;
}

function shouldUseMigrated(
  orderId: string,
  percentage: number
): boolean {
  return (
    bucketFor(orderId) &lt; percentage
  );
}
</code></pre>
<p>If <code>percentage</code> is <code>10</code>, roughly 10% of IDs will be assigned to the migrated path.</p>
<p>Now add some tiny in-memory metrics:</p>
<pre><code class="language-typescript">const metrics = {
  legacyRequests: 0,
  migratedRequests: 0,
  mismatches: 0,
};
</code></pre>
<p>Then put the legacy and migrated implementations behind one migration-aware entry point:</p>
<pre><code class="language-typescript">class IncrementalInvoiceService {
  migratedEnabled = true;
  rolloutPercentage = 10;

  constructor(
    private readonly legacy:
      InvoiceGenerator,
    private readonly migrated:
      InvoiceGenerator
  ) {}

  async generate(
    input: InvoiceInput
  ): Promise&lt;Invoice&gt; {
    const legacyResult =
      await this.legacy.generate(
        structuredClone(input)
      );

    const migratedResult =
      await this.migrated.generate(
        structuredClone(input)
      );

    if (
      migratedResult.orderId !==
        legacyResult.orderId ||
      migratedResult.total !==
        legacyResult.total
    ) {
      metrics.mismatches += 1;
    }

    const useMigrated =
      this.migratedEnabled &amp;&amp;
      shouldUseMigrated(
        input.orderId,
        this.rolloutPercentage
      );

    if (useMigrated) {
      metrics.migratedRequests += 1;
      return migratedResult;
    }

    metrics.legacyRequests += 1;
    return legacyResult;
  }
}
</code></pre>
<p>This small service combines several ideas from the article.</p>
<p>First, it runs both implementations with the same input and compares their results. Because this example is entirely in memory and has no external side effects, doing that is safe.</p>
<p>Second, it routes only a percentage of requests to the migrated result.</p>
<p>Third, it records how many requests used each path and how many behavioral mismatches occurred.</p>
<p>You can exercise it with a few requests:</p>
<pre><code class="language-typescript">const service =
  new IncrementalInvoiceService(
    new LegacyInvoiceGenerator(),
    new MigratedInvoiceGenerator()
  );

for (let i = 1; i &lt;= 100; i++) {
  await service.generate({
    orderId: `order-${i}`,
    subtotal: 1000,
  });
}

console.log(metrics);
</code></pre>
<p>You might see something like:</p>
<pre><code class="language-text">legacyRequests:   89
migratedRequests: 11
mismatches:        0
</code></pre>
<p>The exact split may not be exactly 90/10 with only 100 inputs because the bucket function is intentionally simple. The important point is that routing is deterministic, measurable, and controlled by <code>rolloutPercentage</code>.</p>
<p>If the migrated implementation starts producing differences, the mismatch counter gives you an observable signal.</p>
<p>And if you decide the rollout should stop, rollback is explicit:</p>
<pre><code class="language-typescript">service.migratedEnabled = false;
</code></pre>
<p>From that point forward, all returned responses come from the legacy implementation again.</p>
<p>This is intentionally a simplified example. A production system would need stronger routing, real metrics, error handling, persistent state, and careful treatment of side effects.</p>
<p>In particular, you shouldn't blindly execute both implementations if generating an invoice sends email, writes to two production databases, charges a customer, or publishes externally visible events. In those cases, the shadow path needs recording adapters, isolated infrastructure, or another mechanism that lets you compare behavior without duplicating real effects.</p>
<p>But the control loop is the same:</p>
<pre><code class="language-text">same input
↓
compare legacy and migrated behavior
↓
route a small percentage
↓
observe
↓
expand or roll back
</code></pre>
<p>That is incremental migration in its smallest useful form.</p>
<h2 id="heading-design-rollback-before-you-need-it">Design Rollback Before You Need It</h2>
<p>Rollback shouldn't be invented during an incident. Before moving traffic, ask what happens if the migrated path fails.</p>
<p>For routing-level migrations, rollback may be simple:</p>
<pre><code class="language-text">migration flag = false
</code></pre>
<p>and traffic returns to:</p>
<pre><code class="language-text">legacy implementation
</code></pre>
<p>For example:</p>
<pre><code class="language-typescript">if (
  featureFlags.useNewInvoices
) {
  return migrated.generate(
    orderId
  );
}

return legacy.generate(orderId);
</code></pre>
<p>If the migrated path behaves incorrectly:</p>
<pre><code class="language-text">useNewInvoices = false
</code></pre>
<p>Rollback is almost immediate.</p>
<p>But rollback becomes more complicated when:</p>
<pre><code class="language-text">data format changes
new data is written
events differ
external systems are updated
legacy code cannot read new records
</code></pre>
<p>In those cases, rollback may require more than flipping a feature flag. You might need backward-compatible schemas so both versions can read the same records, compensating actions for external side effects, replayable events, reconciliation jobs, or a short period where the legacy system remains able to consume data written by the new path.</p>
<p>For higher-risk migrations, it can also help to define a rollback boundary in advance. For example: traffic can return to legacy until a new schema version is written, or after a particular external event is emitted, recovery requires compensation instead of a simple rollback. The important part is knowing when rollback is still reversible and when you've crossed into a different recovery strategy.</p>
<p>That's why rollback design needs to happen before deployment.</p>
<h2 id="heading-treat-data-migration-as-a-separate-problem">Treat Data Migration as a Separate Problem</h2>
<p>Application migration and data migration are related, but they aren't the same problem.</p>
<p>Suppose the legacy system stores:</p>
<pre><code class="language-json">{
  "customer_type": "P",
  "status": 2
}
</code></pre>
<p>while the new system stores:</p>
<pre><code class="language-json">{
  "customerType": "PREMIUM",
  "status": "APPROVED"
}
</code></pre>
<p>You now need to answer:</p>
<pre><code class="language-text">Which database is authoritative?

Can both systems read the same data?

Do we transform on read?

Do we migrate records in batches?

Do we replicate changes?

When does ownership change?
</code></pre>
<p>These decisions should be explicit. Otherwise the application migration may appear successful while the data boundary remains ambiguous.</p>
<h2 id="heading-be-careful-with-dual-writes">Be Careful with Dual Writes</h2>
<p>One common transition strategy is:</p>
<pre><code class="language-text">write to legacy database
+
write to new database
</code></pre>
<p>This is called dual writing, and it looks simple.</p>
<p>For example:</p>
<pre><code class="language-typescript">await legacyOrders.save(order);
await newOrders.save(order);
</code></pre>
<p>But what happens if:</p>
<pre><code class="language-text">legacy write succeeds
new write fails
</code></pre>
<p>Now the two systems disagree.</p>
<p>Or:</p>
<pre><code class="language-text">legacy write fails
new write succeeds
</code></pre>
<p>Same problem.</p>
<p>Dual writes create a distributed consistency problem.</p>
<p>If you use them, you need to think about:</p>
<pre><code class="language-text">retries
idempotency
reconciliation
ordering
partial failure
monitoring
</code></pre>
<p>Sometimes a safer approach is:</p>
<pre><code class="language-text">single authoritative write
↓
change event
↓
replication
</code></pre>
<p>or a transactional outbox.</p>
<p>There's no universal solution. The important point is not to treat dual writing as a trivial migration technique.</p>
<h2 id="heading-decide-who-owns-the-data">Decide Who Owns the Data</h2>
<p>During coexistence, data ownership can become confusing.</p>
<p>Imagine:</p>
<pre><code class="language-text">legacy system writes customers

new system writes invoices

both systems read orders
</code></pre>
<p>That may be perfectly reasonable, but it should be documented.</p>
<p>For each migrated capability, define:</p>
<pre><code class="language-text">system of record
write owner
readers
replication direction
consistency expectations
</code></pre>
<p>For example:</p>
<pre><code class="language-text">Invoices

Write owner:
new system

Source of truth:
new database

Legacy access:
read-only adapter

Replication:
new → legacy reporting store
</code></pre>
<p>Now the architecture has an explicit direction.</p>
<p>Without ownership rules, migrations often create permanent synchronization problems.</p>
<h2 id="heading-observe-business-behavior-not-just-infrastructure">Observe Business Behavior, Not Just Infrastructure</h2>
<p>During rollout, teams often monitor:</p>
<pre><code class="language-text">CPU
memory
latency
HTTP 500s
database connections
</code></pre>
<p>Those are important. But they're not enough.</p>
<p>Suppose:</p>
<pre><code class="language-text">HTTP 200 rate = 99.99%
</code></pre>
<p>while:</p>
<pre><code class="language-text">invoice totals are wrong
</code></pre>
<p>Infrastructure monitoring says:</p>
<pre><code class="language-text">healthy
</code></pre>
<p>But the business system is not healthy.</p>
<p>Migration observability should include domain signals.</p>
<p>For example:</p>
<pre><code class="language-text">orders processed
payments authorized
invoices generated
discount distribution
failed renewals
average invoice total
events published
</code></pre>
<p>If you know normal business behavior, unusual changes can expose migration defects that technical metrics miss.</p>
<h2 id="heading-know-when-a-migration-slice-is-complete">Know When a Migration Slice Is Complete</h2>
<p>A capability isn't fully migrated just because traffic reached 100%.</p>
<p>Before declaring it complete, I would verify:</p>
<pre><code class="language-text">100% traffic on new path
acceptable error rate
acceptable latency
behavioral differences resolved
side effects verified
data ownership established
rollback window completed
legacy callers removed
legacy writes stopped
observability in place
</code></pre>
<p>Then ask:</p>
<blockquote>
<p>Is the legacy implementation still serving any purpose?</p>
</blockquote>
<p>If not, remove it.</p>
<p>Leaving both implementations permanently active creates:</p>
<pre><code class="language-text">maintenance cost
confusion
duplicate bugs
unclear ownership
future migration debt
</code></pre>
<p>Incremental migration should eventually simplify the system, not permanently duplicate it.</p>
<h2 id="heading-remove-the-legacy-path">Remove the Legacy Path</h2>
<p>This step is often delayed.</p>
<p>Teams migrate traffic but leave the old path in place:</p>
<pre><code class="language-text">just in case
</code></pre>
<p>Months later:</p>
<pre><code class="language-text">nobody knows whether it is still used
</code></pre>
<p>Before deleting it, verify:</p>
<pre><code class="language-text">routing metrics show zero traffic
no callers depend on it
data dependencies are removed
rollback period is complete
operational documentation is updated
</code></pre>
<p>Then remove:</p>
<pre><code class="language-text">legacy implementation
legacy feature flags
legacy database access
unused integration code
temporary compatibility layers
</code></pre>
<p>Deletion is part of migration.</p>
<p>A migration that only adds new architecture without removing old architecture can increase complexity rather than reduce it.</p>
<h2 id="heading-how-to-use-ai-during-an-incremental-migration">How to Use AI During an Incremental Migration</h2>
<p>AI can help with many parts of this process.</p>
<p>For example, it can inspect the legacy codebase and help answer:</p>
<pre><code class="language-text">Which modules implement this capability?

Which callers depend on it?

Which database tables does it touch?

Which external services does it call?

Which side effects occur?

Which feature flags already exist?

Which paths need adapters?
</code></pre>
<p>A useful prompt might be:</p>
<pre><code class="language-text">Analyze the Generate Invoice capability.

Identify:

1. entry points,
2. business rules,
3. persistence dependencies,
4. external integrations,
5. side effects,
6. callers,
7. data ownership,
8. possible migration seams.

Do not redesign the system.

Return evidence for each finding using file paths
and relevant code references.
</code></pre>
<p>AI can also help compare migration changes.</p>
<p>For example:</p>
<pre><code class="language-text">Compare the legacy and migrated implementations.

Identify possible behavioral differences in:

- return values,
- errors,
- side effects,
- persistence,
- event ordering,
- retries,
- idempotency,
- transaction boundaries.

Do not assume the new implementation is correct.
</code></pre>
<p>This is useful because migration involves a lot of repetitive analysis, and AI can accelerate that analysis.</p>
<h3 id="heading-dont-let-ai-turn-the-migration-into-a-rewrite">Don't Let AI Turn the Migration into a Rewrite</h3>
<p>There's a common failure mode.</p>
<p>You ask:</p>
<blockquote>
<p>Help me migrate this legacy capability.</p>
</blockquote>
<p>The model responds with:</p>
<pre><code class="language-text">new architecture
new domain model
new API
new event model
new database schema
new validation layer
new framework
</code></pre>
<p>At that point, you're no longer migrating one capability, you're redesigning it.</p>
<p>Sometimes redesign is necessary, but it should be intentional.</p>
<p>During incremental migration, I prefer prompts with explicit constraints.</p>
<p>For example:</p>
<pre><code class="language-text">Migrate this capability without intentionally changing
observable behavior.

Preserve:

- inputs,
- outputs,
- errors,
- side effects,
- ordering where relevant,
- transactional behavior.

Only introduce the minimum structural changes required
to run it in the target environment.

List any behavior you cannot preserve with confidence.
</code></pre>
<p>That keeps the transformation narrow.</p>
<p>AI should help reduce mechanical effort. It shouldn't silently expand project scope.</p>
<h2 id="heading-a-practical-incremental-migration-workflow">A Practical Incremental Migration Workflow</h2>
<p>Here's the workflow I would use.</p>
<h3 id="heading-1-understand-the-capability">1. Understand the Capability</h3>
<p>Identify:</p>
<pre><code class="language-text">inputs
outputs
rules
side effects
dependencies
unknowns
</code></pre>
<h3 id="heading-2-characterize-existing-behavior">2. Characterize Existing Behavior</h3>
<p>Protect important behavior with:</p>
<pre><code class="language-text">characterization tests
integration tests
contract tests
</code></pre>
<h3 id="heading-3-refactor-for-migration">3. Refactor for Migration</h3>
<p>Create:</p>
<pre><code class="language-text">seams
adapters
explicit dependencies
clear orchestration
</code></pre>
<p>without intentionally changing behavior.</p>
<h3 id="heading-4-build-the-new-implementation">4. Build the New Implementation</h3>
<p>Implement the capability in the target environment. Keep its observable contract clear.</p>
<h3 id="heading-5-differentially-test-old-and-new">5. Differentially Test Old and New</h3>
<p>Compare:</p>
<pre><code class="language-text">outputs
errors
side effects
business state
</code></pre>
<p>using representative cases.</p>
<h3 id="heading-6-introduce-explicit-routing">6. Introduce Explicit Routing</h3>
<p>Allow requests to choose:</p>
<pre><code class="language-text">legacy
or
migrated
</code></pre>
<p>through an observable migration policy.</p>
<h3 id="heading-7-start-with-safe-traffic">7. Start with Safe Traffic</h3>
<p>Use:</p>
<pre><code class="language-text">internal users
test tenants
selected customers
</code></pre>
<h3 id="heading-8-increase-traffic-gradually">8. Increase Traffic Gradually</h3>
<p>For example:</p>
<pre><code class="language-text">1%
5%
10%
25%
50%
100%
</code></pre>
<p>only when evidence supports the next stage.</p>
<h3 id="heading-9-monitor-technical-and-business-metrics">9. Monitor Technical and Business Metrics</h3>
<p>Observe both:</p>
<pre><code class="language-text">system health
business behavior
</code></pre>
<h3 id="heading-10-keep-rollback-available">10. Keep Rollback Available</h3>
<p>Make returning to the legacy path fast and understood.</p>
<h3 id="heading-11-transfer-data-ownership">11. Transfer Data Ownership</h3>
<p>Explicitly define which system owns:</p>
<pre><code class="language-text">writes
reads
replication
</code></pre>
<h3 id="heading-12-remove-the-legacy-path">12. Remove the Legacy Path</h3>
<p>After the migration has stabilized:</p>
<pre><code class="language-text">delete old implementation
remove temporary routing
remove obsolete dependencies
</code></pre>
<p>Then choose the next capability.</p>
<h2 id="heading-what-incremental-migration-doesnt-solve">What Incremental Migration Doesn't Solve</h2>
<p>Incremental migration reduces risk, but it doesn't eliminate complexity.</p>
<p>You may still need to deal with:</p>
<pre><code class="language-text">distributed transactions
shared databases
old schemas
tight coupling
unsupported runtimes
poor test coverage
organizational ownership
regulatory constraints
</code></pre>
<p>There are also systems where partial migration is extremely difficult.</p>
<p>For example:</p>
<pre><code class="language-text">highly stateful systems
strongly coupled desktop applications
large transactional batch systems
systems with shared global state
</code></pre>
<p>Sometimes the migration boundary needs to be larger.</p>
<p>The principle remains the same:</p>
<blockquote>
<p>Make the smallest reversible change that produces useful migration progress.</p>
</blockquote>
<p>Incremental doesn't always mean tiny. It means controlled.</p>
<h2 id="heading-the-complete-legacy-modernization-workflow">The Complete Legacy Modernization Workflow</h2>
<p>This article closes the workflow we've been building throughout this series.</p>
<p>We started with a basic problem:</p>
<blockquote>
<p>How do you modernize a legacy application without accidentally turning the project into a rewrite?</p>
</blockquote>
<p>The first step was understanding.</p>
<pre><code class="language-text">Legacy system
↓
investigate
↓
map behavior and dependencies
</code></pre>
<p>Then characterization.</p>
<pre><code class="language-text">observed behavior
↓
tests
↓
behavioral safety net
</code></pre>
<p>Then refactoring.</p>
<pre><code class="language-text">entangled capability
↓
seams and boundaries
↓
migration-friendly structure
</code></pre>
<p>Then differential testing.</p>
<pre><code class="language-text">legacy implementation
        +
new implementation
        ↓
behavior comparison
</code></pre>
<p>And finally incremental migration.</p>
<pre><code class="language-text">Understand
↓
Characterize
↓
Refactor
↓
Migrate
↓
Compare
↓
Route
↓
Observe
↓
Expand
↓
Remove legacy
</code></pre>
<p>The sequence matters.</p>
<p>If you skip understanding, you may migrate the wrong behavior.</p>
<p>If you skip characterization, you may not notice behavioral changes.</p>
<p>If you skip refactoring, the migration boundary may remain too large.</p>
<p>If you skip comparison, differences remain hidden.</p>
<p>If you skip incremental rollout, you discover problems at full blast radius.</p>
<p>Each step reduces a different kind of uncertainty.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Modernizing a legacy application doesn't require replacing everything at once.</p>
<p>In many cases, the safer strategy is to create a path where old and new implementations can coexist temporarily.</p>
<p>Move one capability, then compare it.</p>
<p>Route a small amount of traffic and observe what happens.</p>
<p>Increase traffic when the evidence supports it, and roll back when it doesn't.</p>
<p>Transfer ownership explicitly, then remove the legacy path.</p>
<p>And repeat.</p>
<p>The full workflow becomes:</p>
<pre><code class="language-text">Understand
↓
Characterize
↓
Refactor
↓
Migrate incrementally
↓
Compare behavior
↓
Progressively route traffic
↓
Observe
↓
Remove legacy
</code></pre>
<p>AI can make every stage faster.</p>
<p>It can help map code, identify dependencies, generate adapters, compare implementations, analyze failures, and inspect migration diffs.</p>
<p>But speed isn't the same as confidence.</p>
<p>The important decisions still require engineering judgment:</p>
<pre><code class="language-text">What behavior matters?

What can change?

What should remain compatible?

What is the migration boundary?

What evidence is enough?

When is rollback necessary?

When can the legacy path be removed?
</code></pre>
<p>Those aren't code-generation questions. They're migration decisions.</p>
<p>And that's the larger lesson behind this entire series.</p>
<p>AI makes it increasingly cheap to produce new code. But that doesn't make legacy modernization trivial. It makes the quality of the decisions around the code more important.</p>
<p>Because the safest migration is rarely the one that changes the most software. It's the one that lets you change the system while continuously knowing what changed, why it changed, and whether it's safe to keep going.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Use Differential Testing During a Legacy Migration ]]>
                </title>
                <description>
                    <![CDATA[ The most dangerous moment in a legacy migration isn't necessarily when you start writing the new implementation. It's when the new implementation looks finished. The code compiles, the tests pass, the ]]>
                </description>
                <link>https://www.freecodecamp.org/news/differential-testing-legacy-migration/</link>
                <guid isPermaLink="false">6aa8200e59dfce663a0e73bd</guid>
                
                    <category>
                        <![CDATA[ legacy code ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Software Testing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ software architecture ]]>
                    </category>
                
                    <category>
                        <![CDATA[ migration ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Artificial Intelligence ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Hugo Teijiz ]]>
                </dc:creator>
                <pubDate>Mon, 14 Sep 2026 16:25:50 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/6717bbb6-16b9-4fc1-8bfe-7381a3024f73.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>The most dangerous moment in a legacy migration isn't necessarily when you start writing the new implementation. It's when the new implementation looks finished.</p>
<p>The code compiles, the tests pass, the architecture is cleaner, and the new service responds faster.</p>
<p>Then everybody starts asking the same question:</p>
<blockquote>
<p>Can we switch traffic now?</p>
</blockquote>
<p>That's where confidence becomes difficult.</p>
<p>A new implementation can pass its own test suite and still behave differently from the system it is replacing.</p>
<p>Maybe rounding changed, or null values are handled differently, or an error became a successful response.</p>
<p>Maybe records are sorted differently, or a side effect happens in a different order, or a business rule you never documented was lost during the migration.</p>
<p>This is why, during a legacy migration, I like having another source of evidence: <strong>run the old and new implementations with the same inputs and compare what they do.</strong></p>
<p>That's the basic idea behind differential testing. Instead of asking only if the new system passes its tests, you also ask: given the same input, where does the new system behave differently from the old one?</p>
<p>Those differences become evidence.</p>
<p>Some are bugs, some are intentional improvements, some are harmless representation differences, and some reveal behavior nobody knew existed.</p>
<p>In this tutorial, I'll show you how to use differential testing during a legacy migration to:</p>
<ul>
<li><p>Compare old and new implementations</p>
</li>
<li><p>Define what should be considered equivalent</p>
</li>
<li><p>Normalize outputs before comparing them</p>
</li>
<li><p>Handle timestamps and other nondeterministic values</p>
</li>
<li><p>Compare errors and side effects</p>
</li>
<li><p>Run differential tests automatically</p>
</li>
<li><p>Introduce tolerances where exact equality doesn't make sense</p>
</li>
<li><p>Analyze mismatches</p>
</li>
<li><p>Use shadow traffic in production safely</p>
</li>
<li><p>Use AI to classify divergences without letting it decide correctness</p>
</li>
<li><p>Determine when the new implementation is ready for cutover</p>
</li>
</ul>
<p>The examples use TypeScript and Vitest, but the approach applies to most languages and migration strategies.</p>
<p>The goal isn't to prove that two implementations are internally identical. It's to obtain evidence that they are <strong>behaviorally equivalent where equivalence matters</strong>.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along here, you should be comfortable with:</p>
<ul>
<li><p>TypeScript or a similar language</p>
</li>
<li><p>unit and integration testing</p>
</li>
<li><p>asynchronous code</p>
</li>
<li><p>API and service boundaries</p>
</li>
<li><p>legacy modernization</p>
</li>
<li><p>basic observability concepts</p>
</li>
</ul>
<p>You should also already have some understanding of the capability being migrated.</p>
<p>Ideally, you know its inputs, outputs, important business rules, external contracts, side effects, and known areas of uncertainty.</p>
<p>Differential testing works best after you've already created a boundary around the capability you want to migrate.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-differential-testing-actually-tells-you">What Differential Testing Actually Tells You</a></p>
</li>
<li><p><a href="#heading-start-with-one-observable-boundary">Start with One Observable Boundary</a></p>
</li>
<li><p><a href="#heading-run-the-legacy-and-new-implementations-with-the-same-input">Run the Legacy and New Implementations with the Same Input</a></p>
</li>
<li><p><a href="#heading-dont-compare-raw-output-blindly">Don't Compare Raw Output Blindly</a></p>
</li>
<li><p><a href="#heading-normalize-values-before-comparing-them">Normalize Values Before Comparing Them</a></p>
</li>
<li><p><a href="#heading-handle-timestamps-and-other-nondeterministic-values">Handle Timestamps and Other Nondeterministic Values</a></p>
</li>
<li><p><a href="#heading-compare-business-meaning-not-just-json">Compare Business Meaning, Not Just JSON</a></p>
</li>
<li><p><a href="#heading-compare-errors-as-part-of-the-contract">Compare Errors as Part of the Contract</a></p>
</li>
<li><p><a href="#heading-compare-side-effects-too">Compare Side Effects, Too</a></p>
</li>
<li><p><a href="#heading-use-tolerances-when-exact-equality-is-wrong">Use Tolerances When Exact Equality Is Wrong</a></p>
</li>
<li><p><a href="#heading-build-a-reusable-differential-test-harness">Build a Reusable Differential Test Harness</a></p>
</li>
<li><p><a href="#heading-generate-test-cases-from-real-behavior">Generate Test Cases from Real Behavior</a></p>
</li>
<li><p><a href="#heading-classify-every-difference">Classify Every Difference</a></p>
</li>
<li><p><a href="#heading-how-to-use-ai-to-investigate-differential-failures">How to Use AI to Investigate Differential Failures</a></p>
</li>
<li><p><a href="#heading-how-to-use-shadow-traffic-safely">How to Use Shadow Traffic Safely</a></p>
</li>
<li><p><a href="#heading-measure-divergence-instead-of-waiting-for-perfection">Measure Divergence Instead of Waiting for Perfection</a></p>
</li>
<li><p><a href="#heading-how-to-know-when-youre-ready-for-cutover">How to Know When You're Ready for Cutover</a></p>
</li>
<li><p><a href="#heading-a-practical-differential-testing-workflow">A Practical Differential Testing Workflow</a></p>
</li>
<li><p><a href="#heading-what-differential-testing-cant-prove">What Differential Testing Can't Prove</a></p>
</li>
<li><p><a href="#heading-differential-testing-turns-migration-risk-into-evidence">Differential Testing Turns Migration Risk into Evidence</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-differential-testing-actually-tells-you">What Differential Testing Actually Tells You</h2>
<p>Imagine that your legacy application calculates the final price of an order.</p>
<p>The legacy implementation looks like this:</p>
<pre><code class="language-typescript">type Order = {
  subtotal: number;
  customerType: "STANDARD" | "PREMIUM";
  country: string;
};

function legacyCalculateTotal(order: Order): number {
  let total = order.subtotal;

  if (order.customerType === "PREMIUM") {
    total *= 0.9;
  }

  if (order.country === "AR") {
    total -= 500;
  }

  return Math.max(total, 0);
}
</code></pre>
<p>During the migration, you create a new implementation:</p>
<pre><code class="language-typescript">function newCalculateTotal(order: Order): number {
  const premiumDiscount =
    order.customerType === "PREMIUM"
      ? order.subtotal * 0.1
      : 0;

  const countryAdjustment =
    order.country === "AR"
      ? 500
      : 0;

  return Math.max(
    order.subtotal -
      premiumDiscount -
      countryAdjustment,
    0
  );
}
</code></pre>
<p>The implementations look different. And that's fine. What matters is whether they produce equivalent behavior.</p>
<p>A simple differential test can run both:</p>
<pre><code class="language-typescript">import { describe, expect, it } from "vitest";

describe("order total migration", () =&gt; {
  it("matches the legacy implementation", () =&gt; {
    const order: Order = {
      subtotal: 10000,
      customerType: "PREMIUM",
      country: "AR",
    };

    const legacy =
      legacyCalculateTotal(order);

    const migrated =
      newCalculateTotal(order);

    expect(migrated).toBe(legacy);
  });
});
</code></pre>
<p>For this input:</p>
<pre><code class="language-text">legacy → 8500
new    → 8500
</code></pre>
<p>Good. But one matching example proves very little.</p>
<p>The value comes from systematically asking:</p>
<pre><code class="language-text">same input
↓
legacy implementation ──→ result A

same input
↓
new implementation ─────→ result B

compare A and B
</code></pre>
<p>Every mismatch gives you something to investigate.</p>
<h2 id="heading-start-with-one-observable-boundary">Start with One Observable Boundary</h2>
<p>Don't begin by comparing entire applications. To start, choose one capability.</p>
<p>For example:</p>
<pre><code class="language-text">Calculate Order Total
Generate Invoice
Approve Customer
Renew Subscription
Calculate Commission
Create Shipment
</code></pre>
<p>Suppose the migration boundary is:</p>
<pre><code class="language-typescript">interface OrderProcessor {
  process(order: Order): Promise&lt;ProcessedOrder&gt;;
}
</code></pre>
<p>Now you have two implementations:</p>
<pre><code class="language-text">LegacyOrderProcessor

NewOrderProcessor
</code></pre>
<p>That is a useful differential boundary, because both receive the same conceptual input, and both produce the same conceptual output.</p>
<p>You can compare them without requiring their internal architecture to match.</p>
<p>That matters because migrations often change structure intentionally.</p>
<p>The legacy implementation might be:</p>
<pre><code class="language-text">controller
→ service
→ SQL
→ provider SDK
</code></pre>
<p>while the new implementation might be:</p>
<pre><code class="language-text">use case
→ repository
→ gateway
→ events
</code></pre>
<p>Differential testing shouldn't care. It should care about observable behavior.</p>
<h2 id="heading-run-the-legacy-and-new-implementations-with-the-same-input">Run the Legacy and New Implementations with the Same Input</h2>
<p>Suppose both implementations expose:</p>
<pre><code class="language-typescript">interface OrderProcessor {
  process(order: Order): Promise&lt;ProcessedOrder&gt;;
}
</code></pre>
<p>You can create:</p>
<pre><code class="language-typescript">const legacyProcessor =
  new LegacyOrderProcessor();

const newProcessor =
  new NewOrderProcessor();
</code></pre>
<p>Then:</p>
<pre><code class="language-typescript">it("produces the same processed order", async () =&gt; {
  const input: Order = {
    id: "order-1",
    subtotal: 10000,
    customerType: "PREMIUM",
    country: "US",
  };

  const legacy =
    await legacyProcessor.process(
      structuredClone(input)
    );

  const migrated =
    await newProcessor.process(
      structuredClone(input)
    );

  expect(migrated).toEqual(legacy);
});
</code></pre>
<p>Notice the use of:</p>
<pre><code class="language-typescript">structuredClone(input)
</code></pre>
<p>That matters if either implementation mutates its input.</p>
<p>Without separate copies, the first execution could influence the second.</p>
<p>You want:</p>
<pre><code class="language-text">same initial state
</code></pre>
<p>not:</p>
<pre><code class="language-text">new implementation receives state modified by legacy implementation
</code></pre>
<p>That kind of contamination can create misleading results.</p>
<h2 id="heading-dont-compare-raw-output-blindly">Don't Compare Raw Output Blindly</h2>
<p>The first version of a differential test is often:</p>
<pre><code class="language-typescript">expect(newResult).toEqual(legacyResult);
</code></pre>
<p>Sometimes that's exactly right. But other times it's wrong.</p>
<p>Imagine the legacy system returns:</p>
<pre><code class="language-json">{
  "id": "order-1",
  "total": 9000,
  "status": "PROCESSED",
  "generatedAt": "2026-09-09T10:00:01.231Z",
  "requestId": "legacy-f93a"
}
</code></pre>
<p>The new system returns:</p>
<pre><code class="language-json">{
  "requestId": "new-b517",
  "status": "PROCESSED",
  "generatedAt": "2026-09-09T10:00:01.416Z",
  "total": 9000,
  "id": "order-1"
}
</code></pre>
<p>A raw object comparison may fail because:</p>
<pre><code class="language-text">requestId differs
timestamp differs
</code></pre>
<p>But the business behavior might be equivalent.</p>
<p>You need to decide which fields are part of the meaningful contract.</p>
<p>Maybe:</p>
<pre><code class="language-text">id
total
status
</code></pre>
<p>matter.</p>
<p>While:</p>
<pre><code class="language-text">generatedAt
requestId
</code></pre>
<p>don't need exact equivalence.</p>
<p>That leads to normalization.</p>
<h2 id="heading-normalize-values-before-comparing-them">Normalize Values Before Comparing Them</h2>
<p>Normalization means transforming outputs into a common representation before comparing them.</p>
<p>The goal isn't to change the business meaning of the data. It's to remove differences that are expected and irrelevant to the comparison, such as generated request IDs or timestamps, so the test can focus on the fields that actually define the behavior you care about.</p>
<p>In practice, that often means creating a canonical representation: a smaller, stable shape that contains only the meaningful fields you want to compare.</p>
<p>For example:</p>
<pre><code class="language-typescript">type ProcessedOrder = {
  id: string;
  total: number;
  status: string;
  generatedAt: string;
  requestId: string;
};

function normalizeOrder(
  order: ProcessedOrder
) {
  return {
    id: order.id,
    total: order.total,
    status: order.status,
  };
}
</code></pre>
<p>Here, <code>ProcessedOrder</code> contains both business-relevant fields and values that may legitimately differ between executions.</p>
<p>The <code>normalizeOrder()</code> function keeps <code>id</code>, <code>total</code>, and <code>status</code>, while leaving out <code>generatedAt</code> and <code>requestId</code>. That means two results can still be considered equivalent even if they were generated at slightly different times or used different request identifiers.</p>
<p>Now compare:</p>
<pre><code class="language-typescript">expect(
  normalizeOrder(migrated)
).toEqual(
  normalizeOrder(legacy)
);
</code></pre>
<p>This makes your equivalence rule explicit.</p>
<p>You're saying:</p>
<blockquote>
<p>These fields define relevant behavior for this comparison.</p>
</blockquote>
<p>Normalization can also handle:</p>
<ul>
<li><p>ordering</p>
</li>
<li><p>casing</p>
</li>
<li><p>optional fields</p>
</li>
<li><p>timestamps</p>
</li>
<li><p>generated identifiers</p>
</li>
<li><p>numeric formatting</p>
</li>
<li><p>provider-specific metadata</p>
</li>
</ul>
<p>But normalization must be deliberate. If you remove too much, you can hide real migration bugs.</p>
<h2 id="heading-handle-timestamps-and-other-nondeterministic-values">Handle Timestamps and Other Nondeterministic Values</h2>
<p>Legacy systems contain many nondeterministic values.</p>
<p>For example:</p>
<pre><code class="language-text">timestamps
UUIDs
random tokens
request IDs
trace IDs
database-generated IDs
unordered collections
provider-generated references
</code></pre>
<p>If you compare those values exactly, your differential suite may fail constantly.</p>
<p>One option is dependency control.</p>
<p>Dependency control means moving a nondeterministic source, such as the current time or an ID generator, behind an interface that you can replace during tests.</p>
<p>Instead of letting each implementation read the real clock independently, you inject the same controlled clock into both. That gives them the same value and removes time itself as a source of meaningless divergence.</p>
<p>Suppose the code uses:</p>
<pre><code class="language-typescript">new Date()
</code></pre>
<p>You can replace that dependency with a clock:</p>
<pre><code class="language-typescript">interface Clock {
  now(): Date;
}
</code></pre>
<p>Then both implementations receive:</p>
<pre><code class="language-typescript">const clock = {
  now: () =&gt;
    new Date(
      "2026-09-09T10:00:00.000Z"
    ),
};
</code></pre>
<p>Now time becomes deterministic.</p>
<p>The same technique can work for ID generation:</p>
<pre><code class="language-typescript">interface IdGenerator {
  next(): string;
}
</code></pre>
<p>Then tests can provide:</p>
<pre><code class="language-typescript">const ids = {
  next: () =&gt; "fixed-id",
};
</code></pre>
<p>If controlling nondeterminism is impractical, normalize it out only when it's not part of the behavior you need to protect.</p>
<h2 id="heading-compare-business-meaning-not-just-json">Compare Business Meaning, Not Just JSON</h2>
<p>Two systems can return different representations while expressing the same business state.</p>
<p>Imagine you have this in your legacy system:</p>
<pre><code class="language-json">{
  "status": 2
}
</code></pre>
<p>And this in your new one:</p>
<pre><code class="language-json">{
  "status": "APPROVED"
}
</code></pre>
<p>Raw comparison says:</p>
<pre><code class="language-text">different
</code></pre>
<p>Business comparison may say:</p>
<pre><code class="language-text">equivalent
</code></pre>
<p>You can create a semantic normalizer:</p>
<pre><code class="language-typescript">function normalizeStatus(
  status: number | string
) {
  if (status === 2) {
    return "APPROVED";
  }

  return status;
}
</code></pre>
<p>Here, the normalizer translates the legacy numeric value <code>2</code> into the business meaning used by the new implementation: <code>"APPROVED"</code>.</p>
<p>It doesn't claim that every number and string are interchangeable. It encodes one explicit equivalence rule that you've already decided is valid for this migration.</p>
<p>Then:</p>
<pre><code class="language-typescript">expect(
  normalizeStatus(newResult.status)
).toBe(
  normalizeStatus(legacyResult.status)
);
</code></pre>
<p>This is especially useful when migration intentionally changes:</p>
<pre><code class="language-text">database schema
API representation
enumerations
provider-specific formats
internal identifiers
</code></pre>
<p>The important question becomes:</p>
<blockquote>
<p>Does the observable business meaning remain equivalent?</p>
</blockquote>
<p>Not:</p>
<blockquote>
<p>Are the bytes identical?</p>
</blockquote>
<h2 id="heading-compare-errors-as-part-of-the-contract">Compare Errors as Part of the Contract</h2>
<p>Success responses aren't the whole behavior. Errors matter too.</p>
<p>Suppose the legacy implementation rejects a missing customer:</p>
<pre><code class="language-typescript">throw new Error("Customer not found");
</code></pre>
<p>The new implementation accidentally returns:</p>
<pre><code class="language-typescript">return null;
</code></pre>
<p>These two implementations behave very differently for the same invalid input.</p>
<p>The legacy version fails explicitly, while the new version silently returns a value that a caller may interpret as a successful result.</p>
<p>If your differential tests only exercise cases where a valid customer exists, both implementations may appear equivalent and this contract change will remain invisible.</p>
<p>That's why failure behavior has to be compared too.</p>
<p>Create cases that capture errors:</p>
<pre><code class="language-typescript">async function captureResult&lt;T&gt;(
  operation: () =&gt; Promise&lt;T&gt;
) {
  try {
    return {
      type: "success" as const,
      value: await operation(),
    };
  } catch (error) {
    return {
      type: "error" as const,
      error:
        error instanceof Error
          ? error.message
          : String(error),
    };
  }
}
</code></pre>
<p>The helper wraps an asynchronous operation and converts both possible outcomes into data.</p>
<p>If the operation succeeds, it returns an object with <code>type: "success"</code> and the returned value. If the operation throws, the <code>catch</code> block converts that exception into an object with <code>type: "error"</code> and a readable error message.</p>
<p>This gives both implementations the same comparison shape, so the test can compare success versus failure explicitly instead of letting an exception stop the test before the two behaviors can be evaluated.</p>
<p>Now:</p>
<pre><code class="language-typescript">const legacy =
  await captureResult(() =&gt;
    legacyProcessor.process(input)
  );

const migrated =
  await captureResult(() =&gt;
    newProcessor.process(input)
  );

expect(migrated.type).toBe(legacy.type);
</code></pre>
<p>If errors are contractually important, compare:</p>
<pre><code class="language-text">error category
HTTP status
error code
retryability
validation details
</code></pre>
<p>Don't necessarily compare exact wording unless clients depend on it.</p>
<h2 id="heading-compare-side-effects-too">Compare Side Effects, Too</h2>
<p>One of the easiest migration mistakes is preserving the return value while losing a side effect.</p>
<p>Suppose both implementations return:</p>
<pre><code class="language-json">{
  "status": "PROCESSED"
}
</code></pre>
<p>But the legacy version also:</p>
<pre><code class="language-text">persists the order
publishes an event
creates a payment
writes an audit entry
</code></pre>
<p>and the new version forgets the audit entry.</p>
<p>Response-level differential testing won't catch that. So you'll want to capture side effects.</p>
<p>For example:</p>
<pre><code class="language-typescript">type Effect =
  | {
      type: "payment";
      orderId: string;
      amount: number;
    }
  | {
      type: "event";
      name: string;
      orderId: string;
    };
</code></pre>
<p>A test adapter can record them:</p>
<pre><code class="language-typescript">class RecordingPaymentGateway {
  effects: Effect[] = [];

  async charge(
    orderId: string,
    amount: number
  ) {
    this.effects.push({
      type: "payment",
      orderId,
      amount,
    });
  }
}
</code></pre>
<p>Instead of sending a real payment request, this adapter records what the application attempted to do in the <code>effects</code> array.</p>
<p>You can apply the same idea to event publication:</p>
<pre><code class="language-typescript">class RecordingEvents {
  effects: Effect[] = [];

  async publish(
    name: string,
    orderId: string
  ) {
    this.effects.push({
      type: "event",
      name,
      orderId,
    });
  }
}
</code></pre>
<p>The application still calls its payment and event dependencies as usual. The test doubles simply capture those calls as structured data instead of performing the real external actions.</p>
<p>After running the legacy and migrated implementations with their own recording adapters, you can compare the two recorded effect lists and verify that both systems attempted the same observable side effects.</p>
<p>Now the differential test can compare:</p>
<pre><code class="language-typescript">expect(newEffects).toEqual(legacyEffects);
</code></pre>
<p>Again, exact ordering should only be required if ordering matters.</p>
<h2 id="heading-use-tolerances-when-exact-equality-is-wrong">Use Tolerances When Exact Equality Is Wrong</h2>
<p>Some domains shouldn't use exact equality.</p>
<p>Imagine a migrated calculation produces:</p>
<pre><code class="language-text">legacy → 34.333333333
new    → 34.333333334
</code></pre>
<p>Is that a migration bug? Maybe not.</p>
<p>Floating-point calculations may justify a tolerance.</p>
<p>For example:</p>
<pre><code class="language-typescript">expect(newResult).toBeCloseTo(
  legacyResult,
  6
);
</code></pre>
<p>Or define an explicit comparator:</p>
<pre><code class="language-typescript">function withinTolerance(
  a: number,
  b: number,
  tolerance: number
) {
  return Math.abs(a - b) &lt;= tolerance;
}
</code></pre>
<p>Then:</p>
<pre><code class="language-typescript">expect(
  withinTolerance(
    migrated.total,
    legacy.total,
    0.01
  )
).toBe(true);
</code></pre>
<p>But tolerances should come from domain requirements. Don't use them just to make failing tests disappear.</p>
<p>For financial systems, one cent can matter. For scientific calculations, a much smaller numerical difference may matter.</p>
<p>Equivalence is a business and engineering decision.</p>
<h2 id="heading-build-a-reusable-differential-test-harness">Build a Reusable Differential Test Harness</h2>
<p>Once you compare more than a few cases, you can create a reusable harness.</p>
<p>For example:</p>
<pre><code class="language-typescript">type DifferentialResult&lt;T&gt; = {
  input: T;
  equivalent: boolean;
  legacy: unknown;
  migrated: unknown;
};

async function compareImplementations&lt;
  TInput,
  TOutput
&gt;(
  input: TInput,
  legacy: (
    input: TInput
  ) =&gt; Promise&lt;TOutput&gt;,
  migrated: (
    input: TInput
  ) =&gt; Promise&lt;TOutput&gt;,
  normalize: (
    output: TOutput
  ) =&gt; unknown
): Promise&lt;
  DifferentialResult&lt;TInput&gt;
&gt; {
  const legacyResult =
    await legacy(
      structuredClone(input)
    );

  const migratedResult =
    await migrated(
      structuredClone(input)
    );

  const normalizedLegacy =
    normalize(legacyResult);

  const normalizedMigrated =
    normalize(migratedResult);

  return {
    input,
    equivalent:
      JSON.stringify(
        normalizedLegacy
      ) ===
      JSON.stringify(
        normalizedMigrated
      ),
    legacy: normalizedLegacy,
    migrated: normalizedMigrated,
  };
}
</code></pre>
<p>The harness does four things.</p>
<p>First, it runs the legacy and migrated implementations with separate clones of the same input, so one execution can't mutate the data seen by the other.</p>
<p>Second, it passes both outputs through the same <code>normalize()</code> function. That applies the equivalence rules in one place instead of repeating them in every test.</p>
<p>Third, it compares the normalized results and records whether they're equivalent.</p>
<p>Finally, it returns the input and both normalized outputs together. That makes a failed comparison easier to inspect because the test report can show exactly which case diverged and what each implementation produced.</p>
<p>Then:</p>
<pre><code class="language-typescript">const result =
  await compareImplementations(
    input,
    legacyProcessor.process.bind(
      legacyProcessor
    ),
    newProcessor.process.bind(
      newProcessor
    ),
    normalizeOrder
  );

expect(result.equivalent).toBe(true);
</code></pre>
<p>For real systems, I would usually avoid relying on <code>JSON.stringify()</code> as the final equality mechanism.</p>
<p>The example keeps the harness readable.</p>
<p>In production-quality tooling, use a proper structural or domain-specific comparator.</p>
<p>The important part is that comparison logic becomes centralized.</p>
<h2 id="heading-generate-test-cases-from-real-behavior">Generate Test Cases from Real Behavior</h2>
<p>Hand-written examples are useful. But migrations often fail on cases nobody thought to write manually.</p>
<p>Useful sources of inputs include:</p>
<pre><code class="language-text">existing test fixtures
historical incidents
production-safe request samples
database records
boundary values
previous bug reports
known customer scenarios
</code></pre>
<p>Suppose production shows these order shapes:</p>
<pre><code class="language-typescript">const cases: Order[] = [
  {
    subtotal: 0,
    customerType: "STANDARD",
    country: "US",
  },
  {
    subtotal: 500,
    customerType: "PREMIUM",
    country: "AR",
  },
  {
    subtotal: 10000,
    customerType: "STANDARD",
    country: "AR",
  },
];
</code></pre>
<p>The first block is the test data. It captures a small set of representative input shapes that you've observed in real usage or reconstructed safely from production behavior.</p>
<p>The next block is the test itself. <code>it.each(cases)</code> tells Vitest to run the same differential comparison once for every input in that array.</p>
<p>That separates two concerns: defining realistic cases and defining how every case should be evaluated.</p>
<p>Now:</p>
<pre><code class="language-typescript">it.each(cases)(
  "matches legacy behavior",
  async (input) =&gt; {
    const legacy =
      await legacyProcessor.process(
        structuredClone(input)
      );

    const migrated =
      await newProcessor.process(
        structuredClone(input)
      );

    expect(
      normalizeOrder(migrated)
    ).toEqual(
      normalizeOrder(legacy)
    );
  }
);
</code></pre>
<p>Real examples help expose assumptions that synthetic test data often misses. But production data must be handled carefully.</p>
<p>Remove or anonymize:</p>
<pre><code class="language-text">personal data
credentials
tokens
financial identifiers
confidential business data
</code></pre>
<p>The objective is to preserve useful behavioral shapes, not copy sensitive production information into test fixtures.</p>
<h2 id="heading-classify-every-difference">Classify Every Difference</h2>
<p>A differential failure doesn't automatically mean that the new implementation is wrong.</p>
<p>Suppose you find 200 mismatches. Classify them.</p>
<p>I like categories such as:</p>
<pre><code class="language-text">migration defect
legacy defect intentionally preserved
intentional behavior change
representation difference
nondeterministic difference
test/comparator defect
unknown
</code></pre>
<p>For example:</p>
<pre><code class="language-text">Input:
subtotal = 5000

Legacy:
discount = 0

New:
discount = 500

Classification:
unknown
</code></pre>
<p>Investigation reveals that the new implementation changed:</p>
<pre><code class="language-typescript">amount &gt; 5000
</code></pre>
<p>to:</p>
<pre><code class="language-typescript">amount &gt;= 5000
</code></pre>
<p>Now you need a decision.</p>
<p>Was that:</p>
<pre><code class="language-text">accidental migration change
</code></pre>
<p>or:</p>
<pre><code class="language-text">intentional bug fix
</code></pre>
<p>Differential testing exposes the decision. It doesn't make the decision for you.</p>
<p>That's one of its greatest benefits.</p>
<h2 id="heading-how-to-use-ai-to-investigate-differential-failures">How to Use AI to Investigate Differential Failures</h2>
<p>Large migrations can produce hundreds or thousands of differences. And AI can help triage them.</p>
<p>Suppose you have:</p>
<pre><code class="language-json">{
  "input": {
    "subtotal": 5000,
    "country": "AR"
  },
  "legacy": {
    "total": 4500
  },
  "new": {
    "total": 4000
  }
}
</code></pre>
<p>You can give the model:</p>
<ul>
<li><p>the input</p>
</li>
<li><p>both outputs</p>
</li>
<li><p>relevant legacy code</p>
</li>
<li><p>relevant migrated code</p>
</li>
<li><p>the comparator rules</p>
</li>
</ul>
<p>Then ask:</p>
<pre><code class="language-text">Analyze this differential test failure.

Identify the smallest behavioral difference that could
explain the mismatch.

Compare the legacy and migrated implementations.

Return:

1. observed difference,
2. relevant legacy branch,
3. relevant migrated branch,
4. likely cause,
5. evidence supporting the cause,
6. additional test cases that could confirm it.

Do not decide which behavior is correct.
Do not modify the code yet.
</code></pre>
<p>That last instruction matters. AI can be very useful for locating why two implementations diverge. It shouldn't silently turn that diagnosis into a business decision.</p>
<h3 id="heading-dont-let-ai-decide-which-behavior-is-correct">Don't Let AI Decide Which Behavior Is Correct</h3>
<p>Imagine the legacy system does this:</p>
<pre><code class="language-text">Customer age 65 → no discount
Customer age 66 → discount
</code></pre>
<p>The new system does:</p>
<pre><code class="language-text">Customer age 65 → discount
Customer age 66 → discount
</code></pre>
<p>AI may look at the code and say:</p>
<blockquote>
<p>The new implementation appears more logical because senior discounts typically begin at age 65.</p>
</blockquote>
<p>That's irrelevant.</p>
<p>The business rule might be:</p>
<pre><code class="language-text">age &gt; 65
</code></pre>
<p>for a reason. Or the legacy behavior might contain a bug.</p>
<p>You need evidence.</p>
<p>Use:</p>
<pre><code class="language-text">requirements
existing tests
production behavior
business owners
historical tickets
commit history
contracts
</code></pre>
<p>AI can help gather and summarize that evidence. It shouldn't invent the rule.</p>
<p>Differential testing is valuable because it tells you that there's a difference before you accidentally turn that difference into production behavior.</p>
<h2 id="heading-how-to-use-shadow-traffic-safely">How to Use Shadow Traffic Safely</h2>
<p>Once offline differential tests look good, you can sometimes compare behavior with real traffic. This is often called shadowing or traffic mirroring.</p>
<p>The pattern looks like:</p>
<pre><code class="language-text">real request
    │
    ├────────────→ legacy system
    │                  │
    │                  ↓
    │             real response
    │
    └────────────→ new system
                       │
                       ↓
                  shadow result
</code></pre>
<p>The user still receives:</p>
<pre><code class="language-text">legacy response
</code></pre>
<p>while the new system processes a copy of the request.</p>
<p>Then you compare:</p>
<pre><code class="language-text">legacy output
vs.
shadow output
</code></pre>
<p>This can reveal cases that your test suite never captured.</p>
<p>For example:</p>
<pre><code class="language-text">unexpected null combinations
rare customer states
unusual international data
old records
large values
unusual sequence patterns
</code></pre>
<p>But shadow execution requires careful design, especially when the operation has side effects.</p>
<h3 id="heading-how-to-prevent-shadow-execution-from-duplicating-side-effects">How to Prevent Shadow Execution from Duplicating Side Effects</h3>
<p>Imagine shadowing:</p>
<pre><code class="language-text">POST /payments
</code></pre>
<p>If both systems really execute the payment, you have a serious problem.</p>
<p>The same applies to:</p>
<pre><code class="language-text">send email
create shipment
charge card
modify inventory
publish event
write external record
</code></pre>
<p>The shadow implementation shouldn't perform destructive or externally visible effects unless they're safely isolated.</p>
<p>One approach is to replace real gateways with recording adapters:</p>
<pre><code class="language-typescript">class ShadowPaymentGateway
  implements PaymentGateway {
  calls: PaymentRequest[] = [];

  async charge(
    request: PaymentRequest
  ) {
    this.calls.push(request);

    return {
      paymentId: "shadow",
    };
  }
}
</code></pre>
<p>The new implementation still tries to execute:</p>
<pre><code class="language-text">payment
</code></pre>
<p>but instead of charging a real card, the shadow adapter records:</p>
<pre><code class="language-text">what would have been sent
</code></pre>
<p>You can then compare that intent with the legacy side effect.</p>
<p>This distinction is important:</p>
<pre><code class="language-text">compare behavior
</code></pre>
<p>does not mean:</p>
<pre><code class="language-text">duplicate production effects
</code></pre>
<h2 id="heading-measure-divergence-instead-of-waiting-for-perfection">Measure Divergence Instead of Waiting for Perfection</h2>
<p>When running thousands of comparisons, a binary:</p>
<pre><code class="language-text">pass / fail
</code></pre>
<p>may not tell the whole story.</p>
<p>You can measure divergence.</p>
<p>For example:</p>
<pre><code class="language-text">Requests compared:     100,000
Equivalent:             99,620
Different:                 380

Divergence rate:          0.38%
</code></pre>
<p>Then classify those 380:</p>
<pre><code class="language-text">250 timestamp differences
80 known intentional changes
30 comparator problems
15 migration defects fixed
5 still unexplained
</code></pre>
<p>After normalization:</p>
<pre><code class="language-text">meaningful unresolved divergence:
5 / 100,000
= 0.005%
</code></pre>
<p>Now the conversation becomes much more concrete.</p>
<p>Instead of:</p>
<blockquote>
<p>I think the migration is ready.</p>
</blockquote>
<p>you can say:</p>
<blockquote>
<p>We compared 100,000 representative executions and have five unresolved behavioral differences.</p>
</blockquote>
<p>Whether that's acceptable depends on what those five cases are.</p>
<p>One incorrect financial transaction can matter more than 100 harmless formatting differences.</p>
<p>So don't evaluate only the percentage. Evaluate the severity.</p>
<h2 id="heading-how-to-know-when-youre-ready-for-cutover">How to Know When You're Ready for Cutover</h2>
<p>Differential testing doesn't give you a universal threshold. But it can give you evidence.</p>
<p>Before cutover, I would want to answer questions such as:</p>
<h3 id="heading-have-important-input-classes-been-compared">Have Important Input Classes Been Compared?</h3>
<p>Not only happy paths.</p>
<p>Include:</p>
<pre><code class="language-text">boundaries
errors
historical bugs
large values
missing values
rare states
</code></pre>
<h3 id="heading-are-meaningful-differences-classified">Are Meaningful Differences Classified?</h3>
<p>Avoid:</p>
<pre><code class="language-text">we have 47 unexplained mismatches
</code></pre>
<h3 id="heading-are-critical-differences-resolved">Are Critical Differences Resolved?</h3>
<p>Especially:</p>
<pre><code class="language-text">money
authorization
state transitions
data integrity
external contracts
idempotency
</code></pre>
<h3 id="heading-are-intentional-differences-documented">Are Intentional Differences Documented?</h3>
<p>If the new behavior intentionally differs, that should be explicit.</p>
<h3 id="heading-are-side-effects-equivalent">Are Side Effects Equivalent?</h3>
<p>Not only responses.</p>
<h3 id="heading-have-production-like-cases-been-tested">Have Production-like Cases Been Tested?</h3>
<p>Synthetic fixtures alone may not be enough.</p>
<h3 id="heading-can-the-migration-be-rolled-back">Can the Migration Be Rolled Back?</h3>
<p>Differential confidence reduces risk. It doesn't eliminate the need for rollback.</p>
<p>If you can answer these questions, you're much closer to a controlled cutover.</p>
<h2 id="heading-a-practical-differential-testing-workflow">A Practical Differential Testing Workflow</h2>
<p>Here's the workflow I would use.</p>
<h3 id="heading-1-pick-one-capability">1. Pick One Capability</h3>
<p>For example:</p>
<pre><code class="language-text">Process Order
Calculate Invoice
Approve Customer
</code></pre>
<p>Don't compare the whole platform at once.</p>
<h3 id="heading-2-define-the-observable-contract">2. Define the Observable Contract</h3>
<p>List what matters:</p>
<pre><code class="language-text">return value
status
error
database state
events
external calls
</code></pre>
<h3 id="heading-3-create-legacy-and-new-adapters">3. Create Legacy and New Adapters</h3>
<p>Expose both implementations through the same conceptual interface.</p>
<h3 id="heading-4-define-normalization-rules">4. Define Normalization Rules</h3>
<p>Decide how to handle:</p>
<pre><code class="language-text">timestamps
generated IDs
ordering
representation changes
optional values
</code></pre>
<p>Do this before looking at lots of failures. Otherwise you may weaken the comparator simply to make results pass.</p>
<h3 id="heading-5-compare-known-cases">5. Compare Known Cases</h3>
<p>Begin with:</p>
<pre><code class="language-text">existing tests
characterization cases
edge cases
historical bugs
</code></pre>
<h3 id="heading-6-capture-side-effects">6. Capture Side Effects</h3>
<p>Use recording or fake adapters where necessary.</p>
<h3 id="heading-7-automate-the-harness">7. Automate the Harness</h3>
<p>Produce structured output for every mismatch.</p>
<p>For example:</p>
<pre><code class="language-json">{
  "caseId": "case-493",
  "equivalent": false,
  "legacy": {},
  "migrated": {},
  "difference": {}
}
</code></pre>
<h3 id="heading-8-classify-differences">8. Classify Differences</h3>
<p>Use categories:</p>
<pre><code class="language-text">defect
intentional change
normalization issue
nondeterminism
unknown
</code></pre>
<h3 id="heading-9-add-representative-real-world-cases">9. Add Representative Real-World Cases</h3>
<p>Use anonymized or safely reconstructed production patterns.</p>
<h3 id="heading-10-shadow-real-traffic-when-appropriate">10. Shadow Real Traffic When Appropriate</h3>
<p>Only after controlling side effects and privacy risk.</p>
<h3 id="heading-11-measure-divergence">11. Measure Divergence</h3>
<p>Track both:</p>
<pre><code class="language-text">frequency
severity
</code></pre>
<h3 id="heading-12-resolve-unknowns-before-cutover">12. Resolve Unknowns Before Cutover</h3>
<p>The most dangerous category is often not:</p>
<pre><code class="language-text">different
</code></pre>
<p>It is:</p>
<pre><code class="language-text">different and nobody knows why
</code></pre>
<h2 id="heading-what-differential-testing-cant-prove">What Differential Testing Can't Prove</h2>
<p>Differential testing has an important limitation: it compares the new system against the old one.</p>
<p>That means the legacy system becomes a behavioral reference. But the legacy system may already be wrong.</p>
<p>Suppose:</p>
<pre><code class="language-text">legacy output = wrong
new output    = same wrong result
</code></pre>
<p>The differential test passes, but that doesn't make the behavior correct.</p>
<p>This is why differential testing should complement:</p>
<pre><code class="language-text">specification tests
characterization tests
business requirements
security testing
performance testing
contract testing
domain review
</code></pre>
<p>It answers:</p>
<blockquote>
<p>Did behavior change?</p>
</blockquote>
<p>It doesn't automatically answer:</p>
<blockquote>
<p>Is this the right behavior?</p>
</blockquote>
<p>That distinction matters. The legacy application is evidence, it's not absolute truth.</p>
<h2 id="heading-differential-testing-turns-migration-risk-into-evidence">Differential Testing Turns Migration Risk into Evidence</h2>
<p>There's another reason I like this technique. Without differential testing, migration discussions can become subjective.</p>
<p>One person says:</p>
<blockquote>
<p>The new implementation looks ready.</p>
</blockquote>
<p>Another says:</p>
<blockquote>
<p>I do not trust it yet.</p>
</blockquote>
<p>Both may have reasonable instincts, but neither statement is very measurable.</p>
<p>Differential testing changes the conversation.</p>
<p>Now you can say:</p>
<pre><code class="language-text">12,000 cases compared
47 differences found
31 representation differences
9 intentional behavior changes
6 migration defects fixed
1 unresolved
</code></pre>
<p>That is a much better engineering discussion. You're converting uncertainty into observable differences. Then you can decide what to do with them.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>A legacy migration is not complete because the new implementation passes its own tests.</p>
<p>The harder question is whether it preserves the behavior that matters from the system it is replacing.</p>
<p>Differential testing gives you another way to answer that question.</p>
<p>Run both implementations with the same inputs.</p>
<p>Compare outputs.</p>
<p>Compare errors.</p>
<p>Compare side effects.</p>
<p>Normalize only the differences that truly do not matter.</p>
<p>Investigate everything else.</p>
<p>And when possible, use representative production behavior to discover cases your test suite did not anticipate.</p>
<p>The migration sequence now becomes:</p>
<pre><code class="language-text">Understand
↓
Characterize
↓
Refactor
↓
Migrate
↓
Compare
↓
Cut over
</code></pre>
<p>AI can accelerate this process too.</p>
<p>It can help build comparators, analyze failures, group similar divergences, inspect code paths, and suggest additional test cases.</p>
<p>But it should not decide which implementation is correct.</p>
<p>That still requires evidence, domain knowledge, and engineering judgment.</p>
<p>The purpose of differential testing is not to eliminate uncertainty completely.</p>
<p>It is to make uncertainty visible <strong>before</strong> you switch production traffic.</p>
<p>Because during a migration, discovering that the new system behaves differently is useful.</p>
<p>Discovering it after the old system has been turned off is much more expensive.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
