Dev.to WebDev 🛠 Dev 👁 0 📖 19 min read

Strangler Fig, Branch by Abstraction, Vertical Slices: Choosing a Frontend Migration Strategy

Strangler fig, branch by abstraction, vertical slicing: the strategy decides whether a frontend migration ships, more than the framework you move to. Here's how each one works, where each one bites, and how we combine th

Strangler Fig, Branch by Abstraction, Vertical Slices: Choosing a Frontend Migration Strategy

Strangler fig, branch by abstraction, vertical slicing: the strategy decides whether a frontend migration ships, more than the framework you move to. Here's how each one works, where each one bites, and how we combine them on real projects.

Teams that plan a frontend migration spend weeks on the target: React or Vue, Next.js or Vite, Redux or TanStack Query. Then they spend almost no time on the part that decides whether it ships: how the old app and the new app will live together until the old one is gone.

That part is the migration strategy. Pick the wrong one and a good framework choice still turns into an 18-month rewrite branch that never merges. Pick the right one and even a messy legacy app moves over a piece at a time, with users never noticing a launch day.

This post covers the strategies we actually use: the big-bang rewrite (as the baseline), the strangler fig, branch by abstraction, and vertical slicing, plus the supporting tactics that make them safe: parallel runs, feature flags, micro-frontends, anti-corruption layers and expand-contract. Then it shows how they fit together, because on a real project you rarely use just one. Our framework-specific guides (Angular, Ember, Backbone, jQuery, vanilla JS) go deeper on each starting point. This one is about the strategy underneath all of them.

The Short Version

Strategy Unit of change What runs side by side Rollback Best for
Big-bang rewrite The whole app Nothing in production until launch day Hard: the old app has gone stale Small or frozen apps
Strangler fig A route or a screen Old and new apps behind one router Point the route back at the old app Apps with clear routes or pages
Branch by abstraction A shared dependency behind a seam Old and new implementations behind one interface Flip the switch back Services, stores and widgets used everywhere
Vertical slicing One user-facing feature, top to bottom Migrated and unmigrated features Per slice Choosing the order of work

The first row is the one people start with. The other three are what actually works, and they answer different questions. Strangler fig decides where the line between old and new runs. Branch by abstraction handles the things that cross that line. Vertical slicing decides what to move next.

Four strategies side by side. Big-bang: the old app runs until a single cut-over day. Strangler fig: a router sends some routes to the new app and the rest to the old one. Branch by abstraction: callers talk to an interface with an old and a new implementation behind it. Vertical slicing: one feature is moved through UI, state, API and tests at once

Big-Bang Rewrite: the Baseline

The big-bang rewrite is the default plan nobody writes down: start a new repo, rebuild everything, switch over on a chosen day. It's worth understanding properly, because every other strategy exists to avoid its failure modes.

When it is actually right

  • The app is small. A few screens that one or two people can rebuild in a few weeks. The overhead of running two apps side by side costs more than the rewrite.
  • The app is frozen. No new features are planned, so the old app won't move while you rebuild it.
  • The old app can't host anything new. Some legacy stacks make it so painful to mount new code that coexistence costs more than starting clean. This is rarer than it seems. Almost anything can serve an iframe or hand off a route.

Why it usually fails

  • The target keeps moving. The business doesn't stop for your rewrite. Every feature shipped in the old app is one more thing the new one has to catch up on, and the gap rarely closes.
  • Nothing is proven until the end. Months of work with zero real users means every wrong assumption surfaces on launch day, all at once.
  • The old app holds knowledge nobody wrote down. Ten years of bug fixes are hidden in it. Joel Spolsky called rewriting from scratch the single worst strategic mistake a software company can make, for exactly this reason.
  • Rollback is fiction. By launch day the old app is months behind. "Roll back" means going back to software everyone has stopped maintaining.

The incremental strategies below all share one idea: get the new code in front of real users early, in small pieces, while the old app keeps running.

Strangler Fig

Martin Fowler named this pattern after a vine that grows around a host tree until the tree is gone and the vine stands on its own. In a frontend migration, the vine is the new app and the tree is the old one.

How it works

  1. Put something in front of both apps. A reverse proxy, a CDN rule, or a thin shell app that owns the router. Every request goes through it.
  2. Send every route to the old app at first. Nothing has changed for users, but the switch now exists.
  3. Move one route. Build it in the new app and flip that one path in the router.
  4. Repeat until the old app serves nothing. Then switch it off.

The routing layer can be as simple as a list of migrated paths:

// shell/router.ts: which app owns which path
const MIGRATED = ['/settings', '/profile/:username', '/article/:slug'];

export function ownerOf(path: string): 'react' | 'legacy' {
  return MIGRATED.some((p) => matchPath(p, path)) ? 'react' : 'legacy';
}

In production this is often a CDN or proxy rule instead of code, and each route can sit behind a feature flag so you can roll it out to 5% of users first.

Route-level or widget-level

There are two grains to strangle at:

  • Route-level. Whole pages move at once. It's the cleanest option: each page is owned by one framework, and the boundary is a URL. Start here if your app has real routes.
  • Widget-level. New components are mounted inside old pages (a React date picker inside an Ember template, for example). This is how you migrate apps with one giant page, or a shared header that appears everywhere. It's more fragile, because the two frameworks now share a DOM.

Most projects use both: widget-level for the shared shell (nav, header, notifications), route-level for everything else.

Where it shines

  • Real users see real progress within weeks. Every migrated route is in production.
  • Rollback is one line. Point the route back at the old app.
  • Feature work never stops. New features go straight into the new app.
  • The end is visible. "34 of 52 routes migrated" is a progress bar a manager can read.

Where it costs you

  • Two runtimes at once. Users may download both frameworks for a while. Keep the old bundle off migrated pages where you can.
  • Shared state across the boundary. Login, the current user, feature flags and unsaved drafts have to be readable by both apps. Put them somewhere neutral: a cookie, localStorage, or the API.
  • CSS bleed. Global styles from one app leak into the other. Scope the new app's styles (CSS Modules, a prefix, or Shadow DOM) before you mount anything.
  • Navigation between apps. Crossing from a React page to an Ember page is a full page load unless the shell handles it. Users notice this, so group related routes and move them together.

Gotchas we hit

  1. The router is the hard part, not the pages. Agree on how deep links, redirects, 404s and query strings behave across both apps before moving a single route.
  2. Auth needs to move first. If the new app can't read the old app's session, every migrated page looks logged out. A shared cookie is the usual fix.
  3. Don't strangle by file. Moving "all the components in /shared" isn't strangling, it's a horizontal layer (see vertical slicing below). Strangle by what users see.
  4. Delete as you go. Once a route is moved and stable, remove its old code. Otherwise the old app never shrinks and you end up maintaining both forever.

The Angular guide walks through a strangler fig migration end to end, including the shell app and the staged rollout.

Branch by Abstraction

Strangler fig works when the thing you're replacing has a clear boundary, like a URL. But some parts of a frontend don't have one. The data layer, the auth service, the API client, the store, a date library: they're used from every page. You can't route around them. Branch by abstraction is the pattern for those.

The name comes from Paul Hammant and is described by Fowler and trunkbaseddevelopment.com. The idea: instead of making a long-lived version-control branch to replace something, you make the "branch" in the code itself, behind an interface.

How it works

  1. Add a seam. Write an interface that describes what callers need. Make the old implementation fit it.
  2. Move callers onto the seam. One at a time, change code that talks to the old thing directly so it talks to the interface instead. Ship each change. Nothing behaves differently yet.
  3. Build the new implementation behind the same interface, in the main branch, switched off.
  4. Switch over. A flag picks the new implementation, first for internal users, then for everyone.
  5. Delete the old implementation, and the flag. If the seam isn't useful anymore, delete it too.

Branch by abstraction in four steps. One: callers use the old implementation directly. Two: callers use an interface, still backed by the old implementation. Three: a new implementation is built behind the same interface and a flag picks between them. Four: the old implementation and flag are deleted

Here's what that looks like for a data layer. Say pages load articles through an Ember Data store or Backbone models, and you're replacing that with a plain API client and TanStack Query. The seam is a small repository interface:

// articles/repository.ts: the seam
export interface ArticleRepository {
  get(slug: string): Promise<Article>;
  list(filter: ArticleFilter): Promise<Article[]>;
  save(input: ArticleInput): Promise<Article>;
}

// old: wraps the legacy store, converting its records to plain objects
export const legacyArticles: ArticleRepository = {
  get: async (slug) => toArticle(await store.queryRecord('article', { slug })),
  list: async (filter) => (await store.query('article', filter)).map(toArticle),
  save: async (input) => toArticle(await store.createRecord('article', input).save()),
};

// new: talks to the API directly
export const apiArticles: ArticleRepository = {
  get: (slug) => api.get(`/articles/${slug}`).then((r) => r.article),
  list: (filter) => api.get('/articles', filter).then((r) => r.articles),
  save: (input) => api.post('/articles', { article: input }).then((r) => r.article),
};

export const articles: ArticleRepository =
  flags.isOn('articles-api') ? apiArticles : legacyArticles;

Two details make this work. First, the interface speaks plain objects, not framework records. If callers receive Ember Data models, they'll keep calling .get('title') and you haven't built a seam, just a wrapper. Second, both implementations live in the main branch and are tested in CI. The "branch" is a flag, not a Git branch.

Where you'll use it in a frontend

  • The data layer. Ember Data, Backbone collections, Angular services with their own caches → a query library. This is the most common use.
  • Auth and session. One session interface that both the old and new apps call, so login works on both sides of the strangler boundary.
  • A shared component. The date picker, the rich text editor, the data grid. Wrap it in one component API, then swap the implementation underneath.
  • A library replacement that isn't a framework change at all: moment.js → date-fns, a charting library, an HTTP client.

Why not just use a long-lived branch?

Because a migration branch that lives for months is a big-bang rewrite in disguise. It drifts from main every day, the merge at the end is huge, and nothing in it has met a real user. Branch by abstraction keeps every step small and merged. It pairs naturally with trunk-based development and with feature toggles.

The cost: extra code for a while (the interface, two implementations and a flag), and the discipline to actually do step five. Seams and flags that outlive the migration become their own kind of legacy.

Vertical Slicing

Strangler fig and branch by abstraction tell you how to keep old and new running together. Vertical slicing tells you what to move next.

A vertical slice is one user-facing feature moved top to bottom: its UI, its state, its data access, its routes and its tests, all together, until a user can do that thing entirely in the new code. The opposite is a horizontal layer: "first we'll port all the models, then all the services, then all the components".

Grid of features (login, feed, article, editor, settings) against layers (UI, state, data access, tests). A horizontal plan fills one whole layer at a time and no feature is finished until the end. A vertical plan fills one whole feature column at a time, and each finished column ships

Why horizontal layers fail

  • Nothing ships until the last layer is done. Porting every model first gives you months of code with no user, which is the big-bang problem again.
  • You guess at requirements. Without a real screen using it, a ported service is shaped by what the old code did, not what the new UI needs.
  • Progress is invisible. "80% of the services are done" means nothing to the people paying for it.

Choosing the first slice

The first slice is mostly about proving the plumbing: routing between apps, shared auth, the build and deploy, the test setup. So pick one that is:

  • Real, but low-risk. A settings page or a profile page, not checkout.
  • Light on shared dependencies. It shouldn't need half the app's services to render.
  • Easy to compare. It has clear behavior you can test old against new.
  • Used often enough that you'll hear quickly if something's wrong.

After the first one, order the rest by value against coupling. Move high-value, loosely coupled features early. Leave the tangled core, the screen that touches everything, until the seams around it already exist.

How slices map onto the other strategies

In a well-run migration, a vertical slice usually is a strangler fig step: a feature is a set of routes, and moving the feature means flipping those routes. When a slice hits a dependency it shares with unmigrated features (the article store used by both the feed and the editor), that's where a branch-by-abstraction seam goes in.

Supporting Tactics

These aren't migration strategies on their own. They're tools that make the three above safe.

Parallel run and dark launching

Run the new code alongside the old one in production, compare the results, but only show the old one. GitHub's Scientist library made this famous for backend code. In a frontend it's useful for a new data layer or a new calculation (pricing, permissions, validation): call both implementations behind the seam, log any differences, return the old result. Once the differences hit zero, switch. Dark launching is the same idea for whole features: ship them to production hidden, and turn them on later.

// compare old and new behind the seam; users still get the old result
async function get(slug: string) {
  const [oldResult, newResult] = await Promise.allSettled([
    legacyArticles.get(slug),
    apiArticles.get(slug),
  ]);
  reportDiff('articles.get', oldResult, newResult);
  if (oldResult.status === 'rejected') throw oldResult.reason;
  return oldResult.value;
}

Watch the cost: a parallel run doubles API calls. Sample a percentage of traffic, and never run both sides of a write.

Feature flags and canary releases

Every switch in this post (a migrated route, a new implementation behind a seam) should be a flag you can flip without a deploy. That lets you release to staff first, then 5% of users, then everyone, and roll back in seconds. Name each flag after the migration step and give it an expiry date. The feature toggles article on Fowler's site is the best guide to keeping them under control.

Micro-frontends as a way to coexist

Micro-frontends (module federation, single-spa, web components) let several apps share one page. They're a useful vehicle for widget-level strangling: the old app becomes one micro-frontend, the new one another. But think twice before making them the destination. If the goal is one React app, use the micro-frontend setup as scaffolding and take it down when the migration ends. Otherwise you've swapped one legacy app for a distributed system.

Anti-corruption layer

An anti-corruption layer translates between the old model and the new one so the old model's quirks don't leak into new code. The toArticle() function in the branch-by-abstraction example is a tiny one: it turns a legacy record (with its computed properties, observers and odd field names) into a plain object the new code can trust. Keep these at the boundary and delete them when the old side is gone.

Expand and contract

Also called parallel change. When old and new code share data (an API response, a localStorage key, a URL format, a cookie), you can't change its shape in one go. Expand first: support both the old and new shapes. Migrate each reader and writer. Contract: remove the old shape once nothing uses it. It's how you rename a query parameter or restructure saved user preferences without breaking the half of the app that hasn't moved yet.

Combining Them: the Playbook We Use

On a real project these aren't alternatives. They are layers of one plan:

Question Answered by
Where does the line between old and new run? Strangler fig (routes, then widgets)
What do we move next? Vertical slicing (value against coupling)
What about things every page depends on? Branch by abstraction (a seam, two implementations, a flag)
How do we know the new one behaves the same? Parallel run and shared tests
How do we release and roll back? Feature flags and canaries
How do we change shared data safely? Expand and contract

Timeline of a combined migration in four phases. Foundation: shell router, shared auth, seams around the data layer. First slice: one low-risk feature moved end to end. Steady state: slices move route by route behind flags while seams switch to new implementations. Removal: the old app, the seams and the flags are deleted

In phases:

  1. Foundation. Put the shell or proxy in front of the old app, with every route still pointing at it. Make auth work on both sides. Set up one test suite that can run against either app. Add seams around the data layer and other shared services. Nothing changes for users.
  2. First slice. Move one low-risk feature end to end. Its real job is to prove the plumbing: routing, auth, deploys, monitoring, rollback.
  3. Steady state. Move slices in order of value against coupling. Each slice is a set of routes flipped behind a flag. Switch seams to their new implementations as soon as their callers allow it, with a parallel run where correctness matters. Ship new features only in the new app.
  4. Removal. When the old app serves no routes, switch it off. Then delete the seams you no longer need, the anti-corruption layers and every migration flag. A migration isn't done until the scaffolding is gone.

Choosing a Strategy

If you're deciding where to start, these questions settle most of it:

If your app… Lean towards
Is a handful of screens, or is frozen Big-bang rewrite. Coexistence isn't worth it.
Has clear routes and pages Strangler fig at route level
Is one giant page, or has a shared shell everywhere Strangler fig at widget level, often with a micro-frontend host
Has a data layer, store or service every page uses Branch by abstraction around it, early
Has few tests Write tests at the user level (end-to-end) first, then any strategy. Without them, nothing tells you a slice is done.
Has many teams shipping in parallel Vertical slices owned by one team each, behind flags
Must keep shipping features during the migration Anything except big-bang

Measuring Progress and Knowing When You're Done

Incremental migrations have a known failure mode: they stall at 80%. The easy routes are done, the hard core remains, and the team gets pulled onto feature work. You end up running two frameworks forever. Measuring progress openly is the best defense:

  • Routes served by the new app, as a percentage of traffic, not just a count. Ten rarely used admin pages don't equal one home feed.
  • Lines of legacy code left, falling every week. If it stops falling, something is wrong.
  • Seams and flags still open. Each one is temporary and should have an owner and an end date.
  • Parity results. For each slice: do the same tests pass against old and new, and does the parallel run show zero differences?

And a short definition of done that every slice has to meet before its old code is deleted: same tests passing on both, flag at 100% for a full release cycle with no rollbacks, no remaining imports of the old code, and the old code removed in the same sprint.

Big-bang rewrites fail on a deadline. Incremental migrations fail on discipline. Pick the failure you can manage.

Bottom line:

  1. Get new code in front of real users early. Every strategy that works is built on this.
  2. Strangle by what users see, route by route, not file by file.
  3. Put a seam around anything every page depends on, and switch it with a flag, not a merge.
  4. Move whole features, not layers. A finished slice ships. A finished layer doesn't.
  5. Delete as you go, including the scaffolding. The migration ends when the seams and flags are gone, not when the last route moves.

Further in This Series

Each of these takes the strategies above and applies them to one starting point or one design decision:

Our Migration Plugins

Choosing a strategy starts with knowing the app: every route, every shared service, every piece of hidden wiring. That map is slow to build by hand. We've packaged how we do it as free, open-source plugins for Claude Code, one per starting point. Each one plans an incremental, strangler-fig migration by default:

Plugin Starts from
ember-to-react Ember Classic and Octane
angular-to-react Angular 2+ and AngularJS 1.x, including hybrid ngUpgrade apps
backbone-to-react Backbone.js, Marionette, jQuery templates
jquery-to-react jQuery apps, plugins and jQuery UI
vanillajs-to-react Plain HTML, CSS and JavaScript

Each plugin comes with a migration skill, a /<plugin>:plan command that writes a migration plan (strategy, units, route map, test plan and risks) without touching your code, a read-only inventory agent that maps the old app, a parity-reviewer agent that compares each migrated piece with its original, and a bundled Context7 server for up-to-date library docs. To install one in Claude Code:

/plugin marketplace add cobuild-tech/cbx-plugins
/plugin install ember-to-react@cbx-plugins

They also work in Cursor, in VS Code with GitHub Copilot, and in the GitHub Copilot CLI. Progress is kept in a .migration/ folder in your repo, so a migration can carry on across sessions and teammates. The source and setup steps are at cobuild-tech/cbx-plugins, and issues are welcome.

About MigrateX

MigrateX

MigrateX is our platform and service for moving software, data and infrastructure from one technology to another. It combines automation with experienced engineers. The automation does the repetitive work quickly, and the engineers make the tricky decisions, like where the strangler boundary runs and which shared services need a seam first.

A MigrateX project usually runs like this:

  1. Assessment. We map every route, shared service and hidden dependency in your app, so you see where the work is before anyone agrees on a timeline.
  2. Plan. With your team, we choose the strategy, the slice order, and what can simply be deleted.
  3. Migration, step by step. Automation handles the repetitive parts. Our engineers handle the router, the seams and the code that connects old and new.
  4. Proof that it works. The same tests run against both versions. A piece only counts as moved when it passes on both.
  5. Handover. We remove the temporary code, the seams and the flags, and leave your team with a codebase they understand and own.

Your app keeps running and shipping features the whole time, so there's no big launch day. Our migratex-examples repo has small apps in each legacy framework next to their migrated versions.

Planning a migration and not sure which strategy fits? Tell us a little about your app through Contact Us, and we'll start with a free chat about where the work is likely to be.

References

Further reading

Source code

Originally published on the CobuildX blog.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.