Nest.js – TypeScript im Backend geht auch clean
TypeScript auch als Backend-Sprache einzusetzen wird immer populärer. Node.js und Express oder auch Fastify machen es möglich.
Vor ca. 2 Jahren stand bei mir ein neues Projekt auf der grünen Wiese an: eine komplexe Verwaltungsanwendung mit verschiedenen fachlichen Modulen. Die Technologie im Frontend war schnell gewählt: Vue.js mit Typescript als Programmiersprache. Als zusätzliche Herausforderung gab es die Anforderung, dass ein Mitarbeiter beim Kunden in die ausgewählten Technologien eingearbeitet werden sollte. Dieser hatte bisher allerdings nur Basiskenntnisse in der Software-Entwicklung. Damit nur eine Programmiersprache erlernt werden musste, habe ich damals ein Node.js-Backend mit Typescript vorgeschlagen. Gerade wenn man mehrmals täglich die Programmiersprache wechselt, passiert es schnell, dass die Syntax der jeweiligen Sprachen verwechselt oder vermischt wird. Aus diesem Grund ist es ein Riesenvorteil in der Fullstack-Entwicklung, wenn Frontend und Backend in derselben Programmiersprache geschrieben werden.
Das Backend in Typescript zu schreiben war daher schon eine sehr gute Idee. Wir hatten allerdings minimale Bedenken, dass es bei einem so komplexen Projekt bei einer simplen Node.js-Anwendung an Architektur und Struktur fehlen würde.
Node.js: Wo ist das Problem?
Node.js selbst ist kein Framework, sondern nur eine Runtime, mit der ich Javascript-Code serverseitig ausführen kann. Ich habe aber kein Framework und muss alles von Null auf neu definieren.
Um REST-Anwendungen zu erstellen, wird oft Express.js verwendet. Express.js ist ein extrem leichtgewichtiges Framework, welches eine Sache extrem gut kann: REST-Endpunkte via HTTP-Requests bereitstellen. Allerdings kann Express nur das. Um alles andere muss ich mich selbst kümmern. Wie sieht es aus mit Architektur? Ich muss mir selber ein Architekturkonzept ausdenken.
Dies führt dazu, dass jede Node.js-Anwendung schnell unorganisiert aussieht. Außerdem können sich die Strukturen einzelner Node.js-Applikationen extrem unterscheiden. Es passiert häufig, dass z. B. Businesslogik im Controller landet. Oder wir gehen noch einen Schritt weiter: Wer sagt eigentlich, dass es so etwas wie Controller gibt? Niemand schreibt uns eine Trennung zwischen beispielsweise Controllern und Services vor. In der Theorie können wir machen, was wir wollen und es gleicht dem Wilden Westen. Die Folgen in der Praxis sind oft technische Schulden und große Schwierigkeiten, Unit-Tests für die Anwendung bereitzustellen.
Um das Ganze zu lösen, kommt also Nest.js ins Spiel. In diesem Artikel zeige ich, wie schnell und einfach man mit Nest.js eine Backend-Anwendung erstellen kann und welche Vorteile Nest.js mit sich bringt.
Was ist Nest.js?
Nest.js (nicht zu verwechseln mit Next.js!) ist ein Web-Framework, um serverseitige Anwendungen zu entwickeln. Es basiert dabei auf Node.js. Als Entwickler:in habe ich also alle Freiheiten, die ich aus der Node.js-Welt gewohnt bin. Auch wenn Nest.js von Haus aus bereits viel Funktionalität mitbringt, kann ich z. B. meine Anwendung mit weiteren Dependencies ergänzen. Standardmäßig verwendet Nest.js Typescript als Programmiersprache. Die Möglichkeit, Javascript zu nutzen ist auch gegeben, allerdings ist dies bewusst nicht der Standard. Um mit Nest.js REST-Anwendungen bereitzustellen, wird im Hintergrund des Frameworks Express.js verwendet. Man kann dies aber auch auf z. B. Fastify umstellen. Das könnte beispielsweise aus Performancegründen Sinn ergeben. Es wird außerdem Jest als Test-Framework mitgeliefert.
Nest.js wird immer beliebter
Die Community rund um Nest.js wird derzeit immer größer. Das Core Package von Nest.js hat aktuell z. B. 2,8 Millionen wöchentliche Downloads bei npm.
Bei der jährlichen Stackoverflow-Umfrage war Nest.js 2023 bereits bei den beliebtesten Webframeworks und Technologien vertreten (das war 2022 noch nicht der Fall) [1]. Auf den oberen Plätzen finden wir auch Node.js und Express, mit denen wir ja ebenfalls in Berührung kommen. Warum jQuery nach wie vor so weit oben vertreten ist, ist mir persönlich ein Rätsel. Aber das ist eine andere Geschichte.
Die erste Nest.js-Anwendung
Bevor wir mit der ersten Anwendung starten, sollten wir die Nest.js-Kommandozeile installieren. Die Nest.js-Kommandozeile bietet zahlreiche Features, die bei der Entwicklung einiges an Arbeit abnehmen. Über diese können wir den Grundaufbau von Ressourcen generieren und auch unser Projekt initialisieren. Die Verwendung der Kommandozeile ist natürlich optional und nicht zwingend notwendig.
Die Nest.js-Kommandozeile können wir über folgenden Befehl installieren. Wichtig: Zur Verwendung von Nest.js benötigen wir eine Node-Version 16 oder neuer.
npm i -g @nestjs/cli
Danach können wir alle Operationen verwenden, die von der Nest.js-Kommandozeile bereitgestellt werden. Nun können wir mit folgendem Befehl ein neues Projekt anlegen:
nest new projekt-name
Nach Ausführung werden wir gefragt, ob wir npm, yarn oder pnpm als Packagemanager verwenden möchten.
In diesem Beispiel entscheiden wir uns für npm. Danach sollte die generierte Projektstruktur wie in Abb. 3 aussehen.
Daraufhin kann man via Kommandozeile in das Projekt wechseln und die Anwendung mit folgendem Befehl starten:
npm run start
Weitere Befehle können wir im Skriptbereich der Package.json sehen. Wir könnten die Anwendung z. B. auch im Debug-Modus starten, damit sie bei Änderungen automatisch neu startet.
npm run start:debug
Die Anwendung wurde erfolgreich gestartet. Die generierte Anwendung hat bereits einen "Hello World!"-Endpunkt, den wir via GET testen können. Diesen können wir z. B. über Postman via Port 3000 aufrufen.
Die Architektur von Nest.js
Nest.js-Anwendungen setzen auf eine modulbasierte Architektur. Innerhalb eines Projektes existieren also verschiedene Module, welche deine Anwendung logisch in Einheiten kapselt. Eine Anwendung könnte also z. B. aus einem Benutzer, Adressen und einem Rechnungsmodul bestehen. Ein Modul besteht aus Services, Controllern und weiteren Ressourcen. Die weiteren Ressourcen sind beispielsweise Klassen für Entitäten, DTOs (Data Transfer Objects) oder sämtliche anderen Ressourcen, welche für dieses Modul benötigt werden.
Das Modul steuert auch, welche anderen Module importiert oder welche Services exportiert werden, damit diese von anderen Modulen importiert und verwendet werden können.
Controller
Der Controller stellt die Endpunkte unserer Anwendung bereit. Wenn wir nun in die app.controller.ts-Klasse in unserer frisch erzeugten Anwendung schauen, sehen wir eine ganz normale Typescript-Klasse mit einem @Controller Decorator. Dieser Decorator macht alle Klassen im Nest.js.Kontext zu Controllern. Innerhalb dieser Klasse finden wir eine Methode mit einem @Get Decorator. Das ist der Endpunkt, den wir nach dem Start der Anwendung über Postman aufgerufen haben. Hier können wir natürlich auch weitere Methoden bereitstellen. Es gibt für alle üblichen HTTP-Methoden einen Decorator (@Post, @Put, @Patch, @Get, @Delete). Ein @Post gibt standardmäßig einen 201 HTTP-Code zurück (created). Wenn wir nun allerdings einen 202 (accepted) zurückbekommen wollen, können wir dies ebenfalls via @HttpCode(202) als Decorator an der Methode definieren.
Der Controller soll selbst keine Logik beinhalten. Er dient nur dazu, ein Interface nach außen bereitzustellen. Die eigentliche Businesslogik der Anwendung soll in den Serviceklassen stattfinden. Der Controller könnte wie folgt aussehen:
import { Body, Controller, Get, HttpCode, Post } from '@nestjs/common';
import { AppService } from './app.service';
import { InputDto } from './input.dto';
@Controller()
export class AppController {,
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
@Post()
@HttpCode(202)
postSomethingWithValidation(@Body() input: InputDto): string {
return 'DO SOMETHING IMPORTANT';
}
}
Services
Hier können wir uns die app.service.ts-Klasse anschauen. Diese wird in der generierten Beispielanwendung von dem eben angeschauten Controller aufgerufen. Der Service ist wieder eine normale Typescript-Klasse und ist mit @Injectable markiert – dazu kommen wir später im Dependency-Injections-Abschnitt. In den Services findet die eigentliche Logik der Anwendung statt. Hier finden wir auch die Methode, die "Hello World!" returned. Wenn wir diesen Wert nun anpassen, die Anwendung neu starten und unseren Endpunkt über Postman neu aufrufen, erhalten wir unseren neuen Wert. Der Service könnte wie folgt aussehen:
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}
Dependency Injections
Dependency Injections sind ein Konzept aus der Software-Entwicklung, welches die Initialisierung von Objekten steuert und verwaltet. In der modernen Software-Entwicklung möchte ich, dass meine Services zustandslos sind. Mit Hilfe von Dependency Injections werden andere Services von außen in die Serviceklasse hineingegeben (oder auch "injected"). Mittels des eben erwähnten @Injectable Decorator markiere ich einen Service für Nest.js als Service, der für die Dependency Injections berücksichtigt wird. Per Default sind die Services ein Singleton. Es existiert also immer nur eine Objektinstanz pro Service. Die Services werden zentral von Nest.js verwaltet und ich muss mich selbst nicht darum kümmern. So etwas wird gerne mal als "Framework-Magie" abgestempelt, intern wird Nest.js aber vermutlich einfach eine Map halten, die sich die einzelnen Services merkt und diese in die Klassen hineingibt, sobald diese irgendwo hinterlegt werden. In der Realität wird es im Hintergrund von Nest.js vermutlich deutlich komplexer aussehen, aber so kann man sich das Konstrukt zumindest intern vorstellen.
Wie sehen Dependency Injections in der Umsetzung von Nest.js aus? Dafür gehen wir wieder in die app.controller.ts-Klasse. Hier sehen wir einen Konstruktor, der als Parameter unseren eben durchleuchteten AppService erhält. Hier wird der Service in diesem Controller "injected". Nun kann dieser Service innerhalb des Controllers verwendet werden. Der Controller nimmt die Anfrage von außen entgegen und ruft dann den injizierten Service auf.
constructor(private readonly appService: AppService) {}
Dependency Injections ermöglichen dadurch auch eine unkomplizierte Testbarkeit, weil wir dort die hineingegebenen Services auf einfache Weise ersetzen ("mocken") können.
main.ts
Wo kommt eigentlich der Port 3000 her? Hierfür schauen wir in die main.ts. Sie ist der Haupteinstiegspunkt für eine Nest.js-Anwendung. Hier wird die Anwendung gestartet und es können weitere Konfigurationen gesetzt werden, die den Start der Anwendung beeinflussen. An dieser Stelle sehen wir beispielsweise den Start der Anwendung über den Port 3000:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();
Innerhalb der bootstrap-Funktion in der main.ts wäre beispielsweise auch der richtige Ort, um Tools wie Swagger zu registrieren.
Anlage eines neuen Moduls
Bisher haben wir ja nur die Hauptebene der Nest.js-Anwendung durchleuchtet. Jetzt wollen wir aber ein eigenes Modul erzeugen, um unsere Logik zu implementieren. Da die Nest CLI bereits installiert ist, können wir ein Modul direkt per folgendem Befehl anlegen:
nest g resource dein-modul-name
Daraufhin fragt Nest.js, welche Transportschicht wir für unsere Ressource verwenden wollen. In diesem Beispiel entscheiden wir uns für REST. Im Anschluss wird noch gefragt, ob wir uns außerdem direkt CRUD-Endpunkte generieren lassen möchten. Dies nehmen wir ebenfalls dankend an.
Nun haben wir innerhalb des src-Ordners ein neues Verzeichnis mit dem Namen unseres Moduls. Wir haben eine Service- und eine Controller-Klasse, die den Namen des Moduls erhalten haben. Zur Service- und Controller-Klasse wurden ebenfalls .spec.ts-Klassen für die Unit-Tests erzeugt. Nest.js wird standardmäßig mit Jest ausgeliefert.
Wir finden außerdem ein DTO- und ein Entities-Verzeichnis. Im DTO-Verzeichnis wurde eine Create- und eine Update-Klasse generiert. Per Standard erbt das Update-DTO vom Create-DTO. Die DTOs beinhalten noch keine Eigenschaften, da Nest.js nicht weiß, wie wir diese füllen wollen. Ggf. benötigen wir diese auch gar nicht. Dann könnten wir die Klassen löschen. Im Entities-Verzeichnis wurde eine Entity-Klasse erzeugt. Diese benötigen wir für eine Verbindung zur Datenbank mit einem ORM-Framework wie z. B. TypeORM. Wenn ich beispielsweise Prisma als ORM-Framework verwenden möchte, was natürlich auch möglich ist, brauche ich diese Entity-Klasse in der Regel nicht.
Wenn wir uns nun die Controller-Klasse anschauen, sehen wir, dass bereits alle CRUD-Endpunkte generiert wurden. Via Dependency Injections wird der gleichnamige Service injected und die dazugehörigen Methoden werden aufgerufen. Schaut man sich diesen generierten Services an, stellt man fest, dass alle CRUD-Methoden generiert wurden (create, findAll, findOne, update, remove), diese aber nicht definiert wurden. An dieser Stelle sind wir dann als Entwickler:in am Zug, die zugehörige Logik zu implementieren.
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@Controller('user')
export class UserController {
constructor(private readonly userService: UserService) {}
@Post()
create(@Body() createUserDto: CreateUserDto) {
return this.userService.create(createUserDto);
}
@Get()
findAll() {
return this.userService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.userService.findOne(+id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
return this.userService.update(+id, updateUserDto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.userService.remove(+id);
}
}
import { Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@Injectable()
export class UserService {
create(createUserDto: CreateUserDto) {
return 'This action adds a new user';
}
findAll() {
return `This action returns all user`;
}
findOne(id: number) {
return `This action returns a #${id} user`;
}
update(id: number, updateUserDto: UpdateUserDto) {
return `This action updates a #${id} user`;
}
remove(id: number) {
return `This action removes a #${id} user`;
}
}
Kommunikation zwischen Modulen
Wir haben nun erfolgreich ein Modul erzeugt. In einer komplexen Umgebung wird unsere Anwendung in der Regel aus mehreren Modulen bestehen. Dann möchten wir vermutlich in einem Service aus Modul A einen anderen Service aus Modul B aufrufen. Ein Szenario hierfür könnte ein Benutzer- und ein Adressmodul sein. Bei der Neuanlage eines Nutzers wollen wir dessen Adresse prüfen. Um dies zu ermöglichen, müssen wir im Adressmodul den jeweiligen Service exportieren. Dies wird in der Modulklasse wie folgt gesetzt:
import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { AddressModule } from '../address/address.module';
@Module({
controllers: [UserController],
providers: [UserService],
imports: [AddressModule], //
})
export class UserModule {}
Im anderen Modul (in diesem Fall dem Benutzermodul) importieren wir nun das Adressmodul. Über diesen Weg wird das gesamte Adressmodul importiert. Danach kann man auf alle exportierten Services des Adressmoduls zugreifen. Der Import wird wie folgt in der Modulklasse definiert:
import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { AddressModule } from '../address/address.module';
@Module({
controllers: [UserController],
providers: [UserService],
imports: [AddressModule], //
})
export class UserModule {}
Nun können wir auf den AdressService über Dependency Injections innerhalb des Benutzermoduls zugreifen. Im folgenden Beispiel rufen wir den AddressService (Teil des AddressModuls) im UserService (Teil des UserModuls) auf.
import { Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { AddressService } from '../address/address.service';
@Injectable()
export class UserService {
constructor(private readonly addressService: AddressService) {} //
create(createUserDto: CreateUserDto) {
this.addressService.checkValidAddress(createUserDto.address); //
return 'Do something useful';
}
}
Validation Pipes
Weitere sehr nützliche Features von Nest.js sind die Validation Pipes. Validation Pipes sind vorgefertigte Decorators, die ohne eigenen Code Prüfungen auf den Input deiner Endpunkte ausführen. Wenn also ein ungültiger Wert als Input übergeben wird, wirft Nest.js automatisch einen Fehler zurück. Da die Validation Pipes nicht direkt mit dem Nest.js Core Package ausgeliefert werden, muss folgende zusätzliche Dependency installiert werden:
npm i --save class-validator class-transformer
Ebenfalls müssen wir die Validation Pipes mit folgender Zeile Code in der main.ts für unsere Anwendung registrieren. Nur dann weiß unsere Nest.js-Anwendung, dass wir die Validierung verwenden wollen.
app.useGlobalPipes(new ValidationPipe());
Wir führen beispielsweise hier einen Post-Endpunkt aus. An diesem Endpunkt übergeben wir ein Objekt als Body-Input und erwarten es als Typen von unserem definierten Data Transfer Object (DTO). Die Controller-Methode inklusive DTO als Body-Input könnte wie folgt aussehen:
@Post()
postSomethingWithValidation(@Body() input: InputDto): string {
return 'DO SOMETHING IMPORTANT';
}
Im Folgenden sehen wir das Input-DTO mit verschiedenen möglichen Validierungen. Wenn nun eine der Bedingungen nicht erfüllt ist, wirft Nest.js direkt einen Fehler zurück, ohne dass ich selber eine dieser Validierungen entwickeln musste. Eine typische Prüfung ist die valide E-Mail eines Benutzers. In diesem Beispiel erwarten wir auch eine valide IBAN im Input. Wir übergeben keine valide IBAN und unsere Anwendung wirft uns komplett automatisiert einen 400er HTTP-Code (Bad Request) mit zugehöriger Fehlermeldung zurück, dass die IBAN nicht gültig ist.
import {
IsEmail,
IsIBAN,
IsNotEmpty,
IsOptional,
IsString,
IsUrl,
} from 'class-validator';
export class InputDto {
@IsString()
@IsNotEmpty()
name: string;
@IsUrl()
domain: string;
@IsEmail()
email: string;
@IsIBAN()
@IsOptional()
iban: string;
}
Nest.js: Fazit
Nest.js löst für mich persönlich viele Probleme, die ich in der Vergangenheit in der Node.js-Backend-Entwicklung wahrgenommen habe. Gerade die einheitliche Architektur und die integrierten Dependency Injections sind für mich riesige Pluspunkte, die für die Verwendung von Nest.js sprechen. Auch der simple Aufbau mit den vielen Decorators, die ich schon von Spring Boot gewohnt war, macht den Einstieg extrem einfach. Features wie z. B. die Validation Pipes sind die Kirsche auf der Torte. Für weitere Features lohnt sich ein Blick in die Nest.js-Dokumentation. Es gibt beispielsweise auch einen Scheduler für Cron-Jobs oder Unterstützung beim Upload von Files.
Wann verwende ich jetzt eigentlich Nest.js und wann ist es unnötiger Overhead im Vergleich zu einer "klassischen" Node.js-Backend-Anwendung? Aus meiner Sicht benötige ich Nest.js nicht, wenn ich weiß, dass ich lediglich vor einer kleinen Anwendung stehe, die nur ein konkretes Problem löst. Als Beispiel sei ein REST-Endpunkt gennant, der eine PDF via PDFMake erzeugt. Sobald ich allerdings ein komplexeres Backend entwickle und womöglich auch mit einem Team an einer Software-Lösung arbeite, halte ich Nest.js aktuell für die beste Wahl im Node.js-Kontext.