How to Migrate a Legacy Monolith Incrementally Without a Big-Bang Rewrite — Opportunihub
Course Remote

How to Migrate a Legacy Monolith Incrementally Without a Big-Bang Rewrite

Hugo Teijiz · Remote

At a glance

Type
Course
Organisation
Hugo Teijiz
Location
Remote
Work mode
Remote
Deadline
Rolling / not stated
Posted
18 Sep 2026

About this course

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

How to apply

  1. 1 Read the full details above and confirm you meet the eligibility criteria.
  2. 2 Prepare your documents — an updated CV, and any cover letter, proposal or certificates required.
  3. 3 Click Apply on official site to complete your application on Hugo Teijiz’s official page.
  4. 4 Submit as early as possible — many close once filled.
Apply on official site

Sourced from freecodecamp. Always verify details on the official website. Opportunihub never charges you to apply.

Frequently asked questions

How do I apply for How to Migrate a Legacy Monolith Incrementally Without a Big-Bang Rewrite?

Review the full details and eligibility on this page, prepare your documents, then use the “Apply on official site” button to complete your application on Hugo Teijiz’s official page.

Is this opportunity remote or location-based?

This opportunity is remote-friendly and open to applicants who can work from anywhere.

Is How to Migrate a Legacy Monolith Incrementally Without a Big-Bang Rewrite free to apply for?

Opportunihub lists this Course for free. Legitimate Courses do not ask for payment to apply — never pay a fee to submit an application.