NestJS course Β· Module 1: NestJS Basics
PROJECT: The Legion Register of the Empire
In this lesson8
Ave, legate! Throughout the module you built single pieces: a controller here, a service there, a DTO and the configuration separately. A real application is not a pile of loose blocks - it is one organism in which a request passes through several hands before the response comes back. Time to put everything together: the Legion Register, an API in which the Empire's chancellery receives, checks and stores legionaries' data.
What you will build
A REST API with five endpoints under the /legionaries address:
| Method | Address | What it does | Response code |
|---|---|---|---|
| GET | /legionaries | list of all legionaries | 200 |
| GET | /legionaries/search?province=Gallia | legionaries from one province | 200 |
| GET | /legionaries/:id | one legionary | 200 or 404 |
| POST | /legionaries | recruiting a new legionary | 201 or 400 |
| DELETE | /legionaries/:id | striking off the register | 204 or 404 |
You met every element of this table in this module. The project checks whether you can combine them into one application.
Stage 1: project and packages
Start with a new project (the nest new generator in NestJS 12 needs Node.js 22.22.3+ or 24.15+) and install the packages we used in the lessons on DTOs and configuration:
1npm install -g @nestjs/cli
2nest new legion-register
3cd legion-register
4npm i @nestjs/config joi class-validator class-transformer @nestjs/mapped-typesThen create a .env file in the project folder. Remember that it does not go into the repository - add it to .gitignore, and next to it save a .env.example with the same keys:
1PORT=3000
2LEGION_NAME=Legio X EquestrisStage 2: the DTO - a recruitment form
The register accepts only complete applications. Describe them with a DTO class with decorators from class-validator:
1// legion/dto/create-legionary.dto.ts
2import { IsEmail, IsIn, IsNotEmpty, IsString } from 'class-validator';
3
4export class CreateLegionaryDto {
5 @IsString()
6 @IsNotEmpty()
7 name: string;
8
9 @IsIn(['Miles', 'Optio', 'Centurio'])
10 rank: string;
11
12 @IsString()
13 @IsNotEmpty()
14 province: string;
15
16 @IsEmail()
17 email: string;
18}The decorators alone do not block anything yet. They start working together with ValidationPipe, which you will turn on in stage 5.
Stage 3: the service - the register's chancellery
All the logic lives in the service. It keeps legionaries in an array, gives them consecutive numbers and reports a missing legionary with the NotFoundException exception. It reads the legion's name from the configuration through ConfigService:
1// legion/legion.service.ts
2import { Injectable, NotFoundException } from '@nestjs/common';
3import { ConfigService } from '@nestjs/config';
4import { CreateLegionaryDto } from './dto/create-legionary.dto';
5
6export interface Legionary extends CreateLegionaryDto {
7 id: number;
8}
9
10@Injectable()
11export class LegionService {
12 private legionaries: Legionary[] = [];
13 private nextId = 1;
14
15 constructor(private readonly configService: ConfigService) {}
16
17 findAll(): Legionary[] {
18 return this.legionaries;
19 }
20
21 findByProvince(province: string): Legionary[] {
22 return this.legionaries.filter((l) => l.province === province);
23 }
24
25 findOne(id: number): Legionary {
26 const legionary = this.legionaries.find((l) => l.id === id);
27 if (!legionary) {
28 throw new NotFoundException('No legionary with number ' + id);
29 }
30 return legionary;
31 }
32
33 recruit(data: CreateLegionaryDto): Legionary {
34 const legionary = { id: this.nextId++, ...data };
35 this.legionaries.push(legionary);
36 return legionary;
37 }
38
39 dismiss(id: number): void {
40 this.findOne(id);
41 this.legionaries = this.legionaries.filter((l) => l.id !== id);
42 }
43
44 getLegionName(): string {
45 return this.configService.get('LEGION_NAME');
46 }
47}Note the nextId counter. A number taken from the array length would repeat after striking off someone from the middle of the register, while the counter always grows. The dismiss method calls findOne first, so removing a legionary who does not exist also ends with a 404 response.
Stage 4: the controller - a centurion at the gate
The controller only receives requests and passes them to the service. Watch the order of routes: search before :id, otherwise the /legionaries/search address would reach findOne:
1// legion/legion.controller.ts
2import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Query } from '@nestjs/common';
3import { CreateLegionaryDto } from './dto/create-legionary.dto';
4import { LegionService } from './legion.service';
5
6@Controller('legionaries')
7export class LegionController {
8 constructor(private readonly legionService: LegionService) {}
9
10 @Get()
11 findAll() {
12 return this.legionService.findAll();
13 }
14
15 @Get('search')
16 search(@Query('province') province: string) {
17 return this.legionService.findByProvince(province);
18 }
19
20 @Get(':id')
21 findOne(@Param('id') id: string) {
22 return this.legionService.findOne(Number(id));
23 }
24
25 @Post()
26 recruit(@Body() dto: CreateLegionaryDto) {
27 return this.legionService.recruit(dto);
28 }
29
30 @Delete(':id')
31 @HttpCode(HttpStatus.NO_CONTENT)
32 dismiss(@Param('id') id: string) {
33 this.legionService.dismiss(Number(id));
34 }
35}The id from the address is text, so the controller turns it into a number with Number(id) before passing it to the service. You do not have to set the 201 code for @Post() - NestJS picks it itself.
Stage 5: modules and starting the application
The legion module registers the controller and the service:
1// legion/legion.module.ts
2import { Module } from '@nestjs/common';
3import { LegionController } from './legion.controller';
4import { LegionService } from './legion.service';
5
6@Module({
7 controllers: [LegionController],
8 providers: [LegionService],
9})
10export class LegionModule {}The root module loads the configuration, checks it at startup with a Joi schema and adds the legion module:
1// app.module.ts
2import { Module } from '@nestjs/common';
3import { ConfigModule } from '@nestjs/config';
4import * as Joi from 'joi';
5import { LegionModule } from './legion/legion.module';
6
7@Module({
8 imports: [
9 ConfigModule.forRoot({
10 isGlobal: true,
11 validationSchema: Joi.object({
12 PORT: Joi.number().default(3000),
13 LEGION_NAME: Joi.string().required(),
14 }),
15 }),
16 LegionModule,
17 ],
18})
19export class AppModule {}Finally, main.ts turns on validation for the whole application and starts the server:
1// main.ts
2import { ValidationPipe } from '@nestjs/common';
3import { NestFactory } from '@nestjs/core';
4import { AppModule } from './app.module';
5
6async function bootstrap() {
7 const app = await NestFactory.create(AppModule);
8 app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
9 await app.listen(process.env.PORT ?? 3000);
10}
11bootstrap();Check the register
Start the server with npm run start:dev and test the endpoints in a second terminal:
1curl -X POST http://localhost:3000/legionaries \
2 -H "Content-Type: application/json" \
3 -d '{"name": "Titus Pullo", "rank": "Miles", "province": "Gallia", "email": "titus@legio.rome"}'
4
5curl "http://localhost:3000/legionaries/search?province=Gallia"
6curl http://localhost:3000/legionaries/1
7curl -X DELETE http://localhost:3000/legionaries/1
8curl http://localhost:3000/legionaries/1The last command should return 404, because the legionary has been struck off. Also send an application with a wrong email address or a rank outside the list - ValidationPipe will answer with code 400 and a list of errors.
What you hand in
Push the project to GitHub and submit the link in the next task. In the README describe the endpoints and how to run the project: npm install, a .env file created from .env.example and npm run start:dev. For the eager: add PATCH /legionaries/:id with an UpdateLegionaryDto based on PartialType, as in the lesson on DTOs.
Remember, legate: in a well-built Empire everyone knows their place - the controller receives, the service executes, the DTO guards the gate, and the configuration lives outside the code.
Code for this lesson: src/imperium-project.ts
1// PROJECT: Complex Imperium Romanum Application
2import { Module, Controller, Injectable, Get, Post, Body, Param } from '@nestjs/common';
3
4console.log("=== PROJECT: Imperium Management System ===");
5
6// ===========================================
7// 1. Legion Module
8// ===========================================
9
10interface Legion {
11 id: number;
12 name: string;
13 commander: string;
14 soldiers: number;
15 province: string;
16}
17
18@Injectable()
19export class LegionService {
20 private legions: Legion[] = [
21 { id: 1, name: 'Legio X Gemina', commander: 'Caesar', soldiers: 5000, province: 'Gallia' },
22 { id: 2, name: 'Legio III Augusta', commander: 'Scipio', soldiers: 4500, province: 'Africa' },
23 ];
24
25 findAll(): Legion[] { return this.legions; }
26
27 findById(id: number): Legion | undefined {
28 return this.legions.find(l => l.id === id);
29 }
30
31 create(data: Omit<Legion, 'id'>): Legion {
32 const legion = { id: this.legions.length + 1, ...data };
33 this.legions.push(legion);
34 return legion;
35 }
36}
37
38@Controller('legiones')
39export class LegionController {
40 constructor(private legionService: LegionService) {}
41
42 @Get()
43 findAll() { return this.legionService.findAll(); }
44
45 @Get(':id')
46 findOne(@Param('id') id: string) { return this.legionService.findById(+id); }
47}
48
49@Module({
50 controllers: [LegionController],
51 providers: [LegionService],
52 exports: [LegionService],
53})
54export class LegionModule {}
55
56// ===========================================
57// 2. Tribute Module
58// ===========================================
59
60interface Tribute {
61 id: number;
62 province: string;
63 amount: number;
64 type: 'gold' | 'silver' | 'goods';
65 collectedAt: string;
66}
67
68@Injectable()
69export class TributeService {
70 private tributes: Tribute[] = [
71 { id: 1, province: 'Gallia', amount: 50000, type: 'gold', collectedAt: '44 BC' },
72 { id: 2, province: 'Aegyptus', amount: 80000, type: 'gold', collectedAt: '30 BC' },
73 ];
74
75 findAll() { return this.tributes; }
76 getTotal() { return this.tributes.reduce((sum, t) => sum + t.amount, 0); }
77}
78
79// ===========================================
80// 3. Province Module
81// ===========================================
82
83@Injectable()
84export class ProvinciaService {
85 private provinces = [
86 { name: 'Gallia', governor: 'Gaius Julius Caesar', population: 120000 },
87 { name: 'Aegyptus', governor: 'Gaius Cornelius Gallus', population: 300000 },
88 { name: 'Britannia', governor: 'Aulus Plautius', population: 80000 },
89 ];
90
91 findAll() { return this.provinces; }
92}
93
94// ===========================================
95// Main application module
96// ===========================================
97
98@Module({
99 imports: [LegionModule],
100 providers: [TributeService, ProvinciaService],
101})
102export class AppModule {}
103
104console.log("\n=== PROJECT STRUCTURE ===");
105console.log("LegionModule - managing legions (Controller + Service)");
106console.log("TributeService - managing tributes");
107console.log("ProvinciaService - managing provinces");
108console.log("AppModule - main module connecting everything");
109console.log("\nThe project combines modules, dependency injection,");
110console.log("controllers and services into one application.");
111Spotted a mistake in this lesson?