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
Enter fullscreen mode Exit fullscreen mode
The workspace project folder structure we're going to build in this tutorial.

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
Enter fullscreen mode Exit fullscreen mode
Generate the Angular workspace.

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
Enter fullscreen mode Exit fullscreen mode
Blank workspace content.

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
Enter fullscreen mode Exit fullscreen mode
Install command line utility for editing JSON files.

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"
Enter fullscreen mode Exit fullscreen mode
Enable strict template type checking.

Our TypeScript setup currently includes the following Angular compiler flags.

{
  "//": "tsconfig.json",
  "angularCompilerOptions": {
    "strictInjectionParameters": true,
    "strictTemplates": true
  }
}
Enter fullscreen mode Exit fullscreen mode
Strict template type checking enabled.

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
Enter fullscreen mode Exit fullscreen mode
Set default project directory.

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
Enter fullscreen mode Exit fullscreen mode
Generate booking desktop application project.

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']"
Enter fullscreen mode Exit fullscreen mode
Extract end-to-end testing project.

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/**
Enter fullscreen mode Exit fullscreen mode
Configure builders and architect targets.

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
Enter fullscreen mode Exit fullscreen mode
Applications folder structure after adding the first application and end-to-end testing projects.

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": {
          "//": "(...)"
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
The project configuration for the desktop booking application.

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"
            }
          }
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
End-to-end testing project configuration.

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
Enter fullscreen mode Exit fullscreen mode
Lint and run the end-to-end booking destop test suite.

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,
});
Enter fullscreen mode Exit fullscreen mode
Base Karma configuration in workspace root.

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'),
    },
  });
};
Enter fullscreen mode Exit fullscreen mode
Karma configuration for booking desktop application.

Run the command below to confirm the unit tests for the application continue to pass.

ng run booking-desktop:test --watch=false
Enter fullscreen mode Exit fullscreen mode
Run the booking desktop application's unit test suite.

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
Enter fullscreen mode Exit fullscreen mode
Install command line utility for deleting files and folders.

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
Enter fullscreen mode Exit fullscreen mode
Set the parent folder of the library project.

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
Enter fullscreen mode Exit fullscreen mode
Generate library project.

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
Enter fullscreen mode Exit fullscreen mode
Default Angular package library file and folder structure.

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']"
Enter fullscreen mode Exit fullscreen mode
Rename workspace library project.

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"
Enter fullscreen mode Exit fullscreen mode
Remove the `build` architect target.

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: ['!**/*'] }"
Enter fullscreen mode Exit fullscreen mode
Configure library project linter.

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/*.*
Enter fullscreen mode Exit fullscreen mode
Clean up library project.

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
Enter fullscreen mode Exit fullscreen mode
Generate and export a feature shell Angular module.

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();
  });
});
Enter fullscreen mode Exit fullscreen mode
Test suite for the feature shell Angular module.

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
Enter fullscreen mode Exit fullscreen mode
Generate booking shell component.

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();
  });
});
Enter fullscreen mode Exit fullscreen mode
Booking shell component test suite with router module.

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 {}
Enter fullscreen mode Exit fullscreen mode
Booking feature shell module with shell component.

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"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
Default library path mapping.

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']"
Enter fullscreen mode Exit fullscreen mode
Generate booking feature shell library and configure path mappings.

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"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
Workspace-scoped library path mapping.

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'),
    },
  });
};
Enter fullscreen mode Exit fullscreen mode
Karma configuration for booking feature shell library.

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
Enter fullscreen mode Exit fullscreen mode
Lint and test the booking feature shell library.

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
Enter fullscreen mode Exit fullscreen mode
Feature workspace library file and folder structure.

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/**"]
          }
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
Booking feature shell project configuration.

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 {}
Enter fullscreen mode Exit fullscreen mode
Booking desktop application module.

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>
Enter fullscreen mode Exit fullscreen mode
The booking desktop application's root component template.

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');
  });
});
Enter fullscreen mode Exit fullscreen mode
Revised app component test suite for the booking desktop application.

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)
    );
  });
});
Enter fullscreen mode Exit fullscreen mode
Updated end-to-end test for the booking desktop application.
// 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>;
  }
}
Enter fullscreen mode Exit fullscreen mode
Updated app page object for the booking desktop application.

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
Enter fullscreen mode Exit fullscreen mode
Lint and run tests for the booking application project and it's end-to-end test suite as well as the booking feature shell library project.

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.

The booking desktop application with the booking shell feature.

The booking desktop application with the booking shell feature.

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
Enter fullscreen mode Exit fullscreen mode
The project folder structure at the end of Part 1.

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.