Why We Moved to Nx and How We Executed the Migration
Last year, we completed one of the most significant restructuring efforts our team has undertaken: migrating from Angular CLI to Nx. This piece outlines the rationale behind that decision and the steps we took to get there.
Our Pain Points
Code Sharing: Reusable code was scattered across multiple applications, with the bulk living inside our primary app. As we added more shared logic, the main application grew unwieldy.
Refactoring: Performance optimization meant constant refactoring, but the existing structure made it hard to know what to touch or where new features should go.
Build Time: Builds were slow, consuming valuable time after every PR or MR. Longer builds meant more time stuck on a single task and fewer features shipped per release cycle.
Feature Development: Adding new functionality to an already bloated app became increasingly difficult.
Code Review: With all code housed in one monolith, assigning code owners to different sections was impractical.
These challenges pointed clearly toward NxDevTools as the right solution for us, so we decided to move forward with it.
Why We Chose to Make the Switch
Switching from Angular CLI to Nx was a major call. Prior to the migration, we had one primary project built with Angular CLI and several smaller applications crammed into the same workspace. The codebase was essentially one massive block of code in a single repository, which would have made future growth and optimization nearly impossible if we stayed put.
When I came onboard, the team was already planning to address performance issues, which meant a lot of refactoring work was on the horizon. That made Nx even more attractive.
What Nx Offers
Nx is a DevTools solution for managing monorepos. The key advantage of a monorepo is the ability to create and manage multiple applications within a single workspace and share libraries across them.
But Nx goes beyond that. It provides a devkit for writing custom generators and builders/executors, giving you fine-grained control over your tooling. It also caches builds, so unchanged code isn't recompiled on every run. And when you need caching in CI, Nx Cloud is an excellent option to consider.
Concerns Before We Started
Before migrating, we needed to decide which parts of the app should become separate libraries. Our initial plan was:
We didn't want to break everything at once. For the first pass, we identified a large folder called
common/legacycontaining most of the reusable code and planned to extract it into a new library.However, after moving that legacy folder into a new lib, we encountered a new problem: the bundle size grew exponentially. That approach was a dead end.
We went back to the drawing board and discussed our options. Two ideas emerged:
-
Secondary entrypoints: I had experience with these in the past, and under normal circumstances, I'd recommend them.
- This is usually the right choice, as it keeps imports granular and bundles lean.
- But the volume of code we needed to move was enormous.
- Given our team was only three people, with just me working full-time on this, moving everything through secondary entrypoints could have taken over a year.
-
Wild card paths: To reduce complexity, we went with another route: using wildcard mappings in
tsconfig.base.json, like so:"@domain/common-legacy/*": ["libs/common/legacy/src/lib/*"]- This allowed us to import only what was needed
- But it came with its own set of challenges
Details of the Approach
We split the migration into three distinct phases:
- Move
common/legacyand resolve any issues that arise. - Once that succeeds, move the remaining code.
- Tackle circular dependencies.
Immediate Steps in the First Phase
- Skip creating secondary entrypoints for now; instead, use folders for each
component/module/service/and import directly from them.
import { HomeModule } from '@domain-common-legacy/home.module'
Since we import just what we need, the entire library doesn't end up in the final bundle, keeping bundle sizes in check. For any new code moved, we just need to set the paths correctly.
A downside was that these libraries weren't buildable, but that wasn't a requirement for the first phase.
We also chose to switch off circular dependency checks for now.
The Finalization of Our Strategy
With the initial plan validated, we went through the codebase, identified all the features, and split them into separate libraries.
We found that most features could be broken down into three main pieces:
- feature/common: Reusable components and directives used within a feature and sometimes across other features.
- Core: Since we lazy load our features to avoid a bloated app, the core lib contains components, services, directives, and modules that belong to a feature but aren't shared outside it.
- State: Every feature manages some state. We use NgRx for global state and RxAngular for local state; the state lib holds the NgRx code for a feature and is occasionally shared with other features.
Shared code we put into a central core folder structure:
- core/directive
- core/shared-components
- core/state
- core/model
These core libraries are reused across the organization's many applications.
Steps After Creating the Libraries
Creating libraries was only the first stage. During that process, we discovered that a significant amount of NgRx state management code had been living in the main bundle.
We could address this in parallel by splitting it out and loading only the states needed by the main code.
The initiative started with a main bundle at around 2.9MB, and after optimization, we brought it down to 2.30MB for the evergreen browser build.
Resolving Circular Dependencies
After extracting libraries, we had 180+ of them, all from one application. It was clear that fixing all circular dependencies at once would be impossible.
So we focused on the core libs first, and we found that the bulk of the circular dependency problems came from those, mostly from interfaces, services, and state.
We left the circular dependency check off for existing code but turned it on for new code by adding it to the root eslint config, while disabling it for libraries that already had issues. This ensured that any new library couldn't be merged unless it was free of circular dependencies.
As we fixed existing issues, we gradually enabled the check for more libraries.
Fixing these dependencies sometimes meant creating even more libraries. In the end, we had over 250 libraries.
Making Libraries Buildable
As noted, our approach made it so the libraries weren't buildable. But one of our teammates took that on and wrote a custom builder that could build all the new libraries created with this approach.
He even contributed a library generator to ensure every new library followed the same structure, keeping them out of the main bundle.
Outcomes
After the entire migration, we received several benefits:
Code Owners: We introduced a CODEOWNERS file to delegate review responsibilities based on which group owns a piece of the code.
Custom ESLint Rules: Our workflow had a set of manual checks during code review. Nx allowed us to turn them into custom ESLint rules, saving us a lot of time.
Simplified Refactoring: We make adjustments and add new features every week. Having these libraries made it much easier to pinpoint where changes are needed.
Conclusion
Choosing Nx was a win for us. We managed to identify and extract features into dedicated libraries, which resulted in smaller, more manageable PRs. We also spotted unused and duplicate code along the way.
Custom rules and code owners made review processes much clearer. We now know exactly what needs to be reviewed and by whom.
If you've migrated to Nx, share your story on Twitter—let's hear how it went for you.
Want to discuss? Join the Nx Community Slack we're on it.
Special thanks to Juri for reviewing this article. Appreciate you, Juri 💙
A shoutout to my GitHub Sponsors:
