Your first NestJS service
The partner ordering API from Designing an API people can use now gets real code. This lesson follows one request through NestJS stop by stop, shows how modules and dependency injection keep the code in clean pieces, and makes sure no field reaches your business rules without being checked.
By the end you can
- Name the stops of the NestJS request pipeline in their real order, and say which status code each one can return
- Draw module boundaries with imports, providers and exports, and fix a "can't resolve dependencies" error
- Keep controllers thin and put the rules in services that throw domain errors, not HTTP errors
- Validate every body, query and path parameter with DTOs, a global ValidationPipe and ParseIntPipe
- Return one problem-details error shape from a global exception filter, and load config without leaking secrets
The everyday picture
Picture the order desk at a wholesale warehouse. A shop owner walks in with an order form. At the gate a guard checks the badge: no badge, no entry. At the counter a clerk checks the form: every line has a product code and a quantity, nothing is scribbled in the margins. Only then does the form go to the back, where a picker who knows the real rules (credit limits, what is in stock) does the work.
The warehouse has departments. Each one keeps its own tools on its own shelves and hands out only what it chooses to share. If the picking team needs a price, it asks the pricing department through its front window. It does not walk into their office and open a drawer.
And whatever goes wrong, at the gate or in the back, the shop owner gets the same kind of slip: what happened, which line, what to fix. NestJS is built the same way. This lesson names each person at the desk.
One request, stop by stop
From script to service introduced the parts of a NestJS app. Here we put them in the exact order the framework runs them, because the order decides which error a client sees. For every request, Nest runs these stops:
- Middleware: request logging, CORS, request IDs. It runs before Nest knows which handler will answer, so it cannot read a route's decorators.
- Guards: "who are you, and may you call this route?" A guard gets an execution context that names the handler, so it can read metadata such as required roles (Who are you and what may you do?). It answers 401 or 403.
- Interceptors, the "before" half: start a timer, attach a request ID.
- Pipes: validate and convert each argument. A bad body gets a 400 here, and the handler never runs.
- The handler in the controller, which calls a service.
- Interceptors, the "after" half: shape or time the result on its way out.
- Exception filters: if anything above threw, a filter turns the error into the response.
Each kind can be bound globally, to a controller or to one route. Guards, interceptors and pipes run global first, then controller, then route. Filters go the other way: the route's filter gets the first chance, and an exception that one filter catches is never passed to another. In this course a single global filter does all the work, so every error looks the same.
Quick check
The Partner app sends POST /v1/orders with an expired token and a body where qty is 0. What does the client get?
Guards run before pipes. The guard throws on the expired token, the filter writes a 401, and the ValidationPipe never sees the body. That order matters for security too: you do not want to tell an unknown caller which fields your API expects.
Modules are boundaries, and the container builds the objects
A @Module() has four lists. controllers are the classes that handle routes. providers are the classes Nest should build and inject, such as services. imports are other modules whose shared providers this module needs. exports are the providers this module lets others use. A provider that is not exported is private to its module. That is the warehouse department keeping its drawers closed.
Here is how dependency injection works under the hood. You write constructor(private readonly prices: PriceListService) {}. TypeScript, with emitDecoratorMetadata switched on (the Nest starter does this), stores the constructor's parameter types as metadata on the class. At startup Nest reads that list, finds a provider whose token is the PriceListService class among the module's own providers and the exports of its imports, builds it first (and its own dependencies), then builds your class. If it cannot find one, startup fails with an error that begins "Nest can't resolve dependencies of the OrdersService" and names the missing argument.
Three consequences follow:
- Providers are singletons by default. Nest builds one instance per module scope and shares it with everyone who injects it. Do not keep per-request data, such as the current shop, in a field of a service. Pass it as an argument. A request-scoped provider exists, but Nest then builds it, and everything that injects it, again for every request, which costs time.
- An interface cannot be a token. Interfaces disappear when TypeScript compiles (TypeScript for a JavaScript team), so there is no type left to look up. Inject a class, or use a string or symbol token with
@Inject('PAYMENT_GATEWAY'). - Tests can swap any provider.
Test.createTestingModule({ providers: [OrdersService, PriceListService] }).overrideProvider(PriceListService).useValue(fakePrices)gives the realOrdersServicea fake price list. The service never knows. Testing and documenting the API builds on this.
Quick check
Startup fails with "Nest can't resolve dependencies of the OrdersService", naming PriceListService. That service lives in PriceListModule. What is the right fix?
Exporting and importing shares the one instance that PriceListModule owns. Listing it again in OrdersModule makes the error go away, but builds a second, separate instance with its own cache, and it breaks again as soon as PriceListService needs something private to its own module. Calling new bypasses injection, so tests can no longer swap it.
Thin controllers, services with the rules
A controller method should do three things: read the checked input, call one service method, return the result. Every rule about orders (a shop cannot order more than its credit limit, an order can be cancelled only before it is picked) belongs in the service. The reason is reuse. In Background jobs and idempotency, a queue worker will place orders that came in by phone, with no HTTP request at all. If the credit-limit check lives in the controller, the worker skips it.
For the same reason, a service should not throw HTTP exceptions. NotFoundException means "send a 404", which is meaningless to a queue worker. Instead, the service throws domain errors: small classes such as OrderNotFoundError or OrderNotCancellableError, each with a stable code. One place, the exception filter, maps codes to status codes: not found is 404, a state conflict is 409. When you add a new rule, you add one error class and one line to the map. The status codes stay consistent with the contract from Designing an API people can use, because no developer picks them by hand in a service.
Validation on every input
A DTO is a class that describes a request. The decorators from the class-validator package say what each field must be: @IsInt(), @Min(1), @Max(999), @ArrayMinSize(1), @IsOptional(). The ValidationPipe takes the plain JSON object from the request, turns it into an instance of the DTO class with the class-transformer package, runs the checks, and throws if any fail.
Order lines are nested objects, and they need two decorators together. @ValidateNested({ each: true }) says "check every item of this array against its own class". @Type(() => OrderLineDto) tells class-transformer which class that is. JSON carries no class names, and without @Type the lines stay plain objects with no rules attached. Depending on the class-validator version, a bad line then either passes or fails with a vague message that names no field. This is the most common validation bug in Nest code.
Three options on the pipe matter for an API that other teams build against:
whitelist: trueremoves every property that has no validation decorator. So every DTO field needs at least one decorator; an optional field gets@IsOptional()plus its type check.forbidNonWhitelisted: trueanswers 400 for those unknown properties instead of removing them quietly. For a partner API this is the better default: if the Partner app sendspriceCents, Lagoon Digital's developers learn at once that the API ignores prices from the app.transform: truegives the handler a real DTO instance and converts path and query values to the types you declared. Query strings always arrive as text, so alimitquery field also gets@Type(() => Number)before@IsInt().
Path parameters get their own pipes. @Param('id', ParseIntPipe) id: number answers 400 for /v1/orders/abc before the service runs, and hands the handler a real number. Without it, the service would get "abc" (or NaN, with transform on) and return a confusing 404, or, once the database arrives in Data with PostgreSQL and Prisma, the query would fail and the client would see a 500. If your ids are UUIDs, ParseUUIDPipe does the same job.
Quick check
The pipe has whitelist: true but not forbidNonWhitelisted. The Partner app sends a line with "priceCents": 100, a field the DTO does not declare. What happens?
With whitelist alone, unknown properties are stripped without an error, at the top level and in nested DTOs (as long as @Type is there). Adding forbidNonWhitelisted turns that into a 400, so the client finds out.
One error shape, and config that keeps its secrets
Nest's built-in filter answers with its own JSON: statusCode, message, error. The contract in api-01 promises problem details instead, with an errors array for field problems. So you write one global filter with @Catch() and no arguments, which catches everything. It sorts each error into one of four cases: validation errors become 400 with the field list; domain errors become their mapped status; other HttpExceptions (a ParseIntPipe failure, a route that does not exist) keep their status; anything else becomes a 500.
The last case is where secrets leak. An unexpected error's message can hold a SQL fragment, a file path or a connection string. The filter logs the full error with its stack trace for you, and sends the client only "something went wrong on our side". To give the validation errors a clean structure, the pipe's exceptionFactory option receives class-validator's error tree. A small function walks the tree and turns lines, 0, qty into the JSON Pointer #/lines/0/qty that api-01's contract uses.
Configuration works the same way as input: check it at the border. ConfigModule.forRoot({ isGlobal: true, validate }) from @nestjs/config reads environment variables (and a local .env file, which stays out of Git), and passes them to your validate function at startup. If DATABASE_URL is missing, the app refuses to start and says which key is wrong. It must never print the value: an error message that includes the connection string puts the database password into your deploy logs. Services read values with config.getOrThrow('PORT') and never log the whole config.
Build it
We will create partner-api with an orders module, nested DTOs, an in-memory service, a problem-details filter and checked config, then call it with curl. Node.js 20 or later and NestJS 11 are assumed; generated files differ a little between CLI versions.
-
Create the project and the orders module
Install the Nest CLI, create the project, add the validation and config packages, and generate the three files of the module. Each
nest generatecommand also registers the new class in the right module file.Terminalnpm install -g @nestjs/cli nest new partner-api --package-manager npm cd partner-api npm install class-validator class-transformer @nestjs/config nest generate module orders nest generate controller orders --no-spec nest generate service orders --no-spec -
Describe the request with nested DTOs
Save as
src/orders/dto/create-order.dto.ts. The shop is in the body for now because sales reps order for many shops; in Who are you and what may you do? it is checked against the signed-in user.TypeScriptimport { Type } from 'class-transformer'; import { ArrayMaxSize, ArrayMinSize, IsArray, IsInt, IsOptional, IsString, Max, MaxLength, Min, ValidateNested, } from 'class-validator'; export class OrderLineDto { @IsInt() @Min(1) articleId!: number; @IsInt() @Min(1) @Max(999) // cartons; 1,200 is a typing mistake qty!: number; } export class CreateOrderDto { @IsInt() @Min(1) shopId!: number; @IsArray() @ArrayMinSize(1) @ArrayMaxSize(100) @ValidateNested({ each: true }) // check every line... @Type(() => OrderLineDto) // ...as an OrderLineDto, not a plain object lines!: OrderLineDto[]; @IsOptional() @IsString() @MaxLength(500) note?: string; // optional, but still declared and checked } -
Domain errors and an in-memory service
Save the errors as
src/orders/orders.errors.ts. They know nothing about HTTP.TypeScriptexport abstract class DomainError extends Error { abstract readonly code: string; // stable: becomes the problem type } export class OrderNotFoundError extends DomainError { readonly code = 'order-not-found'; constructor(orderId: number) { super(`Order ${orderId} does not exist`); } } export class OrderNotCancellableError extends DomainError { readonly code = 'order-not-cancellable'; constructor(orderId: number, status: string) { super(`Order ${orderId} is ${status} and can no longer be cancelled`); } }Replace
src/orders/orders.service.ts. AMapstands in for the database until api-03.TypeScriptimport { Injectable } from '@nestjs/common'; import { CreateOrderDto } from './dto/create-order.dto'; import { OrderNotCancellableError, OrderNotFoundError } from './orders.errors'; export interface Order { id: number; shopId: number; status: 'PLACED' | 'PICKED' | 'CANCELLED'; lines: { articleId: number; qty: number }[]; placedAt: string; // ISO 8601, UTC } @Injectable() // one shared instance (singleton) export class OrdersService { private readonly orders = new Map<number, Order>(); private nextId = 90400; // a database sequence in api-03 place(dto: CreateOrderDto): Order { const order: Order = { id: this.nextId++, shopId: dto.shopId, status: 'PLACED', lines: dto.lines.map((l) => ({ articleId: l.articleId, qty: l.qty })), placedAt: new Date().toISOString(), }; this.orders.set(order.id, order); return order; } get(id: number): Order { const order = this.orders.get(id); if (!order) throw new OrderNotFoundError(id); return order; } cancel(id: number): Order { const order = this.get(id); if (order.status !== 'PLACED') throw new OrderNotCancellableError(id, order.status); order.status = 'CANCELLED'; return order; } } -
A thin controller
Replace
src/orders/orders.controller.ts. Each method reads checked input, calls the service and returns.passthrough: truelets us set theLocationheader and still return the body normally.TypeScriptimport { Body, Controller, Get, HttpCode, Param, ParseIntPipe, Post, Res } from '@nestjs/common'; import type { Response } from 'express'; import { CreateOrderDto } from './dto/create-order.dto'; import { OrdersService } from './orders.service'; @Controller('v1/orders') export class OrdersController { constructor(private readonly orders: OrdersService) {} // injected by type @Post() // POST answers 201 by default place(@Body() dto: CreateOrderDto, @Res({ passthrough: true }) res: Response) { const order = this.orders.place(dto); res.location(`/v1/orders/${order.id}`); return order; } @Get(':id') get(@Param('id', ParseIntPipe) id: number) { // '90400' becomes 90400 return this.orders.get(id); } @Post(':id/cancel') @HttpCode(200) // a state change that returns the order cancel(@Param('id', ParseIntPipe) id: number) { return this.orders.cancel(id); } } -
The validation pipe and its field paths
Save as
src/common/validation.ts.TypeScriptimport { BadRequestException, ValidationPipe } from '@nestjs/common'; import type { ValidationError } from 'class-validator'; export interface FieldError { pointer: string; detail: string } export class FieldErrorsException extends BadRequestException { constructor(readonly fieldErrors: FieldError[]) { super('Validation failed'); } } // class-validator returns a tree: lines -> 0 -> qty. Turn it into "#/lines/0/qty". export function flatten(errors: ValidationError[], path: string[] = []): FieldError[] { return errors.flatMap((e) => { const here = [...path, e.property]; const pointer = '#/' + here.join('/'); const own = Object.values(e.constraints ?? {}).map((detail) => ({ pointer, detail })); return [...own, ...flatten(e.children ?? [], here)]; }); } export const validationPipe = new ValidationPipe({ whitelist: true, // drop fields with no decorator... forbidNonWhitelisted: true, // ...or rather, refuse them with a 400 transform: true, // real DTO instances, real numbers exceptionFactory: (errors) => new FieldErrorsException(flatten(errors)), }); -
One filter for every error
Save as
src/common/problem-details.filter.ts. The type URLs and titles are the ones from the api-01 contract.TypeScriptimport { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from '@nestjs/common'; import type { Request, Response } from 'express'; import { STATUS_CODES } from 'node:http'; import { DomainError } from '../orders/orders.errors'; import { FieldErrorsException } from './validation'; const BASE = 'https://api.mangrove.example/problems/'; const DOMAIN_STATUS: Record<string, number> = { 'order-not-found': 404, 'order-not-cancellable': 409 }; @Catch() // no argument: catch everything export class ProblemDetailsFilter implements ExceptionFilter { private readonly logger = new Logger('Errors'); catch(err: unknown, host: ArgumentsHost) { const http = host.switchToHttp(); const req = http.getRequest<Request>(); const p = this.toProblem(err); if (p.status >= 500) this.logger.error(err instanceof Error ? err.stack : String(err)); http.getResponse<Response>().status(p.status).type('application/problem+json') .json({ ...p, instance: req.originalUrl }); } private toProblem(err: unknown) { if (err instanceof FieldErrorsException) return { type: BASE + 'validation', title: 'The request is not valid.', status: 400, detail: `${err.fieldErrors.length} field(s) need attention.`, errors: err.fieldErrors }; if (err instanceof DomainError) { const status = DOMAIN_STATUS[err.code] ?? 422; return { type: BASE + err.code, title: STATUS_CODES[status], status, detail: err.message }; } if (err instanceof HttpException) { // ParseIntPipe, unknown routes... const status = err.getStatus(); return { type: 'about:blank', title: STATUS_CODES[status], status, detail: err.message }; } return { type: 'about:blank', title: 'Internal Server Error', status: 500, detail: 'Something went wrong on our side. Please try again later.' }; // no internals } } -
Checked config, and wiring it all in AppModule
Save as
src/config/env.ts. Put real values in.env(already in the starter's.gitignore; check), and commit a.env.examplewith fake ones.TypeScriptimport { plainToInstance } from 'class-transformer'; import { IsIn, IsInt, IsString, Matches, Max, Min, validateSync } from 'class-validator'; class Env { @IsIn(['development', 'test', 'production']) NODE_ENV!: string; @IsInt() @Min(1) @Max(65535) PORT!: number; @IsString() @Matches(/^postgres(ql)?:\/\//) DATABASE_URL!: string; // used from api-03 } export function validateEnv(raw: Record<string, unknown>): Env { const env = plainToInstance(Env, raw, { enableImplicitConversion: true }); // "3000" to 3000 const errors = validateSync(env); if (errors.length > 0) { // name the bad keys, never print their values throw new Error(`Invalid environment: ${errors.map((e) => e.property).join(', ')}`); } return env; }Replace
src/app.module.ts(you can delete the generatedAppControllerandAppService). Inmain.ts, listen onapp.get(ConfigService).getOrThrow<number>('PORT')instead of the hard-coded port.TypeScriptimport { Module } from '@nestjs/common'; import { APP_FILTER, APP_PIPE } from '@nestjs/core'; import { ConfigModule } from '@nestjs/config'; import { ProblemDetailsFilter } from './common/problem-details.filter'; import { validationPipe } from './common/validation'; import { validateEnv } from './config/env'; import { OrdersModule } from './orders/orders.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, validate: validateEnv }), // fails fast at boot OrdersModule, ], providers: [ { provide: APP_PIPE, useValue: validationPipe }, // also active in e2e tests { provide: APP_FILTER, useClass: ProblemDetailsFilter }, ], }) export class AppModule {} -
Call it: one 201, one 400
Start the app with
npm run start:dev, then place a good order:Terminalcurl -i -X POST http://localhost:3000/v1/orders \ -H "Content-Type: application/json" \ -d '{"shopId":1042,"lines":[{"articleId":2040,"qty":12}]}'OutputHTTP/1.1 201 Created Location: /v1/orders/90400 Content-Type: application/json; charset=utf-8 (other headers trimmed) {"id":90400,"shopId":1042,"status":"PLACED","lines":[{"articleId":2040,"qty":12}], "placedAt":"2026-10-20T08:15:02.481Z"}Now the two problems from the Mangrove story, a typed-in-a-hurry quantity and a price the app should not send:
Terminalcurl -i -X POST http://localhost:3000/v1/orders \ -H "Content-Type: application/json" \ -d '{"shopId":1042,"lines":[{"articleId":2040,"qty":1200,"priceCents":350}]}'OutputHTTP/1.1 400 Bad Request Content-Type: application/problem+json; charset=utf-8 (other headers trimmed, body formatted) {"type":"https://api.mangrove.example/problems/validation", "title":"The request is not valid.","status":400,"detail":"2 field(s) need attention.", "errors":[{"pointer":"#/lines/0/priceCents","detail":"property priceCents should not exist"}, {"pointer":"#/lines/0/qty","detail":"qty must not be greater than 999"}], "instance":"/v1/orders"}Also try
curl -i http://localhost:3000/v1/orders/abc(400 fromParseIntPipe) and/v1/orders/99999(404, typeorder-not-found). All three errors have the same shape. The api-01 contract also adds arequestIdto every error; a small middleware that sets it is a good first exercise on your own.
Bring it to your team
Ask these in the review of any new endpoint.
- "Which DTO checks this body, and does every nested object have both @ValidateNested and @Type?"
- "Is every path parameter parsed with a pipe, and every query field converted before it is checked?"
- "Could a queue worker call this rule? If yes, is it in the service rather than the controller?"
- "Which domain error does this throw, and which status does the filter map it to?"
- "Are the global pipe and filter registered as APP_PIPE and APP_FILTER, so the e2e tests use them too?"
Red flags
- A handler that takes
@Body() body: any, or a DTO field with no decorator whitelistwithoutforbidNonWhitelistedon an API other teams build against- Services that throw
NotFoundExceptionor readreq - Per-request data, such as the current shop, stored in a field of a singleton service
- An error response, startup message or log line that prints a config value or
err.messageof an unknown error
Practice
Write the order validator by hand
To see what the ValidationPipe does for you, write validateCreateOrder(body) without a library. It returns an array of { field, message }, empty when the body is valid. body is always an object. The rules:
shopId: a non-empty string. Otherwise fieldshopId.lines: an array of 1 to 100 lines. Otherwise fieldlines.- In each line
i:articleIda whole number of at least 1 (fieldlines.i.articleId), andqtya whole number from 1 to 999 (fieldlines.i.qty). A quantity sent as the text"12"is wrong. - No unknown fields. The top level allows
shopId,linesand an optionalnote; a line allowsarticleIdandqty. An unknown field's own name is the field (discount,#/lines/0/priceCents).
The message text is up to you. (The filter in Build it writes these paths as JSON Pointers, such as #/lines/0/qty; dotted paths keep this exercise short.) The starter checks a few things and misses the rest.
function validateCreateOrder(body) {
const errors = [];
if (!body.shopId) errors.push({ field: 'shopId', message: 'shopId is required' });
if (!Array.isArray(body.lines) || body.lines.length < 1) {
errors.push({ field: 'lines', message: 'add at least one line' });
} else {
body.lines.forEach((line, i) => {
if (line.qty < 1) errors.push({ field: `lines.${i}.qty`, message: 'qty must be at least 1' });
});
}
return errors;
}
const good = () => ({ shopId: 'shop-118', lines: [{ articleId: 1042, qty: 12 }, { articleId: 77, qty: 1 }] });
const fields = (body) => validateCreateOrder(body).map((e) => e.field);
test('a valid order, with or without a note, has no errors', () => {
expect(validateCreateOrder(good())).toEqual([]);
expect(validateCreateOrder({ ...good(), note: 'deliver after 10' })).toEqual([]);
});
test('shopId must be a non-empty string', () => {
expect(fields({ lines: good().lines })).toContain('shopId');
expect(fields({ ...good(), shopId: '' })).toContain('shopId');
expect(fields({ ...good(), shopId: 118 })).toContain('shopId');
});
test('an order has 1 to 100 lines', () => {
const line = { articleId: 5, qty: 1 };
expect(fields({ shopId: 's', lines: [] })).toContain('lines');
expect(fields({ shopId: 's', lines: Array.from({ length: 101 }, () => ({ ...line })) })).toContain('lines');
expect(validateCreateOrder({ shopId: 's', lines: Array.from({ length: 100 }, () => ({ ...line })) })).toEqual([]);
});
test('qty is a whole number from 1 to 999, and articleId a whole number from 1', () => {
for (const qty of [0, 2.5, 1000, '12']) {
expect(fields({ shopId: 's', lines: [{ articleId: 5, qty }] })).toContain('lines.0.qty');
}
expect(validateCreateOrder({ shopId: 's', lines: [{ articleId: 5, qty: 999 }] })).toEqual([]);
expect(fields({ shopId: 's', lines: [{ articleId: 1, qty: 1 }, { articleId: 0, qty: 1 }] })).toContain('lines.1.articleId');
});
test('unknown fields are rejected at the top level and inside lines', () => {
expect(fields({ ...good(), discount: 10 })).toContain('discount');
expect(fields({ shopId: 's', lines: [{ articleId: 5, qty: 2, priceCents: 350 }] })).toContain('lines.0.priceCents');
});
Show a solution
const TOP = ['shopId', 'lines', 'note'];
const LINE = ['articleId', 'qty'];
const isWhole = (v, min, max) => Number.isInteger(v) && v >= min && v <= max;
function validateCreateOrder(body) {
const errors = [];
const add = (field, message) => errors.push({ field, message });
for (const key of Object.keys(body)) { // like forbidNonWhitelisted
if (!TOP.includes(key)) add(key, `property ${key} should not exist`);
}
if (typeof body.shopId !== 'string' || body.shopId === '') add('shopId', 'shopId must be a non-empty string');
if (!Array.isArray(body.lines) || body.lines.length < 1 || body.lines.length > 100) {
add('lines', 'lines must have 1 to 100 lines');
return errors; // nothing to check inside
}
body.lines.forEach((line, i) => { // like @ValidateNested + @Type
for (const key of Object.keys(line)) {
if (!LINE.includes(key)) add(`lines.${i}.${key}`, `property ${key} should not exist`);
}
if (!isWhole(line.articleId, 1, Infinity)) add(`lines.${i}.articleId`, 'articleId must be a whole number from 1');
if (!isWhole(line.qty, 1, 999)) add(`lines.${i}.qty`, 'qty must be a whole number from 1 to 999');
});
return errors;
}Number.isInteger rejects 2.5 and the text "12" in one check, which line.qty < 1 never does: "12" < 1 is false, so the starter let it through. Collecting every error instead of stopping at the first lets a client fix the whole form in one try.
Put each piece of logic in its place
A teammate's pull request has six pieces of logic for the orders endpoints. For each, say where it belongs (middleware, guard, pipe or DTO, controller, service, or exception filter) and why in one line: (1) reject a request whose token is not valid; (2) add a request ID header to every request, including unknown routes; (3) refuse an order above the shop's credit limit; (4) reject qty of 0; (5) turn OrderNotCancellableError into a 409; (6) set the Location header after a 201.
- Token check in a guard, because it must run before the body is read and can see the route
- Request ID in middleware, because it runs for every request before routing
- Credit limit in the service, because it needs data and a queue worker must apply it too
- qty of 0 in the DTO, checked by the ValidationPipe
- The 409 mapping in the exception filter; the Location header in the controller
Show a model answer
(1) Guard: it runs before pipes and answers 401 without reading the body. (2) Middleware: it runs for every request, even ones that match no route. (3) Service: it is a business rule that needs the shop's balance, and the phone-order worker in api-05 must hit the same rule. (4) DTO with @Min(1): the ValidationPipe rejects it with a 400 before the handler runs. (5) Exception filter: the service throws a domain error and the filter owns every status-code decision. (6) Controller: the Location header is HTTP, and the controller is the one HTTP-aware layer that knows the new order's id.
Check yourself
The credit-limit check was written in OrdersController.place. A month later, orders created by the phone-order queue worker sometimes exceed the limit. What is the best fix?
A business rule belongs where every entry point passes: the service. Copies drift apart. A guard answers "may this caller use this route", and the worker does not use a route at all. Calling your own API over HTTP adds a network hop and still leaves the rule in the wrong layer.
To avoid passing the shop to every method, a teammate stores the current shop in a field of OrdersService, set at the start of each request. What goes wrong?
Providers are singletons by default, and requests overlap while one waits for the database. One request can set the field, another can change it, and the first then uses the wrong shop. Making the service request-scoped would fix that, but makes Nest rebuild it and everything that injects it per request. Passing the shop as an argument is simpler and cheaper.
The orders DTO has @ValidateNested({ each: true }) on lines but no @Type. The pipe has whitelist and forbidNonWhitelisted on. A line arrives as { "articleId": 5, "qty": 5000, "x": 1 }. What is the most likely result?
Without @Type, class-transformer does not know which class the line objects are, so they stay plain objects with no rules attached. Depending on the class-validator version, they either pass or fail with a generic message (recent versions say "an unknown value was passed to the validate function"), but never with the line's real rules. Nothing fails at startup; the bug shows up only when bad data gets in.
Flashcards
Key takeaways
- The pipeline order decides the error: middleware, guards, interceptors, pipes, handler, interceptors, and one global exception filter for anything that throws.
- Modules share only what they export. The container builds singletons by constructor type, which also lets tests swap any provider for a fake.
- Controllers stay thin; services hold the rules and throw domain errors; the filter alone decides status codes and writes problem details.
- Validate every input: DTOs with ValidateNested plus Type for lines, a global ValidationPipe with whitelist, forbidNonWhitelisted and transform, and ParseIntPipe (or ParseUUIDPipe) on path ids.
- Check config at startup, name bad keys without printing values, and never send an unknown error's message to the client.
Sources
- Request lifecycle, NestJS documentation
- Validation, NestJS documentation
- Modules, NestJS documentation
- Exception filters, NestJS documentation
- RFC 9457: Problem Details for HTTP APIs, IETF, 2023