NestJS is a Node.js framework that adds structure on top of Express: code is split into modules, controllers handle HTTP, logic lives in service providers, and a built-in dependency injection container wires them together. Input is described with DTO classes decorated with class-validator rules and checked by ValidationPipe before the handler runs. To test a whole module, Nest boots the application inside a test with @nestjs/testing, and supertest sends the requests.
After this you will be able to
- Create a module with a controller and a service using the Nest CLI and explain who creates the service instance and when
- Describe input with a DTO class and enable
ValidationPipewithwhitelistandforbidNonWhitelisted - Throw
NotFoundExceptionandConflictExceptionfrom a service and get the correct 404 and 409 responses - Write an e2e test for the module with
@nestjs/testingandsupertestthat checks 201, 400, 404 and 409
Know beforehand
Verified with
NestJS 12.1 (@nestjs/core 12.1.2, @nestjs/cli 12.0.8)TypeScript 6.0.3class-validator 0.15.1, class-transformer 0.5.1Vitest 4.1, supertest 7.3Express 5.2 (@nestjs/platform-express)Node.js 24 LTS (24.18)
Checked against docs: October 11, 2026
Contents
Why NestJS when you already have Express
Express gives you routes and middleware (functions that run during request handling) and imposes nothing else. With five handlers in one file that is a strength. With twenty resources and three developers, everyone arranges code differently: validation inside one route, a separate function in another, and the database client imported directly from anywhere. Such code is hard to test because dependencies hide in imports.
NestJS solves an organisational problem. Under the hood it runs the same Express by default (the @nestjs/platform-express package; Fastify is an option), but it adds consistent rules: a module declares what a part of the application consists of; a controller handles HTTP only; a service holds the logic; objects never construct each other but receive dependencies through the constructor from the Nest container.
Route
- Express
app.post("/leads", handler)- NestJS
- A class method decorated with
@Post()inside@Controller("leads")
Request body validation
- Express
- Custom middleware or a schema call in the handler
- NestJS
- DTO class +
ValidationPipe; the handler receives already-validated data
Handler-to-logic link
- Express
- Direct module import
- NestJS
- Constructor-based dependency injection
HTTP errors
- Express
- Custom error handler
(err, req, res, next) - NestJS
- Built-in
NotFoundException,ConflictExceptionand the exceptions layer
Testing a whole module
- Express
supertestagainst the exportedapp- NestJS
Test.createTestingModule+supertest, with provider overrides
The price of structure is more files and more concepts: decorators, modules, providers. For a small service with a couple of routes Express is the honest choice. NestJS pays off when the backend has several domains, a team and a long life.
The project: Nest CLI and layout
The Nest CLI creates the project. According to the docs, NestJS 12 runs on Node.js 20.19+ (or 22.12+ on the 22 line), while the CLI generators need Node.js 22.22.3+, 24.15+ or 26+. The flags below make scaffolding non-interactive: npm as the package manager, no git repository and no @nestjs/observe observability module.
npx @nestjs/cli@12 new leads-api --package-manager npm --skip-git --no-observe
cd leads-api
npx nest g module leads
npx nest g controller leads
npx nest g service leads
npm install class-validator class-transformer$ npx nest g module leads
CREATE src/leads/leads.module.ts (86 bytes)
UPDATE src/app.module.ts (331 bytes)
$ npx nest g controller leads
CREATE src/leads/leads.controller.ts (103 bytes)
CREATE src/leads/leads.controller.spec.ts (506 bytes)
UPDATE src/leads/leads.module.ts (177 bytes)
$ npx nest g service leads
CREATE src/leads/leads.service.ts (93 bytes)
CREATE src/leads/leads.service.spec.ts (474 bytes)
UPDATE src/leads/leads.module.ts (257 bytes)What matters about the NestJS 12 template:
- The project is ESM by default:
package.jsoncontains"type": "module". Relative imports therefore carry a.jsextension —import { LeadsService } from './leads.service.js'— even though the file is.ts. This is a Node.js rule for ES modules, and the v12 migration guide calls it out. - The test runner for an ESM project is Vitest (Jest for CommonJS).
npm testruns unit tests*.spec.ts;npm run test:e2eruns*.e2e-spec.tsfrom thetestfolder with a separate config. - The linter is oxlint and the formatter is Prettier.
npm run buildcallsnest buildand writes JavaScript todist. tsconfig.jsonenablesexperimentalDecoratorsandemitDecoratorMetadata, which decorators and dependency injection rely on.strictPropertyInitializationis off, so DTO fields without initial values compile understrict: true.
The generated app.controller.ts, app.service.ts and their «Hello World!» test are removed in this lesson — the root module only imports the leads module. The generated leads.controller.spec.ts is removed too: the controller receives the service through its constructor, so the empty test without a provider would fail.
Modules, controllers, providers and dependency injection
Start with the data model. Lead statuses are a constant array and the type is derived from it: one source of truth for TypeScript and for runtime validation.
export const LEAD_STATUSES = ['new', 'in_progress', 'done'] as const;
export type LeadStatus = (typeof LEAD_STATUSES)[number];
export interface Lead {
id: string;
name: string;
phone: string;
message?: string;
status: LeadStatus;
createdAt: string;
}A service is a provider: a class decorated with @Injectable(). The NestJS docs describe a provider as something that can be injected as a dependency. Domain rules live in the service: no second open lead for the same phone, no status change for a lead that does not exist.
import { randomUUID } from 'node:crypto';
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { CreateLeadDto } from './dto/create-lead.dto.js';
import type { Lead, LeadStatus } from './lead.js';
@Injectable()
export class LeadsService {
private readonly leads = new Map<string, Lead>();
create(dto: CreateLeadDto): Lead {
const open = [...this.leads.values()].find(
(lead) => lead.phone === dto.phone && lead.status !== 'done',
);
if (open) {
throw new ConflictException(
'An open lead with this phone already exists',
{
errorCode: 'LEAD_DUPLICATE',
},
);
}
const lead: Lead = {
id: randomUUID(),
name: dto.name,
phone: dto.phone,
message: dto.message,
status: 'new',
createdAt: new Date().toISOString(),
};
this.leads.set(lead.id, lead);
return lead;
}
findAll(): Lead[] {
return [...this.leads.values()];
}
findOne(id: string): Lead {
const lead = this.leads.get(id);
if (!lead) {
throw new NotFoundException(`Lead ${id} not found`);
}
return lead;
}
updateStatus(id: string, status: LeadStatus): Lead {
const lead = this.findOne(id);
lead.status = status;
return lead;
}
remove(id: string): void {
this.findOne(id);
this.leads.delete(id);
}
}The controller accepts HTTP and hands the work to the service. The @Controller('leads') prefix is joined with the method path, so @Get(':id') serves GET /leads/:id. The default response status is 200, or 201 for POST; @HttpCode(204) changes it for deletion, where the response has no body.
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
} from '@nestjs/common';
import { CreateLeadDto } from './dto/create-lead.dto.js';
import { UpdateLeadStatusDto } from './dto/update-lead-status.dto.js';
import type { Lead } from './lead.js';
import { LeadsService } from './leads.service.js';
@Controller('leads')
export class LeadsController {
constructor(private readonly leadsService: LeadsService) {}
@Post()
create(@Body() dto: CreateLeadDto): Lead {
return this.leadsService.create(dto);
}
@Get()
findAll(): Lead[] {
return this.leadsService.findAll();
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string): Lead {
return this.leadsService.findOne(id);
}
@Patch(':id/status')
updateStatus(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateLeadStatusDto,
): Lead {
return this.leadsService.updateStatus(id, dto.status);
}
@Delete(':id')
@HttpCode(204)
remove(@Param('id', ParseUUIDPipe) id: string): void {
this.leadsService.remove(id);
}
}The module ties the pieces together. controllers lists what Nest must instantiate and register as routes; providers lists what the container may inject inside the module. The root AppModule imports the leads module.
import { Module } from '@nestjs/common';
import { LeadsController } from './leads.controller.js';
import { LeadsService } from './leads.service.js';
@Module({
controllers: [LeadsController],
providers: [LeadsService],
})
export class LeadsModule {}import { Module } from '@nestjs/common';
import { LeadsModule } from './leads/leads.module.js';
@Module({
imports: [LeadsModule],
})
export class AppModule {}- 01HTTP request
- 02ValidationPipe
- 03LeadsController
- 04LeadsService
- 05In-memory Map
How injection works. The constructor constructor(private readonly leadsService: LeadsService) does not create the service; it declares that it needs one. At startup Nest reads the constructor parameter type metadata (TypeScript emits it thanks to emitDecoratorMetadata), finds LeadsService among the module providers, instantiates it and passes it to the controller. Per the docs the default lifetime is singleton: the instance is created once and shared with every consumer. That is why the Map inside the service lives as long as the process.
Modules are encapsulated: a provider is visible only inside its own module. If, say, a notifications module needs the leads service, add the service to the leads module exports and the leads module to the notifications module imports. Otherwise Nest reports at startup that it cannot resolve the dependency.
DTOs and ValidationPipe
A DTO (Data Transfer Object) describes the shape of incoming request data. In NestJS DTOs are classes, not interfaces: TypeScript interfaces are erased at compile time, while Nest pipes need the type at runtime. Validation rules come from class-validator decorators.
import {
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
export class CreateLeadDto {
@IsString()
@IsNotEmpty()
@MaxLength(100)
name: string;
@Matches(/^\+7\d{10}$/, {
message: 'phone must be in the format +7XXXXXXXXXX',
})
phone: string;
@IsOptional()
@IsString()
@MaxLength(1000)
message?: string;
}import { IsIn } from 'class-validator';
import { LEAD_STATUSES, type LeadStatus } from '../lead.js';
export class UpdateLeadStatusDto {
@IsIn(LEAD_STATUSES)
status: LeadStatus;
}A pipe in Nest runs right before the handler: it receives the arguments, validates or transforms them, and either passes them on or throws — in which case the handler is never called. ValidationPipe turns the request body into a DTO instance and runs the class-validator rules. The ParseUUIDPipe in the controller works the same way for a single path parameter.
import { type INestApplication, ValidationPipe } from '@nestjs/common';
export function configureApp(app: INestApplication): void {
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
}),
);
}import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
import { configureApp } from './setup.js';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
configureApp(app);
await app.listen(process.env.PORT ?? 3000);
}
await bootstrap();whitelist: truestrips properties that are not in the DTO. Without it a client could sendroleorstatusand, if some code spreads the whole body, get them stored.forbidNonWhitelisted: trueturns an unknown property into a 400 instead of silently dropping it, so the client learns about a typo in a field name right away.- The default error format is
errorFormat: 'list'— an array of strings inmessage. NestJS 12 adds a'grouped'option: an object keyed by property path.
$ curl -i -X POST localhost:3000/leads -H 'Content-Type: application/json' \
-d '{"name":"Aida","phone":"+77011234567"}'
HTTP/1.1 201 Created
{"id":"546c1f10-d53a-4e09-af7a-3c8a8e17cd39","name":"Aida","phone":"+77011234567","status":"new","createdAt":"2026-10-11T23:12:57.267Z"}
$ curl -i -X POST localhost:3000/leads -H 'Content-Type: application/json' \
-d '{"name":"","phone":"8701","role":"admin"}'
HTTP/1.1 400 Bad Request
{"message":["property role should not exist","name should not be empty","phone must be in the format +7XXXXXXXXXX"],"error":"Bad Request","statusCode":400}If the project already has Zod schemas — for example from the Express lesson — NestJS 12 offers StandardSchemaValidationPipe: it validates parameters that declare a schema in the decorator (@Body({ schema: createLeadSchema })) and passes everything else through. Both pipes can be enabled together.
Exceptions: 404, 409 and a consistent format
Nest has a built-in exceptions layer: when a handler or service throws an HttpException or a subclass, the client receives the matching status code and a JSON body. Any other exception becomes a 500 with the body {"statusCode": 500, "message": "Internal server error"}, so internal details do not leak.
BadRequestException
- Code
- 400
- When to throw
- Input is formally invalid and no pipe caught it
UnauthorizedException
- Code
- 401
- When to throw
- Authentication is missing or wrong
ForbiddenException
- Code
- 403
- When to throw
- The user is known but lacks permission
NotFoundException
- Code
- 404
- When to throw
- No resource with this identifier
ConflictException
- Code
- 409
- When to throw
- The request contradicts current state: a duplicate, a concurrent change
UnprocessableEntityException
- Code
- 422
- When to throw
- Well-formed data fails a business check — if your team separates 400 from 422
You can pass an errorCode in the second argument — a stable machine-readable code that appears in the response body. The human-facing message may change, while the client (website or mobile app) branches on errorCode.
$ curl -i -X POST localhost:3000/leads -H 'Content-Type: application/json' \
-d '{"name":"Aida","phone":"+77011234567"}'
HTTP/1.1 409 Conflict
{"message":"An open lead with this phone already exists","error":"Conflict","statusCode":409,"errorCode":"LEAD_DUPLICATE"}
$ curl -i localhost:3000/leads/42
HTTP/1.1 400 Bad Request
{"message":"Validation failed (uuid is expected)","error":"Bad Request","statusCode":400}
$ curl -i localhost:3000/leads/8f14e45f-ceea-4e7a-9b1f-1c2d3e4f5a6b
HTTP/1.1 404 Not Found
{"message":"Lead 8f14e45f-ceea-4e7a-9b1f-1c2d3e4f5a6b not found","error":"Not Found","statusCode":404}The default shape is statusCode, message, error. If your API already returns errors as Problem Details (RFC 9457) with the application/problem+json type — as in the Express lesson — change the shape with an exception filter: a class decorated with @Catch() that implements ExceptionFilter, registered globally with app.useGlobalFilters(). That filter is the last exercise of this lesson.
Tests: a service unit test and a module e2e test
Test.createTestingModule takes the same metadata as @Module(), and compile() asynchronously builds the dependency graph. A unit test of the service needs just one provider.
import { ConflictException, NotFoundException } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { LeadsService } from './leads.service.js';
describe('LeadsService', () => {
let service: LeadsService;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
providers: [LeadsService],
}).compile();
service = moduleRef.get(LeadsService);
});
it('creates a lead with status "new"', () => {
const lead = service.create({ name: 'Aida', phone: '+77011234567' });
expect(lead.status).toBe('new');
expect(service.findOne(lead.id)).toEqual(lead);
});
it('rejects a second open lead with the same phone', () => {
service.create({ name: 'Aida', phone: '+77011234567' });
expect(() =>
service.create({ name: 'Aida', phone: '+77011234567' }),
).toThrow(ConflictException);
});
it('allows a new lead once the previous one is done', () => {
const first = service.create({ name: 'Aida', phone: '+77011234567' });
service.updateStatus(first.id, 'done');
const second = service.create({ name: 'Aida', phone: '+77011234567' });
expect(second.id).not.toBe(first.id);
});
it('throws NotFoundException for an unknown id', () => {
expect(() =>
service.findOne('8f14e45f-ceea-4e7a-9b1f-1c2d3e4f5a6b'),
).toThrow(NotFoundException);
});
});An e2e (end-to-end) test boots the whole AppModule: createNestApplication() creates a real application, init() initialises it, and supertest sends HTTP requests to app.getHttpServer() without opening a port. app.close() after each test releases resources; a fresh application in beforeEach also means fresh storage for every test.
import { type INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { type App } from 'supertest/types.js';
import { AppModule } from '../src/app.module.js';
import { configureApp } from '../src/setup.js';
describe('Leads (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication();
configureApp(app);
await app.init();
});
afterEach(async () => {
await app.close();
});
it('POST /leads creates a lead (201)', async () => {
const res = await request(app.getHttpServer())
.post('/leads')
.send({ name: 'Aida', phone: '+77011234567' })
.expect(201);
expect(res.body).toMatchObject({ name: 'Aida', status: 'new' });
expect(res.body.id).toEqual(expect.any(String));
});
it('POST /leads rejects an invalid body (400)', async () => {
const res = await request(app.getHttpServer())
.post('/leads')
.send({ name: '', phone: '8701', role: 'admin' })
.expect(400);
expect(res.body.message).toEqual(
expect.arrayContaining([
'property role should not exist',
'name should not be empty',
'phone must be in the format +7XXXXXXXXXX',
]),
);
});
it('POST /leads returns 409 for a duplicate open lead', async () => {
const lead = { name: 'Aida', phone: '+77011234567' };
await request(app.getHttpServer()).post('/leads').send(lead).expect(201);
const res = await request(app.getHttpServer())
.post('/leads')
.send(lead)
.expect(409);
expect(res.body.errorCode).toBe('LEAD_DUPLICATE');
});
it('GET /leads/:id returns 400 for a non-UUID and 404 for an unknown id', async () => {
await request(app.getHttpServer()).get('/leads/42').expect(400);
await request(app.getHttpServer())
.get('/leads/8f14e45f-ceea-4e7a-9b1f-1c2d3e4f5a6b')
.expect(404);
});
it('PATCH status and DELETE (204)', async () => {
const { body: lead } = await request(app.getHttpServer())
.post('/leads')
.send({ name: 'Aida', phone: '+77011234567' })
.expect(201);
await request(app.getHttpServer())
.patch(`/leads/${lead.id}/status`)
.send({ status: 'archived' })
.expect(400);
const { body: updated } = await request(app.getHttpServer())
.patch(`/leads/${lead.id}/status`)
.send({ status: 'in_progress' })
.expect(200);
expect(updated.status).toBe('in_progress');
await request(app.getHttpServer()).delete(`/leads/${lead.id}`).expect(204);
await request(app.getHttpServer()).get(`/leads/${lead.id}`).expect(404);
});
});$ npm test -- --reporter=verbose
RUN v4.1.11 …/leads-api
✓ src/leads/leads.service.spec.ts > LeadsService > creates a lead with status "new" 408ms
✓ src/leads/leads.service.spec.ts > LeadsService > rejects a second open lead with the same phone 6ms
✓ src/leads/leads.service.spec.ts > LeadsService > allows a new lead once the previous one is done 5ms
✓ src/leads/leads.service.spec.ts > LeadsService > throws NotFoundException for an unknown id 11ms
Test Files 1 passed (1)
Tests 4 passed (4)
$ npm run test:e2e -- --reporter=verbose
RUN v4.1.11 …/leads-api
✓ test/leads.e2e-spec.ts > Leads (e2e) > POST /leads creates a lead (201) 196ms
✓ test/leads.e2e-spec.ts > Leads (e2e) > POST /leads rejects an invalid body (400) 17ms
✓ test/leads.e2e-spec.ts > Leads (e2e) > POST /leads returns 409 for a duplicate open lead 17ms
✓ test/leads.e2e-spec.ts > Leads (e2e) > GET /leads/:id returns 400 for a non-UUID and 404 for an unknown id 16ms
✓ test/leads.e2e-spec.ts > Leads (e2e) > PATCH status and DELETE (204) 24ms
Test Files 1 passed (1)
Tests 5 passed (5)Why the test calls configureApp(app). Tests do not run main.ts, so whatever is set up there with app.useGlobalPipes is missing from the test application. We checked this by commenting the call out: the application accepts invalid data.
$ npm run test:e2e
× POST /leads rejects an invalid body (400) 14ms
× PATCH status and DELETE (204) 15ms
FAIL test/leads.e2e-spec.ts > Leads (e2e) > POST /leads rejects an invalid body (400)
Error: expected 400 "Bad Request", got 201 "Created"
FAIL test/leads.e2e-spec.ts > Leads (e2e) > PATCH status and DELETE (204)
Error: expected 400 "Bad Request", got 200 "OK"
Tests 2 failed | 3 passed (5)Hence the rule: global setup lives in one function called by both main.ts and the tests. The alternative is to register the global pipe as a module provider with the APP_PIPE token, so it comes along with the module.
Common mistakes and limitations
At startup: Nest can't resolve dependencies of the LeadsController (?)
- Cause
- The service is missing from the module
providersor lives in another module withoutexports/imports - Fix
- Add the provider to its module, or export it and import the module
Invalid data gets through with a 201
- Cause
- No
ValidationPipe(often only in tests), or the DTO is imported withimport type - Fix
- One setup function for
main.tsand tests; a regular DTO import
At build time: TS2307: Cannot find module './leads.service'
- Cause
- A relative import without
.jsin an ESM project (moduleResolution: nodenext) - Fix
- Write
./leads.service.js
@Param('id') id: number holds a string
- Cause
- Path and query parameters always arrive as strings
- Fix
ParseIntPipe/ParseUUIDPipeortransform: trueonValidationPipe
Data disappears after a restart
- Cause
- Storage is a
Mapinside a singleton service - Fix
- A teaching simplification; next comes a database behind a repository
- Each area is its own module; the root module only imports them
- The controller holds no rules: route, parameters, service call
- Body and parameters are validated before the handler: DTO +
ValidationPipe,Parse…Pipefor path params - Errors are built-in exceptions with the right code, plus a stable
errorCodefor clients - Global pipes and filters are wired identically in
main.tsand in the e2e test - The e2e test covers 400, 404 and 409, not just the happy path
What this lesson leaves out and a real service will need: database storage behind a repository, authentication and permissions (guards), configuration from environment variables, OpenAPI documentation. Each is a separate lesson in this track; the module → controller → service structure stays the same.
Exercises
Next step: the lesson on backend application layers, where storage moves from the service into a repository while rules and transactions stay in the service. Backend tests against a real database are in the backend testing lesson.
How this was verified7 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.