When building a full-stack application it is desirable to avoid code duplication. However, this can be challenging when you have to share some logic or types between frontend and backend.

In this article, I will show how this can be very simple to use an Nx monorepo. We will build a simple Journal app in Angular and connect it with an API in NestJS. I've chosen to use these frameworks since both make use of TypeScript and they fit very well with what we are trying to accomplish.

However, these same instructions could easily be adapted for other frameworks like React and NodeJS since they are also supported in Nx monorepos.

Create an Nx Workspace#

Let’s start by creating an Nx Workspace. This can be achieved by running the following command:

$ npx create-nx-workspace trombonix --preset=empty --cli=angular

Here trombonix is the name of our workspace, we use the preset empty which scaffolds no apps or libs initially. Since we are building an Angular application, we decided also to use  Angular CLI in our workspace. Now if we cd into the trombonix folder:

$ cd trombonix

We can see that an empty project was scaffolded:

trombonix
├── apps
├── dist
├── libs
├── node_modules
├── tools
├── README.md
├── angular.json
├── jest.config.js
├── nx.json
├── package.json
├── tsconfig.json
├── tslint.json
└── yarn.lock

Our workspace is empty and we can add the technologies we want to use. In this project, we’re building a full-stack application where the journal app will be written using Angular and the API using NestJS, therefore we need to install these schematics to our workspace by running:

$ ng add @nrwl/nest --defaults

$ ng add @nrwl/angular --defaults

The --defaults option tells the schematic to use the default options, which in this case means it will default to Jest and Cypress as unit test and E2E test suites respectively. This aspects will not be covered in this article.

Now everything is set for us to start creating our journal full-stack application.

Create the NestJS API#

If you’re familiar with Angular syntax, the angularish coding style of NestJS will make it easy to understand what is going on here. Let's create an API by using the following command:

$ ng generate @nrwl/nest:app api --directory

Here we use the option --directory to indicate that we want the API to be in the root of our apps folder. The folder structure looks like this:

apps/api
├── src
│   ├── app
│   │   ├── app.controller.spec.ts
│   │   ├── app.controller.ts
│   │   ├── app.module.ts
│   │   ├── app.service.spec.ts
│   │   └── app.service.ts
│   ├── assets
│   ├── environments
│   │   ├── environment.prod.ts
│   │   └── environment.ts
│   └── main.ts
├── jest.config.js
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.spec.json
└── tslint.json

Let’s update app.service.ts to implement the basic get, save, and delete functions. For simplicity, we’ll implement an in-memory persistence.

import { Injectable } from '@nestjs/common';

export interface JournalEntry {
  title: string;
  body: string;
  timestamp?: Date;
}

@Injectable()
export class AppService {

  entries: JournalEntry[] = [{
     title: 'example title',
     body: 'example journal entry',
    timestamp: new Date()
  }];

  getData(): JournalEntry[] {
    return this.entries;
  } 

  create(entry: JournalEntry) {
    const newEntry = {
      title: entry.title,
      body: entry.body,
      timestamp: new Date()
    };   

    this.entries = [...this.entries, newEntry]; 
  } 

  delete(id: number) {
    this.entries = this.entries.filter((_, idx) => idx !== id); 
  }
}

Then we need to update app.controller.ts to expose the verbs GET, POST, and DELETE in our entries endpoint.

import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post } from '@nestjs/common';

import { AppService, JournalEntry } from './app.service';

@Controller('entries')
export class AppController {
 constructor(private readonly appService: AppService) {}

 @Get()
 getData() {
   return this.appService.getData();
 }

 @Post()
 create(@Body() body: JournalEntry) {
   return this.appService.create(body);
 }

 @Delete(':id')
 delete(@Param('id', ParseIntPipe) id: number) {
   return this.appService.delete(id);
 }
}

That’s it! To run our API and check the results we use:

$ ng serve api

Now if we navigate to http://localhost:3333/api/entries , we can see that the example entry is returned.

Create the Journal App

This command uses the Nrwl schematics for Angular to create an app following the workspace best practices. Let's create our journal app:

$ ng generate @nrwl/angular:app journal --routing=false --style=scss --backend-project=api

For this command we set backend-project=api. This creates a proxy.conf.json in our app, which sets the Angular proxy to redirect the requests made to  api to our API avoiding problems with CORS.

apps/journal
├── src
│   ├── app
│   │   ├── app.component.html
│   │   ├── app.component.scss
│   │   ├── app.component.spec.ts
│   │   ├── app.component.ts
│   │   └── app.module.ts
│   ├── assets
│   ├── environments
│   │   ├── environment.prod.ts
│   │   └── environment.ts
│   ├── favicon.ico
│   ├── index.html
│   ├── main.ts
│   ├── polyfills.ts
│   ├── styles.scss
│   └── test-setup.ts
├── browserslist
├── jest.config.js
├── proxy.conf.json
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.spec.json
└── tslint.json

In order to make our UI look nicer, we'll add a small CSS library, that I love, called Bulma. The installation is very simple, we just need to install the package:

$ yarn add bulma

And add the import in our styles.scss file:

@import 'bulma';

body {
  height: 100vh;
  background-color: #fcfcfc;
}

Since we're communicating with an API, let's create a angular service to hold our HTTP requests. We can use the Angular CLI to scaffold it for us:

$ ng generate @nrwl/angular:service services/data --project=journal

Let's also add the HttpClientModule to the root module of our app at app.module.ts

import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

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

@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule, HttpClientModule],
  bootstrap: [AppComponent]
})
export class AppModule {}

Now we need to connect our service to our API using the HttpClient:

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';

export interface JournalEntry {
  title: string;
  body: string;
  timestamp?: Date;
}

@Injectable({
  providedIn: 'root'
})
export class DataService {
  constructor(private http: HttpClient) {}

 fetch() {
   return this.http.get<JournalEntry[]>('/api/entries');
 }

 save(entry: JournalEntry) {
   return this.http.post('/api/entries', entry);
 }

 delete(id: number) {
   return this.http.delete(`/api/entries/${id}`);
 }
}

Note that we duplicated the JournalEntry interface here, in order to safely type our response, that's okay for now. We'll see how to avoid this duplication in the next section.

With our service in place we can code a simple component that uses the data.service.ts to read and register entries in our journal. We can start with app.component.ts:

import { Component, OnInit } from '@angular/core';

import { DataService, JournalEntry } from './services/data.service';

@Component({
 selector: 'trombonix-root',
 templateUrl: './app.component.html',
 styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
 entries: JournalEntry[];

 constructor(private dataService: DataService) {}

 ngOnInit(): void {
   this.fetch();
 }

 fetch() {
   this.dataService.fetch().subscribe({
     next: (response: JournalEntry[]) => (this.entries = response)
   });
 }

 onSaveEntry(titleInput: HTMLInputElement, bodyInput: HTMLInputElement) {
   const entry = {
     title: titleInput.value,
     body: bodyInput.value
   };
   this.dataService.save(entry).subscribe({
     next: () => {
       this.fetch();
       titleInput.value = '';
       bodyInput.value = '';
     }
   });
 }

 onDeleteEntry(index: number) {
   this.dataService.delete(index).subscribe({
     next: () => {
       this.fetch();
     }
   });
 }
}

Then add some UI by changing the template in  app.component.html:

<h1 class="title">One line Journal</h1>

<input #titleInput class="input is-fullwidth" placeholder="Title" />

<input
 #bodyInput
 class="input is-fullwidth"
 placeholder="Start typing here..."
 (keydown.ENTER)="onSaveEntry(titleInput, bodyInput)"
/>

<button
 class="button is-info"
 type="submit"
 (click)="onSaveEntry(titleInput, bodyInput)"
>
 save
</button>

<div class="card" *ngFor="let entry of entries; index as idx">
 <div class="card-content">
   <h1 class="title">{{ entry.title }}</h1>
   <button class="delete is-small" (click)="onDeleteEntry(idx)"></button>
   <p>"{{ entry.body }}"</p>
   <p class="is-size-7 has-text-grey-lighter">
     {{ entry.timestamp | date: 'short' }}
   </p>
 </div>
</div>

and adding some simple CSS classes in app.component.scss

:host {
 display: block;
 font-family: sans-serif;
 min-width: 300px;
 max-width: 600px;
 padding: 50px;
 margin: auto;
}

input {
 margin-bottom: 8px;
}

.card {
 margin: 16px;
 border-radius: 8px;
}

.delete {
 position: absolute;
 right: 8px;
 top: 8px;
}

The journal app is ready! We can check the results by running:

$ ng serve journal -o

Great! It works! Now we can write our daily memos and have them persisted in memory by our API.

Create a shared library#

You might have noticed that to safely type everything it was needed to duplicate a JournalEntry interface in both the journal app and the API. Let’s create a shared library to extract this common code and avoid the code duplication.

Creating a shared library is simple using the command below:

$ ng generate @nrwl/workspace:library types 

The types library was created inside the libs directory:

libs
└── types
    ├── src
    │   ├── lib
    │   └── index.ts
    ├── README.md
    ├── jest.config.js
    ├── tsconfig.json
    ├── tsconfig.lib.json
    ├── tsconfig.spec.json
    └── tslint.json

Now let’s move the interface to types.ts and refactor our code to use the shared type:

export interface JournalEntry {
  title: string;
  body: string;
  timestamp?: Date;
}

Next we remove the interface from the API in the app.service.ts file:

import { Injectable } from '@nestjs/common';
import { JournalEntry } from '@trombonix/types'; // <-- this should be added instead

// this should be removed
// export interface JournalEntry {
//   title: string;
//   body: string;
//   timestamp?: Date;
// }

@Injectable()
export class AppService {
...
}

Notice that we can now import the JournalEntry from @trombonix/types, which points to the shared types library. This is possible thanks to the schematic we used to scaffold the library. It creates a TypeScript alias for the types library.

We also need to correct the imports on the app.controller.ts to import from our shared types library. The same process has to be repeated for data.service.ts and app.component.ts in the Journal app respectively.

We’ve completed our full-stack Journal app with a shared library. If we generate a dependency graph, we can see how our code is connected. This can be achieved by using once more the CLI:

 $ yarn dep-graph

As we see above, now we have a shared library types that contains the types used by the frontend and also the backend. This allows us to define a contract between both parts while developing and take maximum advantage of TypeScript.

Conclusion#

In this article we covered how to leverage the NxDevTools to create a full-stack application. Furthermore, we learned how to create a shared library to avoid code duplication and make our code more maintainable

Be aware that not all the code is a good candidate to be shared between frontend and backend. The response interfaces are a good choice, since they create a contract between backend and frontend. However, classes with too much business logic can create tight coupling and should be used sparingly.

An additional advantage of using a monorepo is the ability to develop a feature end-to-end without the need to synchronize multiple pull requests in different repositories.

An Nx workspace comes packed full of tooling that makes developing in a monorepo practical and productive. In this article, we barely scratched the surface of the potential behind these tools, but I hope it inspired you to start exploring them.

The full code for this article can be found in GitHub: https://github.com/Carniatto/journal-nx-angular-nest