Migrating a large AngularJS application to modern Angular sounds simple on paper:
Rewrite the old application, move everything to Angular, remove AngularJS, and ship it.
In a real production product, that's rarely an option.
Our application has been running for years, new features still need to be delivered, and users don't care that we're in the middle of a framework migration. They expect the product to keep working.
So instead of a Big-Bang rewrite, we chose a hybrid approach.
Today, our application runs AngularJS 1.8.3 and Angular 19 side by side in a single application.
It's not a temporary demo.
It's production.
And roughly half of the application has already moved to Angular.
This is what that migration actually looks like.
Where We Started
The legacy application is based on AngularJS 1.8.3.
The new application currently uses:
{
"@angular/core": "19.2.14"
}
Our target is Angular 19.
Not "the latest Angular eventually".
We deliberately chose a specific target version and are migrating toward it incrementally.
The repository is large enough that the migration can't be described as simply replacing controllers with components.
The rough picture today looks like this:
src/js
└── ~1000 legacy files
src/ng-app
└── ~1500 Angular TS/HTML files
The Angular side already contains around 20 feature areas.
At this point, Angular isn't just an experiment sitting next to the legacy application.
It's becoming the main platform.
But AngularJS is still very much alive.
Why We Didn't Rewrite Everything
The obvious alternative was to create a new Angular application and gradually move everything into it.
We decided against that.
The problem wasn't just the amount of code.
The application is a living product.
While we're migrating the framework, the product still needs:
- new features;
- bug fixes;
- customer requests;
- integrations;
- analytics;
- improvements to existing workflows.
Stopping development for a year and saying "we'll come back when the rewrite is finished" isn't realistic.
We needed the migration and product development to happen simultaneously.
That led us to one principle:
New functionality should be written in Angular, while legacy functionality can continue running until it is replaced.
This sounds obvious.
Making it work is not.
One Application, Two Frameworks
The most important architectural decision was to keep everything inside one hybrid application.
There is no iframe.
There are no separate deployments.
There isn't a second application running next to the old one.
It's one application with two frameworks sharing the same runtime.
The bridge is provided by Angular's @angular/upgrade package.
The simplified bootstrap process looks like this:
platformBrowserDynamic()
.bootstrapModule(AppModule)
.then(platformRef => {
const upgrade = platformRef.injector.get(UpgradeModule);
upgrade.bootstrap(
document.body,
['unSpotApp']
);
});
Angular starts first.
AngularJS is then bootstrapped through UpgradeModule.
This gives us a single application lifecycle while allowing both frameworks to coexist.
The Router Is Still AngularJS
This is one of the strangest parts of the migration.
We have Angular 19.
But our router is still UI-Router from the AngularJS side.
So the application doesn't have a clean boundary like:
Legacy Application
↓
New Application
Instead, it's more like:
UI-Router
│
┌──────────┴──────────┐
│ │
AngularJS Angular
components components
The router still knows about the application's states.
New Angular components are exposed to AngularJS using downgradeComponent().
For example, an Angular component can become something that the old router understands:
export const ungLogin = downgradeComponent({
component: LoginComponent,
});
And then the state can reference it:
{
name: 'login',
component: 'ungLogin'
}
From the router's perspective, it is just another component.
Underneath, it's Angular.
This allowed us to move entire pages to Angular without immediately replacing the routing infrastructure.
The Other Direction: Angular Calling AngularJS
Migration would be much easier if Angular only needed to consume old functionality once.
Unfortunately, real applications don't work that way.
Angular sometimes needs legacy services.
For example:
const state = injector.get('$state');
There are still AngularJS services that haven't been migrated yet:
-
$state; -
authService; - legacy image cropping functionality;
- various existing application services.
So Angular can temporarily access the old dependency injection system.
The important word here is temporarily.
AngularJS Can Also Use Angular
The dependency direction works both ways.
We have new Angular services and state that legacy code needs to access.
For example, the new application state is exposed back to AngularJS through downgradeInjectable().
Conceptually:
@Injectable({
providedIn: 'root',
})
export class AppState {
// ...
}
And then:
angular.module('unSpotApp')
.factory(
'ngAppState',
downgradeInjectable(AppState)
);
Now legacy code can use the new Angular service.
This creates an interesting situation:
Angular
│
│ downgradeInjectable
▼
AngularJS code
AngularJS
│
│ $injector
▼
Angular
Both directions are possible.
And that flexibility is what makes incremental migration possible.
It's also one of the things that makes hybrid applications surprisingly difficult to reason about.
Two DI Containers
One of the first things you learn in a hybrid application is that you effectively have two dependency injection worlds.
Angular has its own DI system.
AngularJS has $injector.
During migration, both have to coexist.
Sometimes Angular uses a legacy service.
Sometimes AngularJS consumes a new Angular service.
Sometimes both implementations temporarily exist.
That means dependency ownership becomes an architectural concern.
We have to constantly ask:
Which side owns this service now?
And more importantly:
Which side should own it after the migration?
This sounds like a small detail.
Across a large codebase, it isn't.
State: One Truth, Two Worlds
We introduced a new application state based on RxJS.
Angular consumes it directly.
But some of the initial information still originates in AngularJS.
For example, after authentication, the legacy application already knows about the company.
Instead of rewriting the entire authentication flow immediately, AngularJS pushes the information into the new state:
ngAppState.setCompanySettings(
$rootScope.company
);
The result is effectively:
Login
│
▼
AngularJS
│
│ company settings
▼
AppState
│
▼
Angular
This is one of the compromises we intentionally accepted.
For now, we have two worlds and a manually maintained bridge between them.
It's not beautiful.
But it works.
And more importantly, it lets us migrate the consumers before migrating the producer.
Two Translation Systems
Internationalization created another similar problem.
The legacy application uses:
angular-translate
The Angular application uses:
ngx-translate
So during migration, we have two translation systems running at the same time.
Again, the long-term goal is obvious.
We want one system.
But rewriting every translation dependency wasn't necessary to start migrating pages.
So we allowed the old system to remain where it was needed while new Angular features use the new implementation.
This is one of the recurring patterns throughout the migration:
Don't migrate infrastructure just because it is old. Migrate it when the application boundary requires it.
What Has Already Been Migrated
The Angular side is no longer a small part of the application.
Several production areas have already moved to Angular, including:
- login;
- analytics;
- employee schedules;
- visitors;
- requests;
- buildings;
- users;
- organizational structure;
- integrations;
- check-in;
- office settings;
- displays;
- services;
- parts of the administration area.
New features are also being developed in Angular.
At the same time, some important parts are still AngularJS:
- the application shell;
- header;
- employee map;
- parts of the administration area;
- office map legacy functionality;
- schedules;
- desks;
- meeting rooms;
- lockers;
- inbox;
- space and group administration.
So the migration isn't:
AngularJS → Angular
It's currently closer to:
AngularJS ───────────────┐
│
▼
Hybrid App
▲
│
Angular ────────────────┘
The balance is roughly 50/50.
Angular is no longer a side project.
But AngularJS is still the owner of some fundamental parts of the application.
The Biggest Problem: Living in the Hybrid
The hardest part of the migration hasn't been Angular 19 itself.
It's the hybrid architecture.
Every time we touch a feature, we have to understand which world we're working in.
Is the service Angular or AngularJS?
Is the state coming from the old application or the new one?
Which translation service should be used?
Which router API is available?
Can this component be converted directly?
Does the legacy code expect an AngularJS object?
Can Angular safely depend on this old service?
The framework migration becomes less about syntax and more about managing boundaries.
The Router Problem
We still have UI-Router from the AngularJS world.
There is even a LegacyRouterService in the new application with a TODO that basically says:
// TODO: remove after the new router is introduced
That comment has been around while the migration continues.
It's a good reminder that migrations are rarely linear.
Sometimes the cleanest architecture is not the architecture you build immediately.
It's the architecture that lets you move from the old architecture to the new one without stopping the business.
The TypeScript Problem
There is another interesting side effect of running a modern Angular application inside a large legacy build.
Our Angular 19 code currently lives in an environment where TypeScript 4.9 is still part of the build constraints because of the existing webpack setup and the old application's dependencies.
So we're effectively putting:
Angular 19
+
TypeScript 4.9
+
AngularJS 1.8.3
+
legacy webpack configuration
into one application.
It works.
But it makes you appreciate how many layers exist between a framework and the code you actually write.
The Interactive Map: Where the Two Worlds Meet
The office map is currently one of the largest remaining migration challenges.
We are rebuilding the administrative map editor using Angular and Fabric.js.
But the old employee map and some of the existing map infrastructure still belong to the legacy world.
That means the new implementation needs to understand data produced by the old engine.
One particularly painful example is coordinates and viewConfig.
If the new editor interprets the old coordinate system incorrectly, objects don't simply throw an error.
They move.
The desks are there.
The data is there.
Fabric.js is working.
But everything appears in the wrong place.
This is a particularly good example of why framework migration isn't just about rewriting components.
You are migrating assumptions.
Why We Don't Migrate Everything at Once
At this point, it might be tempting to say:
"We've already migrated half the application. Why not just finish it?"
Because the remaining half isn't necessarily the easy half.
Some of the oldest parts are also the most interconnected.
The office map, for example, touches:
- legacy data models;
- coordinates;
- rendering;
- permissions;
- routing;
- administration;
- employee-facing functionality.
Moving it isn't equivalent to moving a simple settings page.
The migration has to follow the actual dependency graph of the product, not an arbitrary percentage target.
Our Current Migration Strategy
The strategy has become relatively simple.
1. New features go to Angular
We don't create new AngularJS functionality unless there is a very good reason.
This prevents the legacy side from growing again.
2. Migrate complete feature boundaries
Whenever possible, we move an entire user-facing area instead of converting random components one by one.
This reduces the amount of hybrid code.
3. Keep bridges temporary
downgradeComponent, downgradeInjectable, $injector, and other compatibility mechanisms are tools for migration.
They aren't the architecture we want to keep forever.
4. Don't migrate unrelated infrastructure
If a legacy service still works and isn't blocking the current migration, we leave it alone.
5. Remove old code only after the new implementation is stable
The goal isn't to have two implementations forever.
The goal is to create a safe path from one implementation to another.
What We Would Do Differently
If we were starting this migration today, I would pay more attention to the boundaries before writing the first Angular component.
In particular:
- define ownership of shared state earlier;
- establish clearer rules for AngularJS/Angular service dependencies;
- document which side owns each piece of infrastructure;
- define a migration strategy for routing earlier;
- identify coordinate systems and data contracts before touching UI code.
The hardest problems weren't caused by Angular.
They were caused by unclear boundaries between the old and new systems.
The Most Important Lesson
A framework migration is often described as a technical rewrite.
In practice, it is closer to changing the architecture of a running system without stopping the system.
That's a different problem.
You don't get a clean slate.
You have users.
You have production data.
You have business requirements.
You have old code that still works.
And you have new code that needs to coexist with it.
The most useful mindset we found was this:
Don't try to make the old system disappear first. Make the new system capable of replacing it piece by piece.
Once that becomes the goal, the migration becomes much more manageable.
Where We Are Now
We are approximately halfway through the migration.
Angular 19 is already responsible for a significant part of the production application.
New functionality is being built on Angular.
Legacy functionality is gradually being replaced.
The AngularJS application is still responsible for the bootstrap process and routing, so we aren't at the finish line yet.
But the important part is that the direction has changed.
AngularJS is no longer where the product grows.
Angular is.
And that's probably the most meaningful milestone of the migration so far.
Final Thoughts
A Big-Bang rewrite gives you the cleanest architecture on paper.
A hybrid migration gives you something more valuable in a production product:
a way to improve the architecture without stopping the product.
It isn't always elegant.
Sometimes you'll have two dependency injection systems.
Two translation libraries.
An old router controlling new components.
A legacy service being injected into modern Angular code.
And a TODO saying:
// TODO: remove after migration
that survives much longer than anyone expected.
But if each new feature moves the system a little further toward the target architecture, eventually the old framework becomes the smaller part of the application.
And one day, you can finally remove it.
We're not there yet.
But we're getting closer.
Top comments (0)