Skip to main content

The "Shell Strategy": Migrating AngularJS to Angular Without a Rewrite

 The "Big Bang" rewrite is the single greatest risk to enterprise software stability. Rewriting a massive AngularJS (v1.x) monolith from scratch pauses feature development for months (or years), destroys institutional knowledge buried in legacy logic, and almost invariably results in a product that has fewer features and more bugs than the original.

The alternative is the Shell Strategy (an implementation of the Strangler Fig pattern). Instead of replacing the application, you wrap the legacy AngularJS application inside a modern Angular "Shell." Both frameworks run simultaneously in the same browser tab, sharing state and adhering to a unified routing strategy.

This allows you to migrate one route or component at a time while shipping new features in modern Angular immediately.

The Architectural Conflict: Digest Cycles vs. Zones

The fundamental difficulty in running AngularJS and Angular side-by-side lies in their change detection mechanisms and routing arbitration.

  1. Change Detection Desync: AngularJS relies on the $digest cycle (triggered by $scope.$apply()). Modern Angular relies on Zone.js to monkey-patch async events. If these two aren't bridged, an update in an Angular component won't update an AngularJS directive, and vice-versa.
  2. Routing Collision: Single Page Applications (SPAs) rely on the browser's History API. If you load ui-router (AngularJS) and the Angular Router simultaneously, they will race to handle URL changes. Without intervention, both routers will try to resolve the same URL, leading to 404s or infinite redirection loops.

To solve this, we must establish the Modern Angular app as the "host," manually bootstrap AngularJS inside it, and implement a UrlHandlingStrategy to partition traffic.

The Fix: Implementing the Hybrid Shell

We will convert a standard AngularJS application into a Hybrid application.

Step 1: Establish the Shared State (The "Bridge")

Before handling routing, we need a mechanism to share data between frameworks. We create the service in modern Angular (the source of truth) and "downgrade" it for AngularJS consumption.

user-session.service.ts (Modern Angular)

import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

export interface UserProfile {
  id: string;
  name: string;
  role: 'admin' | 'user';
}

@Injectable({
  providedIn: 'root'
})
export class UserSessionService {
  // BehaviorSubject ensures legacy code gets the current value immediately upon subscription
  private userState = new BehaviorSubject<UserProfile | null>(null);
  
  public user$ = this.userState.asObservable();

  constructor() {
    // Simulate restoring session
    this.userState.next({ id: '101', name: 'Legacy Architect', role: 'admin' });
  }

  updateUser(newUser: UserProfile): void {
    this.userState.next(newUser);
  }
}

Step 2: The Routing Arbiter

We need to tell the Angular Router specifically which URLs it owns. Everything else falls through to AngularJS. We do this by implementing a custom UrlHandlingStrategy.

legacy-handling.strategy.ts

import { UrlHandlingStrategy } from '@angular/router';

export class LegacyHandlingStrategy implements UrlHandlingStrategy {
  /**
   * Defines which routes Modern Angular handles.
   * In this example, Angular handles the login page and the new dashboard.
   * All other routes fall through to AngularJS.
   */
  shouldProcessUrl(url: any): boolean {
    const urlStr = url.toString();
    return urlStr.startsWith('/login') || urlStr.startsWith('/new-dashboard');
  }

  /**
   * If Angular handles the URL, we process the whole thing.
   */
  extract(url: any): any {
    return url;
  }

  /**
   * If Angular handles the URL, we merge nothing specific back.
   */
  merge(newUrlPart: any, rawUrl: any): any {
    return newUrlPart;
  }
}

Step 3: Bootstrapping the Hybrid Shell

We use UpgradeModule from @angular/upgrade/static to bootstrap AngularJS inside the Angular ngDoBootstrap hook. This ensures Zone.js is loaded first and the bridges are established.

app.module.ts

import { NgModule, DoBootstrap, ApplicationRef } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { UpgradeModule, downgradeInjectable } from '@angular/upgrade/static';
import { RouterModule } from '@angular/router';

import { UserSessionService } from './user-session.service';
import { LegacyHandlingStrategy } from './legacy-handling.strategy';
import { UrlHandlingStrategy } from '@angular/router';
import { AppComponent } from './app.component';
import { NewDashboardComponent } from './new-dashboard.component';

// 1. Reference the legacy AngularJS module (ensure this file is imported so the global variable exists)
import './legacy-app/app.module.js'; 
declare const angular: any;

// 2. Register the Modern Angular Service as an AngularJS Factory
angular.module('legacyApp')
  .factory('userSessionService', downgradeInjectable(UserSessionService));

@NgModule({
  declarations: [
    AppComponent,
    NewDashboardComponent
  ],
  imports: [
    BrowserModule,
    UpgradeModule,
    // Define Modern Routes
    RouterModule.forRoot([
      { path: 'new-dashboard', component: NewDashboardComponent },
      // Note: No wildcard (**) route here yet, or it will swallow legacy routes!
    ])
  ],
  providers: [
    UserSessionService,
    // 3. Provide the custom strategy
    { provide: UrlHandlingStrategy, useClass: LegacyHandlingStrategy }
  ]
})
export class AppModule implements DoBootstrap {
  constructor(private upgrade: UpgradeModule) {}

  ngDoBootstrap(appRef: ApplicationRef) {
    // 4. Bootstrap the Modern Angular Component (The Shell)
    appRef.bootstrap(AppComponent);

    // 5. Bootstrap the Legacy AngularJS App
    // This effectively replaces angular.bootstrap(document, ['legacyApp'])
    this.upgrade.bootstrap(document.body, ['legacyApp'], { strictDi: true });
  }
}

Step 4: Consuming Modern State in Legacy Code

Now, inside the legacy AngularJS controllers, we inject the modern service just like a standard Angular 1.x factory.

legacy-dashboard.controller.js

angular.module('legacyApp').controller('LegacyDashboardCtrl', [
  '$scope', 
  'userSessionService', // This is the downgraded Typescript service
  function($scope, userSessionService) {
    
    // Subscribe to the RxJS observable
    // We must manually manage the subscription in legacy code
    const subscription = userSessionService.user$.subscribe(user => {
      // Force a digest cycle because RxJS runs outside the AngularJS digest loop
      $scope.$applyAsync(() => {
        $scope.currentUser = user;
      });
    });

    // Cleanup to prevent memory leaks
    $scope.$on('$destroy', () => {
      subscription.unsubscribe();
    });
  }
]);

Why This Works

The UpgradeModule Bridge

The UpgradeModule is not just a loader; it is a synchronization engine. When you bootstrap via upgrade.bootstrap, Angular creates a root ng1 module. Crucially, it sets up hooks so that:

  1. AngularJS $rootScope.$digest is triggered whenever the Angular Zone becomes stable.
  2. Angular Zone runs whenever AngularJS executes specific async operations (like $timeout or $http). This bidirectional syncing ensures that if you update the UserSessionService in a modern component, the legacy controller's subscription fires, and the UI updates without manual refresh.

Traffic Partitioning

The UrlHandlingStrategy is the firewall. Without it, the Angular Router attempts to match /legacy/settings against its route configuration, fails to find a match, and throws an error (or hits a wildcard 404). By implementing shouldProcessUrl, we force the Angular Router to "stand down" for specific URL patterns, allowing the ui-router or ngRoute listening on the same window object to pick up the hash change or push state event.

Conclusion

The Shell Strategy transforms a migration from a cliff-edge rewrite into a manageable, iterative refactor. By establishing a modern Angular shell, creating a unified UrlHandlingStrategy, and sharing state via downgradeInjectable, you decouple the migration timeline from feature delivery. You can now build new features in modern Angular today, while the legacy code continues to function seamlessly until you are ready to retire it.