How to set up an Nx-style monorepo workspace with the Angular CLI: Part 2

layzee

Lars Gyrup Brink Nielsen

Posted on March 31, 2021

How to set up an Nx-style monorepo workspace with the Angular CLI: Part 2

Original cover photo by Edgar Chaparro on Unsplash.

Original publication date: 2020-05-12.

This tutorial is part of the Angular Architectural Patterns series.

In Part 1 of this tutorial, we set up the booking desktop application project, a project for its end-to-end test suite, and the booking feature shell workspace library.

In this part, we'll set up our custom generate project tool to automate the steps we did manually in Part 1. We'll use it to create the shared and booking data acess libraries with NgRx Store, NgRx Effects, NgRx Schematics, and NgRx Store DevTools.

To configure the data access libraries while keeping the flow of dependencies correct, we'll extract a shared environments library. Data access will be hooked up to the booking feature shell library.

Generate project tool

To generate the rest of the workspace libraries, we are going to use commands very similar to the ones we just saw.

Instead of copy-pasting snippets, let's create a script to generate a workspace library. We could have created an Angular CLI schematic for this, but we'll use a Node.js script for simplicity.



npm install --save-dev yargs
# or
yarn add --dev yargs


Enter fullscreen mode Exit fullscreen mode
Install utility for parsing command line arguments.

We'll use the package yargs for parsing command line arguments passed to our script.

Create a tools folder with a filed named generate-project.js.

Paste in the content of the Gist LayZeeDK/generate-project.js.

The tool basically runs commands demonstrated in the earlier sections with a few tweaks. We could have created a schematic, but I wanted you to be able to see the similarities without knowing about the complicated nature of schematic implementations.

yargs is used to setup up commands and parameters for the tool.

When we run node ./tools/generate-project.js, we get output similar to the following.



Usage: generate-project <command> <args>

Commands:
  generate-project application <name>     Generate application    [aliases: app]
  generate-project library <type> [name]  Generate workspace library
                                                                  [aliases: lib]

Options:
  --scope, -s      Project scope, for example "shared", "booking", or "check-in"
                                                    [string] [default: "shared"]
  --npm-scope, -p  Workspace path mapping scope, for example "workspace", or
                    "nrwl-airlines"               [string] [default: "workspace"]
  --help, -h       Show help                                           [boolean]
  --version, -v    Show version number                                 [boolean]


Enter fullscreen mode Exit fullscreen mode
Command line instructions for the generate project tool.

Let's set up an NPM script for our tool in package.json.



{
  "//": "package.json",
  "scripts": {
    "generate-project": "node ./tools/generate-project.js"
  }
}


Enter fullscreen mode Exit fullscreen mode
NPM script for the generate project tool.

Booking data access library

Now we're ready to try out the generate project tool. Run the following commands to generate the booking data access library.



npm run generate-project -- library data-access --scope=booking --npm-scope=nrwl-airlines
# or
yarn generate-project library data-access --scope=booking --npm-scope=nrwl-airlines


Enter fullscreen mode Exit fullscreen mode
Generate booking data access library.

The generated file and folder structure is shown in this figure.



libs/booking/data-access
├── src
│   ├── lib
│   │   ├── booking-data-access.module.spec.ts
│   │   └── booking-data-access.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
Initial booking data access file and folder structure.

Like our other workspace library, this one is configured with test and lint architect targets.

You know the drill. Run the commands in this listing to try them out.



ng run booking-data-access:lint

ng run booking-data-access:test --watch=false


Enter fullscreen mode Exit fullscreen mode
Lint and test the booking data access library.

Booking state with NgRx

Let's add application state for the booking domain.



npm install @ngrx/store @ngrx/effects
npm install --save-dev @ngrx/schematics
# or
yarn add @ngrx/store @ngrx/effects
yarn add --dev @ngrx/schematics


Enter fullscreen mode Exit fullscreen mode
Install NgRx package dependencies.

First, we install the NgRx packages we're going to use by running the previous commands.

Next, we're going to generate application state management with NgRx Store and NgRx Effects as seen in this listing.



ng generate @ngrx/schematics:feature +state/booking --project=booking-data-access --module=booking-data-access.module.ts --creators=true --api=false


Enter fullscreen mode Exit fullscreen mode
Generate booking feature state.

This generates the folder and files illustrated in the following figure inside libs/booking/data-access/src/lib.



libs/booking/data-access/src/lib/+state
├── booking.actions.spec.ts
├── booking.actions.ts
├── booking.effects.spec.ts
├── booking.effects.ts
├── booking.reducer.spec.ts
├── booking.reducer.ts
├── booking.selectors.spec.ts
└── booking.selectors.ts


Enter fullscreen mode Exit fullscreen mode
File and folder structure for booking feature state.

The NgRx feature schematic registered our booking feature store and effects with the booking data access module as per this listing.



// booking-data-access.module.ts
import { NgModule } from '@angular/core';
import { EffectsModule } from '@ngrx/effects';
import { StoreModule } from '@ngrx/store';

import { BookingEffects } from './+state/booking.effects';
import * as fromBooking from './+state/booking.reducer';

@NgModule({
  imports: [StoreModule.forFeature(fromBooking.bookingFeatureKey, fromBooking.reducer), EffectsModule.forFeature([BookingEffects])],
})
export class BookingDataAccessModule {}


Enter fullscreen mode Exit fullscreen mode
Booking data access module with feature store and effects.

Finally, we'll register it in the booking feature shell module as shown in the following listing.



// booking-feature-shell.module.ts
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { BookingDataAccessModule } from '@nrwl-airlines/booking/data-access';

import { ShellComponent } from './shell/shell.component';

const routes: Routes = [
  {
    path: '',
    component: ShellComponent,
    children: [],
  },
];

@NgModule({
  declarations: [ShellComponent],
  exports: [RouterModule],
  imports: [
    RouterModule.forRoot(routes),
    BookingDataAccessModule, // ?
    CommonModule,
  ],
})
export class BookingFeatureShellModule {}


Enter fullscreen mode Exit fullscreen mode
Booking feature shell module with booking data access registered.

Shared data access library

Let's move on to the shared data access library. This workspace library will be shared between all the booking and check-in applications.



npm run generate-project -- library data-access --scope=shared --npm-scope=nrwl-airlines
# or
yarn generate-project library data-access --scope=shared --npm-scope=nrwl-airlines


Enter fullscreen mode Exit fullscreen mode
Generate shared data access library.

Use the previous commands to generate the shared data access library. By now, you know what this generates and configures.

A shared data access library sets up root level configuration for application state management and data services. This could be adding HTTP interceptors for security and API paths.

In our case, we'll initialise the root store and effects of our applications.



npm install @ngrx/store-devtools
# or
yarn add @ngrx/store-devtools


Enter fullscreen mode Exit fullscreen mode
Install the NgRx Store development tools package.

First, we install the NgRx Store development tools package with the previous commands.



ng generate @ngrx/schematics:store app --project=shared-data-access --module=shared-data-access.module.ts --root --state-path=+state --state-interface=app-state


Enter fullscreen mode Exit fullscreen mode
Generate the root store in the shared data access library.

The previous listing shows how to generate and register the root store in the shared data access library. Unfortunately, we stumble upon a few issues when doing this, at least at the time of writing.



// shared-data-access.module.ts
import { NgModule } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';

import { environment } from '../environments/environment';
import { reducers, metaReducers } from './+state';

@NgModule({
  imports: [
  StoreModule.forRoot(reducers, {
      metaReducers,
      runtimeChecks: {
        strictStateImmutability: true,
        strictActionImmutability: true,
      },
    })!environment.production ? StoreDevtoolsModule.instrument() : []]
})
export class SharedDataAccessModule {}


Enter fullscreen mode Exit fullscreen mode
Default root store and store development tools registration in the shared data access module.

There are a few problems in the generated code shown in the previous listing.

First, a comma is missing after the import of StoreModule.forRoot. This is easily fixed as per this listing.



// shared-data-access.module.ts
import { NgModule } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';

import { environment } from '../environments/environment';
import { metaReducers, reducers } from './+state';

@NgModule({
  imports: [
    StoreModule.forRoot(reducers, {
      metaReducers,
      runtimeChecks: {
        strictStateImmutability: true,
        strictActionImmutability: true,
      },
    }),
    !environment.production ? StoreDevtoolsModule.instrument() : [],
  ],
})
export class SharedDataAccessModule {}


Enter fullscreen mode Exit fullscreen mode
Shared data access module with corrected store development tools registration.

Using the environment configuration in a workspace library

The final problem we need to solve is that because our registration is done in a workspace library, we don't have access to the environment file which is usually in an application project.

We don't have path mappings that we can use to import from an application project. We shouldn't add them either! Workspace libraries must never depend on application projects – only the other way around.

How do we solve this dependency issue?

We solve it by extracting a shared environments workspace library from our application project as described in my article "Tiny Angular application projects in Nx workspaces".

As we don't expect any differences between our application environment settings, we'll make the library shared between all application projects.



npm run generate-project -- library environments --scope=shared --npm-scope=nrwl-airlines
#or
yarn generate-project library environments --scope=shared --npm-scope=nrwl-airlines

npx rimraf libs/shared/environments/src/lib/*.*

mv apps/booking/booking-desktop/src/environments/*.* libs/shared/environments/src/lib

"export * from './lib/environment';" > ./libs/shared/environments/src/index.ts

npx rimraf apps/booking/booking-desktop/src/environments


Enter fullscreen mode Exit fullscreen mode
Generate the shared environments library.

After running the previous commands, our environments library has the file and folder structure shown in this figure.



libs/shared/environments
├── src
│   ├── lib
│   │   ├── environment.prod.ts
│   │   └── environment.ts
│   ├── index.ts
│   └── test.ts
├── README.md
├── karma.conf.js
├── tsconfig.lib.json
├── tsconfig.spec.json
└── tslint.json


Enter fullscreen mode Exit fullscreen mode
The file and folder structure of the shared environments library.

Because we moved the environment file, we have to update the fileReplacements option for the production configuration of our application's build architect target. This is shown in the following listing.



{
  "//": "angular.json",
  "projects": {
    "booking-desktop": {
      "architect": {
        "build": {
          "configurations": {
            "production": {
              "fileReplacements": [
                {
                  "replace": "libs/shared/environments/src/lib/environment.ts",
                  "with": "libs/shared/environments/src/lib/environment.prod.ts"
                }
              ]
            }
          }
        }
      }
    }
  }
}


Enter fullscreen mode Exit fullscreen mode
Updated `fileReplacements` option which uses the environments library.

Now we're ready to correct the dependency in our shared data access module as seen in this code listing.



// shared-data-access.module.ts
import { NgModule } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { environment } from '@nrwl-airlines/shared/environments';

import { metaReducers, reducers } from './+state';

@NgModule({
  imports: [
    StoreModule.forRoot(reducers, {
      metaReducers,
      runtimeChecks: {
        strictStateImmutability: true,
        strictActionImmutability: true,
      },
    }),
    !environment.production ? StoreDevtoolsModule.instrument() : [],
  ],
})
export class SharedDataAccessModule {}


Enter fullscreen mode Exit fullscreen mode
Shared data access module using the shared environments library.

Don't forget to update the import statement in the booking desktop application's main.ts file. This is shown here.



// main.ts
import { enableProdMode } from '@angular/core';
import { environment } from '@nrwl-airlines/shared/environments';

if (environment.production) {
  enableProdMode();
}


Enter fullscreen mode Exit fullscreen mode
Main file using the shared environments library

To use the shared data access library in the booking desktop application, we need to register our shared data access module in the booking feature shell module.



// booking-feature-shell.module.ts
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { BookingDataAccessModule } from '@nrwl-airlines/booking/data-access';
import { SharedDataAccessModule } from '@nrwl-airlines/shared/data-access';

import { ShellComponent } from './shell/shell.component';

const routes: Routes = [
  {
    path: '',
    component: ShellComponent,
    children: [],
  },
];

@NgModule({
  declarations: [ShellComponent],
  exports: [RouterModule],
  imports: [
    RouterModule.forRoot(routes),
    SharedDataAccessModule, // ?
    BookingDataAccessModule,
    CommonModule,
  ],
})
export class BookingFeatureShellModule {}


Enter fullscreen mode Exit fullscreen mode
Booking feature shell module with shared data access registered.

Note that we register the shared data access module before the domain-specific data access module BookingDataAccessModule as Angular modules import order matters. For example, the root store needs to be registered before any feature store.



ng generate @ngrx/schematics:effect +state/app --project=shared-data-access --module=shared-data-access.module.ts --root --creators=true --api=false


Enter fullscreen mode Exit fullscreen mode
Generate root effects in the shared data access library.

Let's also generate root effects with the commands in the listing above. This will register the root effects in the shared data access module as seen in this listing.



// shared-data-access.module.ts
import { NgModule } from '@angular/core';
import { EffectsModule } from '@ngrx/effects';
import { StoreModule } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { environment } from '@nrwl-airlines/shared/environments';

import { metaReducers, reducers } from './+state';
import { AppEffects } from './+state/app.effects';

@NgModule({
  imports: [
    StoreModule.forRoot(reducers, {
      metaReducers,
      runtimeChecks: {
        strictStateImmutability: true,
        strictActionImmutability: true,
      },
    }),
    !environment.production ? StoreDevtoolsModule.instrument() : [],
    EffectsModule.forRoot([AppEffects]),
  ],
})
export class SharedDataAccessModule {}


Enter fullscreen mode Exit fullscreen mode
Shared data access with registered root effects.

Our shared data access library ends up having the file and folder structure displayed in the following figure.



libs/shared/data-access
├── src
│   ├── lib
│   │   ├── +state
│   │   │   ├── app.effects.spec.ts
│   │   │   ├── app.effects.ts
│   │   │   └── index.ts
│   │   ├── shared-data-access.module.spec.ts
│   │   └── shared-data-access.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
The file and folder structure of the shared data access library.

If we lint the workspace library now, we have a few code smells to address in the generated code. I'll leave this as an exercise for you to do. Consider circling back and doing the same for the booking data access library.

Conclusion

Start the application by running the ng run booking-desktop:serve command.

The booking desktop application with the NgRx Store DevTools open.

The previous screenshot shows how the booking desktop application looks with the NgRx Store DevTools open.



nrwl-airlines
├── apps
│   └── booking
│       ├── booking-desktop
│       └── booking-desktop-e2e
├── libs
│   ├── booking
│   │   ├── data-access
│   │   └── feature-shell
│   └── shared
│       ├── data-access
│       └── environments
└── tools


Enter fullscreen mode Exit fullscreen mode
Workspace folder structure after Part 2.

At this point, our workspace has project folders as seen in the previous figure.

In Part 2, we started by setting up our custom generate project tool. It automated the steps we did manually to generate application, end-to-end, and workspace library projects in Part 1.

First, we used the tool to generate the booking data access library. We then installed NgRx Store, NgRx Effects, and NgRx Schematics to our package dependencies.

We used the NgRx Schematics to generate a +state folder in our workspace library with NgRx feature effects, actions, reducers, and selectors.

To register the feature state in the booking desktop application, we imported the booking data access Angular module in the booking feature shell Angular module.

After the booking data access library, we generated the shared data access library which is intended to configure and initialise the root NgRx state and effects for both the booking and check-in (still to come) applications.

We added the NgRx Store DevTools package, used the NgRx schematics to create a +state folder with root reducers, meta reducers, root effects and configuration of runtime checks and NgRx Store DevTools instrumentation.

Some of the configuration needed to know whether the application was running in the development or production mode. This state is usually defined in the environment object of the application project.

Since the shared data access is a library project, it must not depend on an application project. Because of this, we used a recipe from the article "Tiny Angular application projects in Nx workspaces" to extract a shared environments library. We set up file replacements in our builders.

With this in place, both our booking desktop application project and the shared data access library project was able to depend on the environment configuration.

We hooked everything up by registering data access in the booking feature shell Angular module. As we saw in the screenshot in this conclusion, NgRx Store DevTools shows us that everything is set up correctly.

That's it for Part 2 of this tutorial. In Part 3, we'll create the passenger info and flight search feature libraries with routing and set up the mobile booking application project and its end-to-end test project and create a mobile-specific template for the flight search component.

Resources

For the impatient programmer, the full solution is in the LayZeeDK/ngx-nrwl-airlines-workspace GitHub repository

💖 💪 🙅 🚩
layzee
Lars Gyrup Brink Nielsen

Posted on March 31, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related