Original cover photo by Edgar Chaparro on Unsplash.
Original publication date: 2020-05-12.
This tutorial is part of the Angular Architectural Patterns series.
The first installment walked through scaffolding the booking desktop application, its dedicated end-to-end test project, and the booking feature shell workspace library.
Now we're going to build a custom generator that reproduces the manual steps from Part 1. With it, we'll scaffold both the shared and booking data access libraries, wiring in NgRx Store, NgRx Effects, NgRx Schematics, and NgRx Store DevTools along the way.
To keep the dependency graph clean while structuring the data access libraries, we'll pull out a shared environments library. The data access layer will then connect back into the booking feature shell.
Generate project tool
The remaining libraries in the workspace can be produced with commands that closely mirror what we've already seen.
Rather than repeatedly copying code snippets, we'll write a script that handles library generation. An Angular CLI schematic would have worked too, but a Node.js script keeps things straightforward.
npm install --save-dev yargs
# or
yarn add --dev yargs
For parsing command line arguments in our script, we will rely on the yargs package.
Set up a directory named tools and within it place a file called generate-project.js.
Copy the contents from the Gist LayZeeDK/generate-project.js and paste them into that file.
This utility executes the commands covered in prior sections, albeit with some modifications. Although crafting a schematic was an option, my goal here was to highlight the parallels without requiring familiarity with how intricate schematics can be underneath.
Via yargs, the tool defines its available commands and accepts the corresponding parameters.
Executing node ./tools/generate-project.js produces output akin to what is shown below.
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]
We'll add an NPM script that points to our utility inside package.json.
{
"//": "package.json",
"scripts": {
"generate-project": "node ./tools/generate-project.js"
}
}
Booking data access library
With this setup in place, we can put the generate project tool to work. Execute these commands to create 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
You can see the resulting file and directory layout in the figure below.
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
Just like the earlier workspace library, this project includes test and lint architect targets.
You’re familiar with the procedure—execute the commands shown below to see them in action.
ng run booking-data-access:lint
ng run booking-data-access:test --watch=false
NgRx-based booking state
Now we'll introduce NgRx to manage the booking feature's state.
npm install @ngrx/store @ngrx/effects
npm install --save-dev @ngrx/schematics
# or
yarn add @ngrx/store @ngrx/effects
yarn add --dev @ngrx/schematics
To begin, we install the NgRx packages required for this setup by executing the prior set of commands.
Following that, we generate the application's state management using NgRx Store and NgRx Effects, as illustrated in the listing below.
ng generate @ngrx/schematics:feature +state/booking --project=booking-data-access --module=booking-data-access.module.ts --creators=true --api=false
The resulting directory structure and its contents under libs/booking/data-access/src/lib are shown below.
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
The NgRx feature schematic linked the booking feature store and its effects to the booking data access module, matching what you see in the listing below.
// 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 {}
Last but not least, the registrar shell module for the booking feature will include the registration as demonstrated in the listing below.
// 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 {}
Shared data access library
Next up is the shared data access library. Every booking and check-in application in this workspace will rely on this particular library.
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
Run the same commands as before to create the shared data access library. At this stage, you're already familiar with what gets scaffolded and wired up by that process.
With a shared data access library in place, root-level configuration for state management and data services is established. That might include things like HTTP interceptors for authentication or API endpoint definitions.
Here, we'll set up the root store and effects for both applications.
npm install @ngrx/store-devtools
# or
yarn add @ngrx/store-devtools
To begin, we add the NgRx Store development tools package using the same commands as before.
ng generate @ngrx/schematics:store app --project=shared-data-access --module=shared-data-access.module.ts --root --state-path=+state --state-interface=app-state
The snippet above demonstrates how to create the root store and wire it up in the shared data access library. That said, at least as of now, we run into a handful of problems when we follow this approach.
// 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 {}
Several issues exist within the code that was generated in the listing above.
To begin with, the import statement for StoreModule.forRoot lacks a trailing comma. Correcting this is straightforward, as demonstrated in the following 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 {}
Using the environment configuration in a workspace library
The last issue we have to address is that our registration lives in a workspace library, so the environment file—typically found in an application project—is not available to it.
Imports from application projects aren't possible through path mappings, and they shouldn't be created either. Workspace libraries must never depend on their application counterparts; the dependency flows the other way.
So, how can this dependency problem be overcome?
The approach is to carve out an environments workspace library from the application project, as detailed in my post "Tiny Angular application projects in Nx workspaces".
Given that the environment settings for our apps will match, we'll share this library across every application project.
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
Once the commands from the previous section have been executed, the environments library will contain the directory layout depicted in the figure below.
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
Since the environment file has been relocated, the fileReplacements option in the production configuration for the app's build architect target must now be adjusted. The relevant update appears below.
{
"//": "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"
}
]
}
}
}
}
}
}
}
With that in place, we can now update the dependency within our shared data access module, as illustrated in the snippet below.
// 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 {}
Remember to refresh the import in the desktop booking app's main.ts file, as illustrated below.
// main.ts
import { enableProdMode } from '@angular/core';
import { environment } from '@nrwl-airlines/shared/environments';
if (environment.production) {
enableProdMode();
}
Before the booking desktop app can leverage the shared data access library, its shared data access module must be declared inside 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 {}
Keep in mind that the shared data access module must be imported before the domain-specific
BookingDataAccessModule, since the order of Angular module imports is significant. As an example, the root store must be set up ahead of 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
To set up the root effects for the workspace, use the commands provided in the preceding listing. Those commands will add the root effects to the shared data access module, mirroring the structure shown 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 {}
The resulting layout for our shared data access library is shown in the figure below.
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
Linting the workspace library at this stage surfaces a few code smells in the generated files. I’ll let you tackle those on your own. It’s worth revisiting later to apply the same cleanup to the booking data access library.
Conclusion
To launch the application, execute the ng run booking-desktop:serve command.
The screenshot above displays the booking desktop application with the NgRx Store DevTools panel active.
nrwl-airlines
├── apps
│ └── booking
│ ├── booking-desktop
│ └── booking-desktop-e2e
├── libs
│ ├── booking
│ │ ├── data-access
│ │ └── feature-shell
│ └── shared
│ ├── data-access
│ └── environments
└── tools
By this stage, the workspace contains the project folders illustrated in the previous figure.
Part 2 opened with the creation of our bespoke project generation tool, which handled the same steps we had previously executed by hand to generate application, end-to-end, and workspace library projects in Part 1.
Initially, the tool was employed to produce the booking data access library. Following that, we added NgRx Store, NgRx Effects, and NgRx Schematics to the package dependencies.
Through the NgRx Schematics, we created a +state folder inside the workspace library, containing NgRx feature effects, actions, reducers, and selectors.
To wire up the feature state in the booking desktop app, the booking data access Angular module was brought into the booking feature shell Angular module.
Subsequent to the booking data access library, we built the shared data access library, meant to set up and initialize the root NgRx state and effects across both the booking and the upcoming check-in applications.
We incorporated the NgRx Store DevTools package, and using the NgRx schematics, we generated a +state folder that holds root reducers, meta reducers, root effects, runtime checks configuration, and NgRx Store DevTools instrumentation.
A portion of the configuration needed to know whether the app was operating in development or production mode—a detail typically captured in the application project's environment object.
As the shared data access is a library project, it cannot rely on an application project. To address that, we applied a method from the article "Tiny Angular application projects in Nx workspaces" to create a standalone environments library. Our builders were updated with the necessary file replacements.
After that, both the booking desktop application project and the shared data access library project could depend on the environment configuration.
To finalize the integration, we registered data access in the booking feature shell Angular module. As the screenshot in this conclusion demonstrates, NgRx Store DevTools confirms that everything is working as intended.
That wraps up Part 2 of this guide. Moving to Part 3, we'll develop the passenger info and flight search feature libraries with routing, set up the mobile booking application project along with its end-to-end test project, and craft a mobile-specific template for the flight search component.
Resources
Those who want to jump straight ahead can find the complete codebase in the LayZeeDK/ngx-nrwl-airlines-workspace GitHub repository.

