In the world of monorepos

Bilel Msekni

Transformation from monolithic architecture to microservices led to changing how codebases are managed. Instead of having a single repository, a dedicated one is now created for every microservice and code is shared via packages. Although it seems logical, it’s not the most efficient one.

Having a repository per application means redundent work during setups (CI builds, coding rules, testing config, ..). Furthermore, sharing code via packages introduces additional work to maintain and to update these dependencies. Usually, one package update requires as many pull requests as there are references to it. Finally, when code is scattered across multiple repositories, keeping it consistent becomes a real challenge.

Thus, a new trend emerged, monorepos: Keeping multiple applications’ source code within the same repository. It may sound insane but let’s have a look at the pros and cons:

Pros 👍

  • Single continous integration process.
  • Easy to coordinate changes across different applications.
  • Single place to report issues.
  • Cross applications bugs are detected faster.
  • All applications are kept in sync.

Cons 👎

  • Onboarding is a challenge.
  • Not all version control tools can keep up with large code base size

This is why monorepo trend is is gaining momentum. Tech giants like Google and Facebook already adopted this practice. In this context, Angular provides developers with the tools they need to create and to maintain applications inside monorepos.

Monorepos with Angular

Angular CLI generates projects that can contain multiple applications. Let’s take a look at the old cli (1.x) settings file (.angular-cli.json):

{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"project": {
"name": "app1"
},
"apps": [
{
"root": "src",
"outDir": "dist",
"assets": [
"assets",
"favicon.ico"
],
"index": "index.html",
"main": "main.ts",
"polyfills": "polyfills.ts",
"test": "test.ts",
"tsconfig": "tsconfig.app.json",
"testTsconfig": "tsconfig.spec.json",
"prefix": "app",
"styles": [
"styles.css"
],
"scripts": [],
"environmentSource": "environments/environment.ts",
"environments": {
"dev": "environments/environment.ts",
"prod": "environments/environment.prod.ts"
}
}
],
"e2e": {
"protractor": {
"config": "./protractor.conf.js"
}
},
"lint": [
{
"project": "src/tsconfig.app.json",
"exclude": "**/node_modules/**"
},
{
"project": "src/tsconfig.spec.json",
"exclude": "**/node_modules/**"
},
{
"project": "e2e/tsconfig.e2e.json",
"exclude": "**/node_modules/**"
}
],
"test": {
"karma": {
"config": "./karma.conf.js"
}
},
"defaults": {
"styleExt": "css",
"component": {}
}
}

Notice how the apps key is an array that can host an infinite number of apps. Adding new applications was done manually like the cli docs explains. However, as its name stated, it’s an app, so it can’t be packaged nor distributed like a library. A potential workaround was to separate shared code then to package it with ng-packagr 😩

Enter Angular CLI v6.x:

In its sixth version, the CLI settings file (angular.json) changed completly:

{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"app1": {
"root": "",
"sourceRoot": "src",
"projectType": "application",
"prefix": "app",
"schematics": {},
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/app1",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.app.json",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"src/styles.css"
],
"scripts": []
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
}
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "app1:build"
},
"configurations": {
"production": {
"browserTarget": "app1:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "app1:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.spec.json",
"karmaConfig": "src/karma.conf.js",
"styles": [
"src/styles.css"
],
"scripts": [],
"assets": [
"src/favicon.ico",
"src/assets"
]
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"src/tsconfig.app.json",
"src/tsconfig.spec.json"
],
"exclude": [
"**/node_modules/**"
]
}
}
}
},
"defaultProject": "app1"
}

Notice the apps array was replaced with projects and the introduction of projectType parameter in order to choose between application and library type. This means that Angular monorepos can now contain applications projects and libraries projects at the same time. Libraries can be used inside apps or even be published to npm out of the box 😆

This opens a wide variety of options for developers. For instance, an Angular repo can be of this shape:

AngularRepo

├──/src * main application 1

├──/projects * root of projects
| ├──/app2 * application 2 folder
| ├──/lib1 * library 1 folder
│ └──/lib2 * library 2 folder

├──tslint.json * lint config
├──tsconfig.json * typescript config
├──angular.json * angular cli config
└──package.json * npm packages

A main application in the src folder and another application inside projects. Two additional libraries can be easily used inside the applications.

import { ComponentA } from 'lib1';
import { ComponentB } from 'lib2';

During development phase, applications will refresh automatically everytime library code changes thus allowing developers to be more efficient.

It’s a great idea but …

Sometimes things do not go as expected, let me give an example:

AngularRepo

├──/src * feature project
│ ├──/login * feature folder
│ ├──login.component.ts * feature component
├──/projects
| ├──/fr * feature project
│ ├──/login * feature folder
| ├──login.component.ts * feature component

│ └──/it * feature project
│ ├──/login * feature feature
| ├──login.component.ts * feature service

├──tslint.json * lint config
├──tsconfig.json * typescript config
├──angular.json * angular cli config
└──package.json * npm packages

In this repository, three versions of the same app English (en), French (fr) & Italian (it) coexist. All three versions differ in implementation due to legal constrains. Let’s imagine a login feature to identify and to log users. Because the feature is inside the three apps, any possibly shared code will have to be duplicated since no base class exists. One may argue why not create a base library to share code ?

It’s a great idea but adopting such strategy when there are another 14 additional features that need to be available per version too will result in a hollow base project containing a base class per feature. My team and I voted against this because it felt inconvenient to have a base class and child class in two different projects.

To overcome these challenges, we decided to organize our code differently:

AngularRepo

├──/src
├──/login * feature project
│ ├──/en * feature folder
│ ├──login.component.ts * feature component
│ ├──/fr * feature folder
| ├──login.component.ts * feature component
│ ├──/it * feature folder
| ├──login.component.ts * feature component
| ├──login.component.ts * base login component
├──tslint.json * lint config
├──tsconfig.json * typescript config
├──tsconfig.fr.json * typescript config
├──tsconfig.es.json * typescript config
├──tsconfig.it.json * typescript config
├──angular.json * angular cli config
└──package.json * npm packages

Notice how the feature is declined in different versions depending on which app it belongs to. Code can be shared using a parent class that resides in the root folder of the feature and developers can quickly find the implementation of any version of a particular feature.

However, one down side to this approach is the fact that the compiler will try to bundle everything it finds no matter if it belongs to the app or not, thus bloating your final bundle with unrelevant code. The workaround is easy. Since every project has its own tsconfig, we can make use of its exclude option to configure the compiler to ignore files unrelated to the application. This also implies the usage of convention based naming to respect the ignore patterns. For instance, for French app, its tsconfig should look like this:

{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/app",
"types": []
},
"exclude": [
"**/it",
"**/en",
"test.ts",
"**/*.spec.ts"
]

}

It exludes every file inside an en or it folder as well as tests. This is great at least for the machine but again what about developer experience?

At first, it wasn’t so bad but the more countries we added the more troublesome it became. For someone who wants to work on the french project, italian and english code were just there creating a distraction. Brainstorming on how we could solve this, we found no pattern nor design that could help us. As if we reached the limits of software architecture.

No challenge is unsolvable

Actually , a good starting point already exists. Our tsconfigs helped the compiler understand which files belong to which project. What if they could also be used to help VS Code filter out files in its explorer view.

How? VS Code provides a rich API that can be interacted with through extensions. I imagined an extension that will scan the workspace for an angular.json file and extract the projects it contains. By reading the corresponding tsconfigs and particularly include/exclude arrays, it can understand which files belong to which project therefore when a developer wants to focus on a specific project, all is needed is to select that project. The extension will only display files belonging to that project thus calling it Projectizer. Once the list of files not belonging to the project is computed, Projectizer will simply update the VS Code’s setting file.exclude with the appropriate patterns.

Projectizer in action

Projectizer is available on VS Code marketplace and its source code is here.

In conclusion, we succeeded in creating a monorepo containing 3 applications, feature driven but without increasing our bundle size nor compromising the developer experience.

If you like Angular or you just want to know more, you can checkout my articles about web components with Angular or how to use jest with Angular and vscode.

Thanks to Maxime Mader

Bilel Msekni

Written by

Craftsman, coach and a composer of words

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade