I see many teams believing that npm packages are the way they should share code in Angular projects. The problem with this is that this creates a lot of friction for code sharing because:
- Every shared lib needs to have its own CI/CD pipeline
- Each CI/CD pipeline should handle versioning of changes
- Every time a new change in a shared repo needs to be implemented in clients, you need to go to all clients and update their npm packages
- There can be mismatches with different npm packages for the clients, eg. different Angular or Typescript versions, making it hard to share code
- In general, harder to do a cross-cutting change across multiple apps and libs as these are all in their own repository
The monorepo approach fixes all of these pains and makes it very easy to share code across apps and libs. Migrating to a monorepo will be a complete game changer for your teams in terms of productivity and code sharing. That is also why you will see industry-leading companies following this way of working, such as Google and Facebook. You might also have noticed that the Angular Github repo is a monorepo containing many libs.
What’s the catch? You need the tooling for making this work with your CI/CD pipeline. As this scales to many libs and apps you want to make sure, that your build is efficient and you only test and build the affected code. It’s actually easy when you know how to do it, which is what I will show you in this post.
In this post, we will go through how to create a monorepo using Nx schematics from Nrwl, which will extend the Angular CLI commands with helpful commands for working with monorepos.
Setting up the monorepo
Using Nx, it is very easy to set up a new monorepo project.
Make sure you have Angular CLI installed:
npm i -g @angular/cli
And then install the Nx schematics globally:
npm install -g @nrwl/schematics
Now you are ready to generate a new Nx workspace with:
create-nx-workspace myworkspacedemo
This will give you a couple of options, which style processor you want to use, what npm scope and what package manager:
I chose here to go with SCSS, using the default npm scope, which means that the libs will be imported by using the folder name (myworkspacedemo) as a prefix like @myworkspacedemo/*lib-name*. Lastly, I choose to use npm but use yarn if that fits you better.
This should give you the following folder structure:
│ .editorconfig │ .gitignore │ .prettierignore │ .prettierrc │ angular.json │ karma.conf.js │ nx.json │ package-lock.json │ package.json │ README.md │ tsconfig.json │ tslint.json │ ├───apps │ .gitkeep │ ├───libs │ .gitkeep │ └───tools │ tsconfig.tools.json │ └───schematics .gitkeep
The monorepo folder structure is containing all the base configurations on the root level, that could be tsconfig.json, TSLint, karma.config etc, and then the specific apps and libs can override these.
There is a folder for apps, which contains all the different apps in the monorepo. These apps should never reference each other but should reference the libs. On the root level, there is also a libs folder, containing all the different libs in the monorepo. Libs can depend on each other but should never depend on apps. Nx is making it easy to enforce which import is allowed using TSLint rules.
Creating apps and libs
You can create a new app with:
ng g app myapp
This will ask you if you want Angular routing, which unit test runner, which E2E test runner and tags used for Linting.
Nx supports Karma or Jest for unit testing and Protractor or Cypress for E2E testing. By design Protractor/Selenium is not able to create a stable test without helpers, which I have been blogging a bit about. By using Cypress you get a lot of these features out of the box, but that is for another blog post.
An app can import libs, which you can generate with:
ng g lib mylib
Again this will prompt you for options: which module should import the library, do you want a lazy loaded module with routes, the unit test runner to use and tags used for linting. The tags are used in the TSLint file, where you can specify dependency rules:
This example will enforce that apps with the project1 tag can only depend on libs with tag project1. As the monorepo grows it is good to be able to enforce module boundaries this way.
Converting an existing app to a monorepo
If you want to add existing apps to the monorepo while keeping the Git history, you can use Git subtree for this.
The following command will clone the remote repository into the app folder and will add all the history from the remote app to the monorepo versioning
git subtree pull - prefix apps/*appname*/ *remote-git-url* -squash
This will copy the repository into the given directory.
Note that you also will need to update ng.json
, angular.json
and package.json
accordingly so it has the same structure as when you generate a new app using ng g app *appname*
.
If you don’t care about keeping the Git history for the app, the easiest way is to generate a new app and copy the existing app into this.
Monorepos on the CI/CD pipeline
All we have done so far has been pretty easy. The thing about monorepos is that they need some tooling for the CI/CD pipeline to handle this in a scalable manner. That means, that if you change a shared lib you want to retest and rebuild the dependants (the apps that use the lib). To do this you need some kind of dependency graph to know which dependants to retest and rebuild.
Luckily, NX comes to the rescue with its scripts for running code on only affected projects.
Setting up the build pipeline for pull requests
For pull requests you can make Nx test only the affected with:
That way you make sure to catch if you break something for other projects.
Note also, that the CI contains a pipeline for each app, that will trigger only if that app has changed.
The complete build pipeline for an app to be run on Pull Requests for feature branches look like this:
This will also create a feature site for the app for QA’ing the new feature. To distinguish feature sites for different apps I like to use the convention: *app name*-*branch name*.
This is something you will be doing for every app in the monorepo, so it is a good idea to make this into a template on your CI/CD system. Also, since every pipeline is specific to every app you want your pipeline for that app to only be triggered when changes to that app have happened. Azure DevOps supports a path filter to trigger builds if a specific path has changed.
Setting up the release pipeline for deploying to production
The release pipeline for putting the code to production is going to be only slightly different than for pull requests. I see a lot of teams that are violating the “Build once, deploy many” DevOps rule by rebuilding the app for every environment instead of just changing the configuration and use the same build. I have written a couple of posts about this already you can read.
To stay true to build once, deploy many I recommend that you use the same build for all environments and substitute the configuration on the CI/CD pipeline for the environment you are deploying to. To do this you need to identify the build of the branch that you have merged, substitute the configuration values for the environment and deploy it to the new environment.
To identify the build you can:
- Save the build as an artifact identified by the pull request id and create a trigger for you pull request to start the release using that specific artifact that matched the pull request id
- You can get the last merge commit by using stuff like git show :/^Merge. Then, identify the feature build using a Git hash or a tag on the Git commit
- Set the merge strategy to fast forward only, so the master branch and the feature branch is going to be on the same commit. Then, identify the feature build using a Git hash or a tag on the Git commit
I recommend that you go with 1 if it is possible with the tool you use for pull requests. Otherwise, use 2 to get the commit of the branch and use that to identify the build that also has that commit hash/tag+app name. Doing fast forward only can be annoying since you can only merge if you are fully caught up the master branch. You can mitigate this by creating some tooling for merging the master in all the feature branches automatically when the master branch changes, but this is starting to get complicated if this is not supported by your CI/CD system.
You can also for now just do a rebuild of the apps when the master branch gets updated until you find a convenient way to identify the pull request build to reuse for production.
Once you have a build you want to substitute the config values. Substituting JSON files is supported in many CI/CD tools and I would recommend just using that as a contrast to scripting it yourself with PowerShell or Bash. You can see my blog post here how you set up your Angular app for dynamic environments. Lastly, you deploy your build folder to the front end server.
The whole pipeline for deploying an app to prod looks like this:
Since this is something you are going to do for every app, I recommend that you make this into a template on your CI/CD system, so you only need to substitute variables for making this working for the different apps in the monorepo.
Now you have an easy way to share code and deploy apps, with the safety of running test and build on the affected apps and libs.
Alternatives
As an alternative to this setup, you can use Lerna and Yarn workspaces for monorepos. This is beneficial if you want to keep your monorepo Angular agnostic because it might also contain React and Vue apps. Yarn workspaces will make sure that you only have one shared node_modules and Lerna covers some of the same monorepo tools as Nx, eg. by being able to only test and build affected projects.
Conclusion
This post showed you a better way of sharing code and being more efficient with Angular development in general by using a monorepo. Having a monorepo enables you to easily make changes across multiple apps and libs and you can even ship all these changes in one pull request. The price you pay for this is that you need better tooling for your CI/CD system. Nx gives us great tooling for working with monorepos including running unit tests, build and E2E tests for only the affected apps and libs and enables us to enforce module boundaries using TSLint rules.
I hope this post helped you to start adopting this way of working and let me know in the comment section if this was helpful.
References
https://nrwl.io/nx/guide-getting-started
Do you want to become an Angular architect? Check out Angular Architect Accelerator.