Cover photo originally by Edgar Chaparro on Unsplash.
First published on 2020-05-10.
Nrwl's Nx toolchain enables development within what's called a workspace — a monorepo that can host multiple applications, workspace libraries, and package libraries simultaneously.
If introducing Nx to your team or manager isn't feasible, there's still a path forward. This tutorial demonstrates how to create an Nx-style workspace with the Angular CLI instead of the Nx CLI. A custom Node.js tool will be used for generating application and workspace library projects.
To illustrate working in an Angular CLI workspace with multiple applications and multiple platforms per application, we'll build a sufficient portion of the Nrwl Airlines example. A shared feature exists across the application domains. Within each domain, both platforms use an identical feature set and routing, facilitated by a feature shell library that handles orchestration and acts as the entry point to the application project.
As shown in the figure below, the final project folder structure will contain 4 application projects, 4 end-to-end test suites, and 12 workspace libraries.
nrwl-airlines
├── apps
│ ├── booking
│ │ ├── booking-desktop
│ │ ├── booking-desktop-e2e
│ │ ├── booking-mobile
│ │ └── booking-mobile-e2e
│ └── check-in
│ ├── check-in-desktop
│ ├── check-in-desktop-e2e
│ ├── check-in-mobile
│ └── check-in-mobile-e2e
└── libs
├── booking
│ ├── data-access
│ ├── feature-flight-search
│ ├── feature-passenger-info
│ └── feature-shell
├── check-in
│ ├── data-access
│ └── feature-shell
└── shared
├── data-access
├── environments
├── seatmap
│ ├── data-access
│ └── feature-seat-listing
├── ui-buttons
└── util-formatting
The Nrwl Airlines example comes from the free Nrwl e-book "Enterprise Angular Monorepo Patterns".
Our initial project generation will rely on the Angular schematics and some command-line utilities. Later on, we'll streamline those steps with a bespoke CLI tool named generate-project.
We'll cover this material across five parts. For now, we’ll set up the Angular CLI monorepo workspace, create the booking desktop app along with its end-to-end testing project, and scaffold the booking feature shell library for the workspace.
Angular workspace
To start, run ng new to create a fresh Angular CLI workspace named nrwl-airlines. This assumes a global installation of the Angular CLI.
ng new nrwl-airlines --strict --create-application=false
When you pass the --strict flag, the Angular CLI configures the TypeScript compiler with several strict checks.
By omitting the --create-application option, the CLI does not scaffold an application project immediately. That matters here, because our goal is an Nx-style directory layout, with dedicated folders for apps and for libraries kept inside the workspace.
nrwl-airlines
├── .editorconfig
├── .gitignore
├── README.md
├── angular.json
├── package.json
├── tsconfig.json
├── tslint.json
└── yarn.lock
Here is what the generated blank workspace looks like in terms of its files and directories.
npm install --save-dev json
# or
yarn add --dev json
Let's bring in the json package for modifying JSON configs inside our workspace. Add it to the dev dependencies.
We can see it in action by turning on Ivy's strict template type checking. Execute the commands below.
npx json -I -f tsconfig.json -e "delete this.angularCompilerOptions.fullTemplateTypeCheck"
npx json -I -f tsconfig.json -e "this.angularCompilerOptions.strictTemplates = true"
Our TypeScript setup currently includes the following Angular compiler flags.
{
"//": "tsconfig.json",
"angularCompilerOptions": {
"strictInjectionParameters": true,
"strictTemplates": true
}
}
The majority of our current projects are structured as workspace libraries. We’ll designate the libs directory, which will be created in a moment, as the default project location. Execute the following ng config command to put that in place.
ng config newProjectRoot libs
Booking desktop application
Kicking things off, we create the desktop web app for the booking domain.
To do this, we invoke the default Angular application generator schematic, passing the following options.
ng generate application booking-desktop --prefix=booking --project-root=apps/booking/booking-desktop --style=css --routing=false
Now, we'll break the project directory and its settings in angular.json into two distinct projects: one dedicated to the app, the other to the end-to-end tests. Execute the commands below.
npx copy apps/booking/booking-desktop/e2e/**/* apps/booking/booking-desktop-e2e
npx rimraf apps/booking/booking-desktop/e2e
npx json -I -f apps/booking/booking-desktop-e2e/tsconfig.json -e "this.extends = '../../../tsconfig.json'"
npx json -I -f apps/booking/booking-desktop-e2e/tsconfig.json -e "this.compilerOptions.outDir = '../../../out-tsc/e2e'"
ng config projects["booking-desktop"].architect.e2e.options.protractorConfig apps/booking/booking-desktop-e2e/protractor.conf.js
npx json -I -f angular.json -e "this.projects['booking-desktop-e2e'] = this.projects['booking-desktop']"
Last but not least, we’ll wire up the builders and architect targets for the two booking desktop apps, exactly as shown below.
ng config projects["booking-desktop-e2e"].root apps/booking/booking-desktop-e2e
ng config projects["booking-desktop-e2e"].architect.lint.options.tsConfig apps/booking/booking-desktop-e2e/tsconfig.json
npx json -I -f angular.json -e "delete this.projects['booking-desktop'].architect.e2e"
npx json -I -f angular.json -e "this.projects['booking-desktop'].architect.lint.options.tsConfig.pop()"
ng config projects["booking-desktop"].architect.lint.options.exclude[1] !apps/booking/booking-desktop/**
npx json -I -f angular.json -e "delete this.projects['booking-desktop-e2e'].architect.build"
npx json -I -f angular.json -e "delete this.projects['booking-desktop-e2e'].architect['extract-i18n']"
npx json -I -f angular.json -e "delete this.projects['booking-desktop-e2e'].architect.serve"
npx json -I -f angular.json -e "delete this.projects['booking-desktop-e2e'].architect.test"
npx json -I -f angular.json -e "delete this.projects['booking-desktop-e2e'].prefix"
npx json -I -f angular.json -e "delete this.projects['booking-desktop-e2e'].sourceRoot"
npx json -I -f angular.json -e "delete this.projects['booking-desktop-e2e'].schematics"
ng config projects["booking-desktop-e2e"].architect.lint.options.exclude[1] !apps/booking/booking-desktop-e2e/**
A portion of these commands simply tidy things up after we've separated our project directory from the workspace configuration file. Others replicate the setup that the Nx CLI, paired with Nrwl's schematics for Angular, would normally produce.
apps
└── booking
├── booking-desktop
│ ├── src
│ │ ├── app
│ │ │ ├── app.component.css
│ │ │ ├── app.component.html
│ │ │ ├── app.component.spec.ts
│ │ │ ├── app.component.ts
│ │ │ └── app.module.ts
│ │ ├── assets
│ │ │ └── .gitkeep
│ │ ├── environments
│ │ │ ├── environment.prod.ts
│ │ │ └── environment.ts
│ │ ├── favicon.ico
│ │ ├── index.html
│ │ ├── main.ts
│ │ ├── polyfills.ts
│ │ ├── styles.css
│ │ └── test.ts
│ ├── browserslist
│ ├── karma.conf.js
│ ├── tsconfig.app.json
│ ├── tsconfig.spec.json
│ └── tslint.json
└── booking-desktop-e2e
├── src
│ ├── app.e2e-spec.ts
│ └── app.po.ts
├── protractor.conf.js
└── tsconfig.json
Once these steps are completed, the apps folder will contain a layout that matches the diagram above.
{
"//": "angular.json",
"projects": {
"booking-desktop": {
"projectType": "application",
"schematics": {},
"root": "apps/booking/booking-desktop",
"sourceRoot": "apps/booking/booking-desktop/src",
"prefix": "booking",
"architect": {
"build": {
"//": "(...)"
},
"serve": {
"//": "(...)"
},
"extract-i18n": {
"//": "(...)"
},
"test": {
"//": "(...)"
},
"lint": {
"//": "(...)"
}
}
}
}
}
The configuration for booking-desktop inside angular.json mirrors the standard setup, with one notable difference: the e2e architect target has been dropped from it.
{
"//": "angular.json",
"projects": {
"booking-desktop-e2e": {
"projectType": "application",
"root": "apps/booking/booking-desktop-e2e",
"architect": {
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": "apps/booking/booking-desktop-e2e/tsconfig.json",
"exclude": ["**/node_modules/**", "!apps/booking/booking-desktop-e2e/**"]
}
},
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "apps/booking/booking-desktop-e2e/protractor.conf.js",
"devServerTarget": "booking-desktop:serve"
},
"configurations": {
"production": {
"devServerTarget": "booking-desktop:serve:production"
}
}
}
}
}
}
}
You can see in the previous listing that the booking-desktop-e2e project defines two architect targets: e2e and lint.
Execute those targets with the commands below.
ng run booking-desktop-e2e:lint
ng run booking-desktop-e2e:e2e
One shared Karma setup
When we split the workspace into multiple projects, each with its own test builder, every project ends up with its own Karma configuration. But those configurations will nearly always look alike, so we want to define a single one at the workspace root, as the listing below illustrates.
// karma.conf.js
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
const { constants } = require('karma');
const path = require('path');
module.exports = () => ({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [require('karma-jasmine'), require('karma-chrome-launcher'), require('karma-jasmine-html-reporter'), require('karma-coverage-istanbul-reporter'), require('@angular-devkit/build-angular/plugins/karma')],
client: {
clearContext: false, // leave Jasmine Spec Runner output visible in browser
},
coverageIstanbulReporter: {
dir: path.join(__dirname, 'coverage'),
reports: ['html', 'lcovonly', 'text-summary'],
fixWebpackSourcePaths: true,
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: constants.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
restartOnFileChange: true,
});
Below is the rewritten HTML fragment:
The Karma setup for the desktop application project should be swapped out for the configuration that appears in this listing.
// apps/booking/booking-desktop/karma.conf.js
const path = require('path');
const getBaseKarmaConfig = require('../../../karma.conf');
module.exports = (config) => {
const baseConfig = getBaseKarmaConfig();
config.set({
...baseConfig,
coverageIstanbulReporter: {
...baseConfig.coverageIstanbulReporter,
dir: path.join(__dirname, '../../../coverage/apps/booking/booking-desktop'),
},
});
};
Run the command below to confirm the unit tests for the application continue to pass.
ng run booking-desktop:test --watch=false
Booking feature shell library
Now that the initial application project is in place, the feature shell library for the booking apps is next on the agenda.
npm install --save-dev rimraf
# or
yarn add --dev rimraf
To clean up certain files produced by Angular's library schematic, we'll be making use of the command-line tool rimraf. This step is necessary because the schematic generates a library that is built for distribution through a package manager like NPM.
In a workspace setup, libraries are frequently written for a single app or for reuse across several apps. Yet packaging, tagging with versions, and uploading them to a registry is usually unnecessary — a key advantage of adopting a monorepo arrangement.
ng config newProjectRoot libs/booking
The first step, as shown above, is to designate the directory that will contain the library project we're about to create.
Next, we invoke the Angular library schematic to scaffold a new library project using the following command.
ng generate library feature-shell --prefix=booking --entry-file=index --skip-install --skip-package-json
When you run the Angular library generator schematic, the resulting file and folder layout looks like this.
libs/booking/feature-shell
├── src
│ ├── lib
│ │ ├── feature-shell.component.spec.ts
│ │ ├── feature-shell.component.ts
│ │ ├── feature-shell.module.ts
│ │ ├── feature-shell.service.spec.ts
│ │ └── feature-shell.service.ts
│ ├── index.ts
│ └── test.ts
├── README.md
├── karma.conf.js
├── ng-package.json
├── package.json
├── tsconfig.lib.json
├── tsconfig.lib.prod.json
├── tsconfig.spec.json
└── tslint.json
Since the goal is a workspace library rather than a package library, we'll delete ng-package.json, package.json, and tsconfig.lib.prod.json.
The service and its associated tests are also being removed, and the Angular module gets a new name to match Nx standards.
npx json -I -f angular.json -e "this.projects['booking-feature-shell'] = this.projects['feature-shell']"
npx json -I -f angular.json -e "delete this.projects['feature-shell']"
Execute the earlier commands to change the name of the library project in the workspace. Because the feature-shell folder was already positioned inside libs/booking, we supplied feature-shell for the name argument of the library schematic, matching the directory name we intended.
Consequently, the paths referenced in the library project configuration line up correctly.
npx json -I -f angular.json -e "delete this.projects['booking-feature-shell'].architect.build"
When you execute the command provided earlier, the build architect target gets deleted—this library won't be compiled on its own; rather, the booking apps handle its compilation during their own builds.
As for the test and lint architect targets, those stay untouched.
ng config projects["booking-feature-shell"].architect.lint.options.exclude[1] !libs/booking/feature-shell/**
npx json -I -f libs/booking/feature-shell/tslint.json -e "this.linterOptions = { exclude: ['!**/*'] }"
Let's set up the same linting rules that the Nrwl Angular schematics bring in by default. Execute the commands shown previously.
As noted before, the next set of commands will remove the files we don't need.
npx rimraf libs/booking/feature-shell/*package.json
npx rimraf libs/booking/feature-shell/tsconfig.lib.prod.json
npx rimraf libs/booking/feature-shell/src/lib/*.*
The feature shell Angular module is generated using the usual naming convention, then exported in the manner illustrated in the listing below.
ng generate module booking-feature-shell --project=booking-feature-shell --flat --no-common-module
"export * from './lib/booking-feature-shell.module';" > libs/booking/feature-shell/src/index.ts
Next, we’ll attach a suite of tests to the feature shell Angular module, matching the code shown in the listing below.
// booking-feature-shell.module.spec.ts
import { TestBed } from '@angular/core/testing';
import { BookingFeatureShellModule } from './booking-feature-shell.module';
describe('BookingFeatureShellModule', () => {
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [BookingFeatureShellModule],
});
await TestBed.compileComponents();
});
it('should create', () => {
expect(BookingFeatureShellModule).toBeDefined();
});
});
We'll also add a shell component.
ng generate component shell --project=booking-feature-shell --module=booking-feature-shell.module.ts --display-block
"<router-outlet></router-outlet>" > libs/booking/feature-shell/src/lib/shell/shell.component.html
The component test suite is where we bring the router module into the picture.
// shell.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterModule } from '@angular/router';
import { ShellComponent } from './shell.component';
describe('ShellComponent', () => {
let component: ShellComponent;
let fixture: ComponentFixture<ShellComponent>;
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [ShellComponent],
imports: [RouterModule.forRoot([])],
});
TestBed.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ShellComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
It is time to modify the booking feature shell Angular module’s content.
// booking-feature-shell.module.ts
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ShellComponent } from './shell/shell.component';
const routes: Routes = [
{
path: '',
component: ShellComponent,
children: [],
},
];
@NgModule({
declarations: [ShellComponent],
exports: [RouterModule],
imports: [RouterModule.forRoot(routes), CommonModule],
})
export class BookingFeatureShellModule {}
The shell component serves as the root component for our layout and its features. Any new route we introduce needs to be placed inside the children array of the route that points to this shell component.
Those added routes get displayed through the router outlet that lives in the shell component’s template.
When the Angular library schematic ran, it configured the path mapping, as shown here.
{
"//": "tsconfig.json",
"compilerOptions": {
"paths": {
"feature-shell": ["dist/feature-shell/feature-shell", "dist/feature-shell"]
}
}
}
The path mapping assumed the package library would already be compiled and placed in the dist directory.
The library now lacks a build architect target. Keep in mind, in this monorepo setup, workspace libraries run directly from their source and get compiled within the application bundle.
npx json -I -f tsconfig.json -e "delete this.compilerOptions.paths['feature-shell']"
npx json -I -f tsconfig.json -e "this.compilerOptions.paths['@nrwl-airlines/booking/feature-shell'] = ['libs/booking/feature-shell/src/index.ts']"
The preceding example demonstrates the configuration for a path alias using a custom NPM scope, @nrwl-airlines, in the workspace. This alias directs the compiler to the barrel file of the library's public API, specifically index.ts, which is displayed in the subsequent listing.
{
"//": "tsconfig.json",
"compilerOptions": {
"paths": {
"@nrwl-airlines/booking/feature-shell": ["libs/booking/feature-shell/src/index.ts"]
}
}
}
The consumer applications can now reference the feature shell library via the @nrwl-airlines/booking/feature-shell import path.
Make sure to swap out the library's existing Karma setup for the configuration shown below.
// libs/booking/feature-shell/karma.conf.js
const path = require('path');
const getBaseKarmaConfig = require('../../../karma.conf');
module.exports = (config) => {
const baseConfig = getBaseKarmaConfig();
config.set({
...baseConfig,
coverageIstanbulReporter: {
...baseConfig.coverageIstanbulReporter,
dir: path.join(__dirname, '../../../coverage/libs/booking/feature-shell'),
},
});
};
To verify that the configuration is correct, execute the commands below to launch both the linter and the unit tests.
ng run booking-feature-shell:lint
ng run booking-feature-shell:test --watch=false
Below, you can see the file and folder layout that gets created for the booking feature shell library.
libs/booking/feature-shell
├── src
│ ├── lib
│ │ ├── booking-feature-shell.module.spec.ts
│ │ └── booking-feature-shell.module.ts
│ ├── index.ts
│ └── test.ts
├── README.md
├── karma.conf.js
├── tsconfig.lib.json
├── tsconfig.spec.json
└── tslint.json
The listing below demonstrates that the booking feature shell library exposes a pair of architect targets, namely test and lint.
{
"//": "angular.json",
"projects": {
"booking-feature-shell": {
"projectType": "library",
"root": "libs/booking/feature-shell",
"sourceRoot": "libs/booking/feature-shell/src",
"prefix": "booking",
"architect": {
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "libs/booking/feature-shell/src/test.ts",
"tsConfig": "libs/booking/feature-shell/tsconfig.spec.json",
"karmaConfig": "libs/booking/feature-shell/karma.conf.js"
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": ["libs/booking/feature-shell/tsconfig.lib.json", "libs/booking/feature-shell/tsconfig.spec.json"],
"exclude": ["**/node_modules/**", "!libs/booking/feature-shell/**"]
}
}
}
}
}
}
With the feature shell library project generated and configured, the next step is bringing the feature shell module into the booking desktop application.
The top-level route in our initial booking feature shell holds the shell component as part of the root route configuration. Feature-specific routes, setup steps, and configuration will be introduced at a later stage.
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { BookingFeatureShellModule } from '@nrwl-airlines/booking/feature-shell';
import { AppComponent } from './app.component';
@NgModule({
bootstrap: [AppComponent],
declarations: [AppComponent],
imports: [BrowserModule, BookingFeatureShellModule],
})
export class AppModule {}
The shell module for the booking feature is loaded upfront, as demonstrated in the previous snippet. This approach is acceptable, because that module itself defers the loading of its own feature routes.
<!-- apps/booking/booking-desktop/src/app/app.component.html -->
<h1>{{title}}</h1>
<router-outlet></router-outlet>
As shown in the listing above, the root component of the booking desktop app must include a router outlet.
Update the app component's test file to reflect those adjustments.
// apps/booking/booking-desktop/src/app/app.component.spec.ts
import { async, TestBed } from '@angular/core/testing';
import { RouterModule } from '@angular/router';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [AppComponent],
imports: [RouterModule.forRoot([])],
}).compileComponents();
}));
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have as title 'booking-desktop'`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('booking-desktop');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('h1').textContent).toContain('booking-desktop');
});
});
The end-to-end tests need to be adjusted as well. The title should now correspond to 'booking-mobile', and the selector ought to point to booking-root h1.
// apps/booking/booking-desktop-e2e/src/app.e2e-spec.ts
import { browser, logging } from 'protractor';
import { AppPage } from './app.po';
describe('workspace-project App', () => {
let page: AppPage;
beforeEach(() => {
page = new AppPage();
});
it('should display welcome message', () => {
page.navigateTo();
expect(page.getTitleText()).toEqual('booking-desktop');
});
afterEach(async () => {
// Assert that there are no errors emitted from the browser
const logs = await browser.manage().logs().get(logging.Type.BROWSER);
expect(logs).not.toContain(
jasmine.objectContaining({
level: logging.Level.SEVERE,
} as logging.Entry)
);
});
});
// apps/booking/booking-desktop-e2e/src/app.po.ts
import { browser, by, element } from 'protractor';
export class AppPage {
navigateTo(): Promise<unknown> {
return browser.get(browser.baseUrl) as Promise<unknown>;
}
getTitleText(): Promise<string> {
return element(by.css('booking-root h1')).getText() as Promise<string>;
}
}
Before anything else, we should verify that both our application and end-to-end projects are in good shape—running lint checks and the test suite will confirm that.
ng run booking-desktop:lint
ng run booking-desktop:test --watch=false
ng run booking-desktop-e2e:lint
ng run booking-desktop-e2e:e2e
ng run booking-feature-shell:lint
ng run booking-feature-shell:test --watch=false
Use the command ng run booking-desktop:serve, and then open your browser to http://localhost:4200 to view the app. The result should match the screenshot below.
Conclusion
By now, our project directory arrangement matches the one depicted below.
nrwl-airlines
├── apps
│ └── booking
│ ├── booking-desktop
│ └── booking-desktop-e2e
└── libs
└── booking
└── feature-shell
Using the Angular CLI, we began by scaffolding a workspace with no application or library projects.
To explore how the json CLI utility edits JSON-based configuration files, we turned on strict template type-checking.
The first project generated was the booking desktop app. After scaffolding it via the official Angular application schematic, the end-to-end tests and related config were moved into a dedicated project featuring lint and e2e architect targets.
Karma configuration was consolidated, with a root-level base setup that the booking desktop app extends by specifying its own coverage report path.
Our first workspace library, the booking feature shell library, came next. Though we used the standard Angular library schematic to bootstrap it, that scaffold targets publishable packages intended for registries like NPM.
In contrast, workspace libraries serve only internal monorepo needs—they can be reused across apps, require no versioning, and their source sits alongside the projects they support.
We created an entry point Angular module with routing for the entry point component—the shell—which handles child routes for the rest of the application, rendered dynamically via its router outlet.
TypeScript path mappings were introduced so the app could import the booking feature shell library. We then added a router outlet to the app component and brought in the shell module to connect the two.
Maintaining evolving code is crucial, so we refreshed both unit and end-to-end test suites to match the new structure.
As a final check, we linted everything and ran the unit and end-to-end tests.
That's a full day's effort.
Coming up in Part 2, we'll remove much of the manual work using a custom generate project tool—it will create the shared and booking data access libraries, add NgRx Store and Effects root/feature state, integrate the Store DevTools and NgRx schematics, register the data access libs in the feature shell, and extract a shared environments workspace library for flexible data access configuration.
Resources
Short on patience? The complete code is in the LayZeeDK/ngx-nrwl-airlines-workspace repository on GitHub.

