Full-stack monorepo — Part II: Nest and Angular

Burak Tasci
Burak Tasci
Published in
8 min readJan 21, 2020

On this second part of series, we’ll extend our application in JavaScript both for the client and server side; by adding microservices in Node.js using the Nest framework and create the user-facing part which consumes the API, in Angular.

Nest (NestJS) is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with and fully supports TypeScript (yet still enables developers to code in pure JavaScript) and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Reactive Programming).

Since both Angular and Nest come with nice CLI tools that deliver an application that already works — right out of the box, we’ll use Nx, providing a set of workspace schematics and builders to extend the CLI functionality to kick-off the things faster and a lot easier.

Getting started

In addition to the Node.js runtime which we installed during the previous part, to get started you’ll need to install Angular CLI.

$ yarn add global @angular/cli

Then, run the nx-workspace command at the repository root, to create the an empty workspace located in nodejs directory.

Note: You must have cleaned the contents of nodejs directory beforehand, otherwise the CLI will return and error.

$ rm -rf nodejs
$ ng config -g cli.packageManager yarn
$ yarn create nx-workspace nodejs --preset=empty --cli=angular
$ cd nodejs

This will create an empty workspace powered by Angular CLI, and use yarn as the package manager throughout the further stages.

Docker images for the apps

Even though the CLI offers a development server (ng serve) with hot-reloading features, etc. we’ll focus on getting a production-ready lean image.

We start by creating two base Dockerfiles in nodejs directory: Dockerfile.angular and Dockerfile.nest, with the instructions below.

  • Dockerfile.angular
# Start from node base image
FROM node:12-alpine as builder
# Set the current working directory inside the container
WORKDIR /build
# Copy package.json, yarn.lock files and download deps
COPY package.json yarn.lock ./
RUN yarn global add @angular/cli
RUN yarn
# Copy sources to the working directory
COPY . .
# Set the node environment
ARG node_env=production
ENV NODE_ENV $node_env
# Build the Node.js app
ARG project
RUN ng build $project
# Start a new stage from nginx
FROM nginx:alpine
WORKDIR /dist# Copy the build artifacts from the previous stage
ARG project
COPY --from=builder /build/dist/apps/$project /usr/share/nginx/html
# Set the port number and expose it
ARG port=80
ENV PORT $port
EXPOSE $port
# Run nginx
CMD ["nginx", "-g", "daemon off;"]
  • Dockerfile.nest
# Start from node base image
FROM node:12-alpine as builder
# Set the current working directory inside the container
WORKDIR /build
# Copy package.json, yarn.lock files and download deps
COPY package.json yarn.lock ./
RUN yarn global add @angular/cli
RUN yarn
# Copy sources to the working directory
COPY . .
# Set the node environment
ARG node_env=production
ENV NODE_ENV $node_env
# Build the Node.js app
ARG project
RUN ng build $project
# Start a new stage from node
FROM node:12-alpine
WORKDIR /dist# Set the node environment (nginx stage)
ARG node_env=production
ENV NODE_ENV $node_env
# Copy the build artifacts from the previous stage
ARG project
COPY --from=builder /build/dist/apps/$project .
COPY package.json yarn.lock ./
RUN yarn
# Run nginx
CMD ["node", "main.js"]

And the following Dockerfile.test to run test suites.

# Start from node base image
FROM node:12-alpine as builder
# Set the current working directory inside the container
WORKDIR /test
# Copy package.json, yarn.lock files and download deps
COPY package.json yarn.lock ./
RUN yarn global add @angular/cli
RUN yarn
# Copy sources to the working directory
COPY . .
# Run the test suite
ARG project
RUN ng test $project

Directory structure

At this point, we have (more or less) the following directory structure for our Node.js workspace, and we’re just one-step before adding a Nest and Angular application.

/nodejs
├── apps
│ └── ...
├── libs
│ └── ...
├── tools
│ └── ...
├── angular.json
├── Dockerfile.angular
├── Dockerfile.nest
├── Dockerfile.test
├── nx.json
├── package.json
├── tsconfig.json
└── ...

The apps and libs directory contain all the projects in the workspace, in a way apps having the minimal amount of code required to package a bunch of reusable libraries together.

Finally, the tools directory contains custom scripts and workspace schematics, which is not a topic covered by this article.

Adding the Angular app

Our first step is to add an Angular application and as a prerequisite we need to start adding Angular capabilities to the workspace — by running the following command on the terminal, still in nodejs directory.

$ ng add @nrwl/angular --unit-test-runner=jest --e2e-test-runner=cypress

With the Angular capabilities (and test runner configuration) at hand, we can finally generate the frontend app using the CLI.

$ ng g @nrwl/angular:app frontend --style=scss --routing

Then it will take a while for the CLI to generate the frontend application (also the e2e test suites), and for yarn to resolve and install dependencies. Grab a cup of coffee (better beer) meanwhile.

It’s actually a matter of just few minutes for the CLI to finish its job, and then we can confirm the directory structure below.

/nodejs
├── apps
│ ├── frontend
│ │ └── ...
│ └── frontend-e2e
│ └── ...
├── libs
├── tools
├── angular.json
├── Dockerfile.angular
├── Dockerfile.nest
├── Dockerfile.test
├── nx.json
├── package.json
├── tsconfig.json
└── ...

Adding a Nest service

The second step is to add a Node.js service and we’ll use the Nest framework since it uses similar techniques to Angular — use of modules, providers, etc. and controllers in place of components.

We need to begin by adding Nest capabilities to the workspace by running the following command on the terminal …

$ ng add @nrwl/nest

… followed by generating a Nest application called ocean (since I miss being on beach vacation these days).

$ ng g @nrwl/nest:app ocean --directory

This time we don’t need to wait really much until CLI finishes the job and generates our first Nest application, located in apps/ocean directory.

/nodejs
├── apps
│ ├── frontend
│ │ └── ...
│ └── frontend-e2e
│ │ └── ...
│ └── ocean
│ └── ...
├── libs
├── tools
├── angular.json
├── Dockerfile.angular
├── Dockerfile.nest
├── Dockerfile.test
├── nx.json
├── package.json
├── tsconfig.json
└── ...

Consuming the API

First of all, we need a small change at the Nest application since we have to enable CORS mechanism in order to allow the ocean service to be requested from frontend domain.

  • nodejs/apps/ocean/src/main.ts
import { NestFactory } from '@nestjs/core';

import { AppModule } from './app/app.module';

async function bootstrap() {
const app = await NestFactory.create(AppModule, { cors: true });
const globalPrefix = 'api';
app.setGlobalPrefix(globalPrefix);
const port = process.env.port || 3333;
await app.listen(port, () => {
console.log('Listening at http://localhost:' + port + '/' + globalPrefix);
});
}

bootstrap();

Later on, we need to send a get request from the frontend app to ocean service, and then write the result on the home page.

Here are the changes on the frontend app.

  • nodejs/apps/ocean/src/app/app.module.ts
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RouterModule } from '@angular/router';

import { AppComponent } from './app.component';

@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
RouterModule.forRoot([], { initialNavigation: 'enabled' }),
HttpClientModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule {}
  • nodejs/apps/ocean/src/app/app.component.ts
import { HttpClient } from '@angular/common/http';
import { Component, OnInit } from '@angular/core';
import { map, take } from 'rxjs/operators';

@Component({
selector: 'nodejs-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
title = 'frontend';
ocean = '';

constructor(private readonly http: HttpClient) {
}

ngOnInit(): void {
this.http.get<{ message: string }>('http://localhost:8003/api')
.pipe(take(1), map(cur => cur.message))
.subscribe(res => this.ocean = res);
}
}
  • nodejs/apps/ocean/src/app/app.component.spec.ts
import { TestBed, async } from '@angular/core/testing';
import { AppComponent } from './app.component';
import { RouterTestingModule } from '@angular/router/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing';

describe('AppComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [RouterTestingModule, HttpClientTestingModule],
declarations: [AppComponent]
}).compileComponents();
}));

it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy();
});

it(`should have as title 'frontend'`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app.title).toEqual('frontend');
});

it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.debugElement.nativeElement;
expect(compiled.querySelector('h1').textContent).toContain(
'Welcome to frontend!'
);
});
});
  • nodejs/apps/ocean/src/app/app.component.html
<header class="flex">
<img
alt="Nx logo"
width="75"
src="https://nx.dev/assets/images/nx-logo-white.svg"
/>
<h1>Welcome to {{ title }}!</h1>
</header>
<main>
<h2>Message from Ocean</h2>
<p>
{{ ocean }}
</p>
</main>
<router-outlet></router-outlet>

Docker Compose configuration

We need to update the Docker Compose configuration since we have to use two more Docker containers for the frontend and ocean apps.

  • docker-compose.yml
version: '3'
services:
calypso:
build:
context: ./golang
dockerfile: ./Dockerfile
args:
project: ./cmd/calypso/
environment:
- PORT=3001
- APP_NAME=calypso
ports:
- 8001:3001
restart: on-failure
volumes:
- calypso_vol:/usr/src/calypso/
networks:
- monorepo_net
echo:
build:
context: ./golang
dockerfile: ./Dockerfile
args:
project: ./cmd/echo/
environment:
- PORT=3001
- APP_NAME=echo
ports:
- 8002:3001
restart: on-failure
volumes:
- echo_vol:/usr/src/echo/
networks:
- monorepo_net
frontend:
build:
context: ./nodejs
dockerfile: ./Dockerfile.angular
args:
project: frontend
ports:
- 8000:80
restart: on-failure
volumes:
- frontend_vol:/usr/src/frontend/
- node_modules:/usr/src/frontend/node_modules/
networks:
- monorepo_net
ocean:
build:
context: ./nodejs
dockerfile: ./Dockerfile.nest
args:
project: ocean
ports:
- 8003:3333
restart: on-failure
volumes:
- ocean_vol:/usr/src/ocean/
- node_modules:/usr/src/ocean/node_modules/
networks:
- monorepo_net

volumes:
calypso_vol:
echo_vol:
frontend_vol:
ocean_vol:
node_modules:

networks:
monorepo_net:
driver: bridge
  • docker-compose.test.yml
version: '3'
services:
pkg_test:
build:
context: ./golang
dockerfile: ./Dockerfile.test
args:
project: ./pkg/...
volumes:
- testing_vol:/usr/src/pkg/
networks:
- monorepo_net
calypso_test:
build:
context: ./golang
dockerfile: ./Dockerfile.test
args:
project: ./cmd/calypso/...
depends_on:
- pkg_test
volumes:
- testing_vol:/usr/src/calypso/
networks:
- monorepo_net
echo_test:
build:
context: ./golang
dockerfile: ./Dockerfile.test
args:
project: ./cmd/echo/...
depends_on:
- pkg_test
volumes:
- testing_vol:/usr/src/echo/
networks:
- monorepo_net
frontend_test:
build:
context: ./nodejs
dockerfile: ./Dockerfile.test
args:
project: frontend
volumes:
- testing_vol:/usr/src/frontend/
networks:
- monorepo_net
ocean_test:
build:
context: ./nodejs
dockerfile: ./Dockerfile.test
args:
project: ocean
volumes:
- testing_vol:/usr/src/ocean/
networks:
- monorepo_net

volumes:
testing_vol:

networks:
monorepo_net:
driver: bridge

Put it all together

If you followed everything correctly, you may run the tests by using make test, and run the containers for all the Go services and newly added Node.js apps by using make start.

And then start the application by navigating to http://localhost:8000.

Angular app consuming the API

You can compare the payload by sending a get request to http://localhost:8003/api to check everything works as expected.

Nest app on port 8003

Voila! They’re identical, and we finally managed to add an Angular app and a Nest app to our monorepo, consume the API and to dockerize them.

Wrapping it up

Things were very lean and even more straightforward on this second part. I had to skip several steps such as consuming the Go services calypso and echo, configuring their CORS, etc. but the idea is the same.

My aim was to keep the focus on step-by-step explaining how to organize that polyglot group of projects and use Docker Compose to orchestrate them.

Meanwhile, keep in mind that this fullstack-monorepo project is currently (yeah, still) very much WIP and still more is underway. I created this tag to keep the point where we are with everything explained in this article.

Burak Tasci (fulls1z3)
https://www.linkedin.com/in/buraktasci
http://stackoverflow.com/users/7047325/burak-tasci
https://github.com/fulls1z3

--

--

Burak Tasci
Burak Tasci

Full-stack software engineer and enthusiastic power-lifter