When adopting monorepos and Nx, it can feel like there's a lot that needs to be understood first before you get all the benefits. Oftentimes it's tempting to skip some fundamentals and just add all your code into one monorepo and call it a day. Following this mentality is the best way to actually increase your CI run times and cost. Monorepos should simplify your architecture and reduce costs, not cause you to spend more money and time to build, test, and deploy. To make this possible, following a simple restructure of multiple applications and investing time and effort into setting up your monorepo can pay off in the long run. But what does this ideal structure look like, and what are the benefits?
Real World Example
While it's easy to say this architectural approach is better, it does help to highlight cases where other teams have faced this very challenge. A major sports retailer in the US made the switch to monorepos powered by Nx and faced several challenges. Their project structure had a massive amount of code duplication across all of the apps in monorepo. When measuring their initial migration, they had an average CI run time of an hour and a cache hit rate of under 20%. We worked with this team and were able to guide them along their migration to figure out where they could make the biggest impact. After investing in this effort, they've been able to cut their CI run times down to 7-9 minutes and with a cache hit rate of 57%. This significant improvement can all be traced back to following best practices.
Projects All The Way Down
In our sports retailer monorepo, let’s consider that we have three apps for different parts of our frontend. In a worst case scenario, we bring all these apps together, configure their CI pipelines, and call it a day. This bare minimum, while it does give us a monorepo, it does not provide any real substantial value outside of dependencies being matched across the apps. As we start to add more and more to the monorepo, we'll see our CI run times start to increase. Anytime we try to cut a release, we'll be left waiting longer and longer. So what's the solution here?
Let's say we have some code in our app that exists in all three of our apps that is copied across all of them. When your PR hits CI, this portion of code is built, tested, and linted in every place that it is copied, which is unnecessary. One block of shared code won't fix our CI pipeline times, but it will set us on the correct path. The solution is to start migrating portions that are common across any apps in a monorepo into its own project that we can import.
Splitting this code out with Nx is as simple as generating a new project and migrating that code over. So this:
1monorepo/
2└── apps/
3 ├── storefront/
4 ├── internal-dash/
5 └── support-dash/
6
Becomes this:
1monorepo/
2├── apps/
3│ ├── storefront/
4│ ├── internal-dash/
5│ └── support-dash/
6└── libs/
7 └── date-time-utils/
8
This small change will now allow Nx to cache the task results of our date-time-utils
. That way, when we attempt to build, test, or lint our apps, Nx will simply provide the cached results if the project hasn't been changed. As we migrate more and more of our code into their own projects, the caching features become more and more impactful.
Splitting our shared utilities seems like a very reasonable first step, but we can take this further by actually splitting any UI features into their own projects. This is helpful if we've standardized around a particular frontend framework, then sharing those components across any number of apps becomes trivial. A secondary aspect of this is now we can ensure consistency across our UI design all while reducing our build times further down.
Projects aren't just for shared code
Shared libraries often make sense as a first attempt at optimizing monorepo architecture because it mirrors what we see in a multirepo approach. Grouping code that's used in multiple places into a single reusable library scratches that engineering itch to be DRY. But shared code is not the only code that should be in projects in a monorepo. Applications themselves can also be broken down into projects.
Why? Caching and parallelization of tasks happens at the project level. Dividing code into logical pieces and separating it into separate projects allow us to:
- Run tasks faster - Running tasks on smaller projects = faster run times
- Cache more results - Making changes to smaller projects results in fewer affected projects = running fewer tasks in CI
Imagine this application has three routes:
1storefront/
2├── product-search
3├── product-details
4└── checkout
5
If we leave this application as-is, we build, test, and lint this entire application any time we make a change.
If we instead break these routes into their own projects:
1monorepo/
2├── libs/
3│ shared
4│ │ └──date-time-utils/
5│ └──storefront/
6│ ├──product-search/
7│ ├──product-details/
8│ └──checkout
9└── apps/
10 ├── storefront/
11 ├── internal-dash/
12 └── support-dash/
13
The routes exist as projects which are then imported by the application. We can now lint and test the app and those routes individually. If we make a change to one route, we don't have to lint and test the other routes.
Improve Your Builds, Talk With Us
Adopting a monorepo powered by Nx on its own does not solve build speeds or CI pipeline times. But adopting a monorepo and taking the time to reevaluate your architecture can lead to a significant improvement in build times when best practices are applied. If you're curious to know more, reach out to us to see how Nx can improve your monorepo experience and ship faster!
Explore Nx Cloud for EnterprisesLearn more about our offerings for enterprises and contact our team