Page loaded: Miguel Carino | Angular Front-End Architect

Photo by Shelby White on Unsplash

Migration

Two Frameworks, One Running App

The big-bang AngularJS rewrite was the plan nobody could afford — so I ran both frameworks in one app and migrated a route at a time until the old one was gone.

Miguel Carino
Focus
Front-end architecture, Angular, team leadership, and stakeholder communication
Experience
25+ years turning enterprise web complexity into maintainable products

The app was a large AngularJS front end the business ran on every day, and the plan on the table was to rewrite it. Not incrementally — wholesale, in modern Angular, over a quarter in which no new features would ship while the team rebuilt what already existed. I understood the appeal. A clean slate is a seductive thing when you're tired of working around an old one.

But the freeze was the part I couldn't make peace with. A quarter of no delivery, followed by a single day where everything switches over and every bug arrives at once, against a spec that kept moving the whole time it was being rebuilt. I'd seen where that road tends to end, and it's a branch nobody merges.

So I argued for the less dramatic option: don't rewrite the app, grow the new one inside it. Renovating a house you still live in doesn't start with a wrecking ball and a hotel booking for three months. You redo one room at a time, and you keep cooking dinner in the kitchen right up until the week you finally gut it. That was the shape I wanted for the code.

A hybrid app, migrated a room at a time

Angular has a supported way to run both frameworks in the same page at once — the ngUpgrade path, built around UpgradeModule from @angular/upgrade/static. It's still shipped and stable today, even though AngularJS itself went end-of-life at the end of 2021 — which is exactly why teams still on it need a graceful exit rather than a rewrite. You bootstrap Angular, then hand it the existing AngularJS app, and from that point both are live in the same document, sharing dependency injection and keeping their change detection in step. That shared runtime is what turns a cliff into a staircase.

With both frameworks running, the migration becomes a sequence of small, shippable moves. A leaf component — a widget, a form control, a card — gets rebuilt in Angular and dropped back into the still-AngularJS page through downgradeComponent, which makes an Angular component usable inside an AngularJS template. Services go the other way as needed, shared as single instances so migrated and legacy code never disagree about who's logged in or what's in the cart. You work upward from the leaves to whole routes, and each step is a normal release. Nothing freezes.

The honest catch is that for a while the app is genuinely two frameworks at once, and that has a cost — a larger bundle while both ship, and a boundary you have to think at. I owned that tradeoff rather than hiding it. I'd chosen to carry two frameworks for a few months instead of carrying all the risk on one day.

What the seam looks like, and what runs

The hybrid bootstrap is the whole trick, and it's smaller than people expect. You start Angular, then tell UpgradeModule to bootstrap the legacy AngularJS module onto the same document:

platformBrowserDynamic()
  .bootstrapModule(AppModule)
  .then(({ injector }) => {
    const upgrade = injector.get(UpgradeModule);
    upgrade.bootstrap(document.body, ['legacyApp']); // AngularJS module name
  });

A component rebuilt in Angular is registered as a downgraded AngularJS directive, so the old templates can keep using it by its selector:

angular
  .module('legacyApp')
  .directive('accountSummary', downgradeComponent({ component: AccountSummary }));

The consumer that renders it is an AngularJS template that hasn't been touched otherwise — it just uses the new element:

<!-- still an AngularJS view; this one tag is Angular -->
<account-summary account-id="vm.accountId"></account-summary>

And what you get, running in the browser, is one page where the framework boundary is invisible to the user:

one page, both frameworks live:
  page shell, routing, most views  → still AngularJS
  <account-summary>                → Angular component, downgraded
  auth/session service             → one shared instance, read by both
each release → a few more views cross from AngularJS to Angular

The user sees an account summary. They don't see that it's the first Angular component in a page that's otherwise the app they've always used. A few sprints later there are ten such components, then whole routes, and eventually the AngularJS module has nothing left to own — at which point you delete the upgrade layer and the second framework leaves with it.

I'll be square about when I would not do this. If the app splits cleanly into independent sections with little shared state, running the old and new apps as two separate deployments behind a reverse proxy — routing each path to the app that owns it — is simpler, because you skip framework interop entirely. It trades that simplicity for a duplicated shell, duplicated auth, and full reloads at the seams between old and new. For a single-page app with shared session and a shared chrome, the interop of a hybrid is the price of not tearing those in half. The big-bang rewrite remains the cleanest end state — it's the transition that's the gamble, and this is how I stopped gambling.

A Migration Is a Sequence of Shippable Steps, Not a Cliff

The rewrite everyone pictures is a single leap across a gap, with the whole app in your arms. The version that actually lands is a hundred small steps where the app is running and shippable the entire way across. ngUpgrade made that possible by letting both frameworks share one runtime, so modernization could happen in the daylight of normal releases instead of behind a months-long freeze. The old framework still ended up gone. It just left one room at a time, while the business kept living in the house.