1. Courses
  2. Developer Foundations
  3. Building APIs with Node.js
  4. Stop 2

40 minIntermediate

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:

  1. : request logging, CORS, request IDs. It runs before Nest knows which handler will answer, so it cannot read a route's decorators.
  2. : "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.
  3. , the "before" half: start a timer, attach a request ID.
  4. : validate and convert each argument. A bad body gets a 400 here, and the handler never runs.
  5. The handler in the controller, which calls a service.
  6. Interceptors, the "after" half: shape or time the result on its way out.
  7. : 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.

POST /v1/orders in Nest Middleware request ID, CORS Guards who? allowed here? Interceptors in start a timer Pipes validate, convert Handler, service the rules run here Interceptors out log the duration Exception filter one error shape 401, 403 400 404, 409 201 + order JSON 4xx problem+json
The happy path runs top to bottom. A stage that throws skips everything below it and goes straight to the exception filter, so a request with a bad token never reaches the pipes, and a request with a bad body never reaches the handler.

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?

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 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 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 real OrdersService a fake price list. The service never knows. Testing and documenting the API builds on this.
OrdersModule imports PriceListModule OrdersController OrdersService injects PriceListModule exports PriceListService PriceCache private PriceListService exported injects: OK not exported: error Each provider built once injected by its class
Imports and exports are a contract between modules. OrdersService may use PriceListService because PriceListModule exports it and OrdersModule imports that module. PriceCache is private, so asking for it fails at startup, before any request is served.

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?

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 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: true removes 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: true answers 400 for those unknown properties instead of removing them quietly. For a partner API this is the better default: if the Partner app sends priceCents, Lagoon Digital's developers learn at once that the API ignores prices from the app.
  • transform: true gives the handler a real DTO instance and converts path and query values to the types you declared. Query strings always arrive as text, so a limit query 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?

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 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.

  1. 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 generate command also registers the new class in the right module file.

    Terminal
    npm 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
  2. 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.

    TypeScript
    import { 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
    }
  3. Domain errors and an in-memory service

    Save the errors as src/orders/orders.errors.ts. They know nothing about HTTP.

    TypeScript
    export 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. A Map stands in for the database until api-03.

    TypeScript
    import { 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;
      }
    }
  4. A thin controller

    Replace src/orders/orders.controller.ts. Each method reads checked input, calls the service and returns. passthrough: true lets us set the Location header and still return the body normally.

    TypeScript
    import { 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);
      }
    }
  5. The validation pipe and its field paths

    Save as src/common/validation.ts.

    TypeScript
    import { 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)),
    });
  6. 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.

    TypeScript
    import { 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
      }
    }
  7. 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.example with fake ones.

    TypeScript
    import { 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 generated AppController and AppService). In main.ts, listen on app.get(ConfigService).getOrThrow<number>('PORT') instead of the hard-coded port.

    TypeScript
    import { 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 {}
  8. Call it: one 201, one 400

    Start the app with npm run start:dev, then place a good order:

    Terminal
    curl -i -X POST http://localhost:3000/v1/orders \
      -H "Content-Type: application/json" \
      -d '{"shopId":1042,"lines":[{"articleId":2040,"qty":12}]}'
    Output
    HTTP/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:

    Terminal
    curl -i -X POST http://localhost:3000/v1/orders \
      -H "Content-Type: application/json" \
      -d '{"shopId":1042,"lines":[{"articleId":2040,"qty":1200,"priceCents":350}]}'
    Output
    HTTP/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 from ParseIntPipe) and /v1/orders/99999 (404, type order-not-found). All three errors have the same shape. The api-01 contract also adds a requestId to 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
  • whitelist without forbidNonWhitelisted on an API other teams build against
  • Services that throw NotFoundException or read req
  • 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.message of 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 field shopId.
  • lines: an array of 1 to 100 lines. Otherwise field lines.
  • In each line i: articleId a whole number of at least 1 (field lines.i.articleId), and qty a whole number from 1 to 999 (field lines.i.qty). A quantity sent as the text "12" is wrong.
  • No unknown fields. The top level allows shopId, lines and an optional note; a line allows articleId and qty. 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.

JavaScript exerciseRuns in your browser with tests
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;
}
Ctrl + Enter runs
Show a solution
JavaScript
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.

Pass the exercise to finish this task.

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?

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?

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?

Flashcards

Open my review

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

Episode transcriptThe gate, the clerk and the picker · about 12 minutes · tap any line to play from there
  1. NadiaImran, a sales rep is on the phone with a shop owner. The owner says twelve cartons of cooking oil. The rep types twelve hundred. What happens next?
  2. ImranWith our old ordering endpoint? The order goes through. The warehouse picks twelve hundred cartons, and the driver brings almost all of them back.
  3. NadiaAnd that's not a rare story. Roughly one phone order in twelve gets corrected after delivery. Today we build the first real code of the partner ordering API in NestJS, and we make sure no field reaches the business rules without being checked.
  4. ImranSo this is the lesson where the design from last time turns into running code.
  5. NadiaRight. Start with a 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.
  6. NadiaAt the counter, a clerk checks the form. Every line has a product code and a quantity, nothing scribbled in the margins. Only then does the form go to the back, where a picker who knows the real rules, like credit limits, does the work.
  7. ImranAnd if something goes wrong anywhere, the shop owner gets the same kind of slip back. What happened, which line, what to fix.
  8. NadiaExactly. NestJS has a person for each of those jobs, and they always work in the same order. That order decides which error a client sees.
  9. ImranGo on then. What's the order?
  10. NadiaIf you can see your screen, the first diagram is a column of boxes with one tall box on the right. If not, picture this. First, middleware. It runs on every request, before Nest even knows which handler will answer. Request IDs, CORS, that kind of thing.
  11. NadiaSecond, guards. Who are you, and may you call this route? A guard answers four oh one or four oh three. Third, the before half of interceptors, say starting a timer. Fourth, pipes. They validate and convert the input. A bad body gets a four hundred here.
  12. ImranAnd then the handler finally runs.
  13. NadiaThe handler in the controller, which calls a service. Then the after half of the interceptors, on the way out. And the tall box on the right is the exception filter. If any stage throws, everything below it is skipped, and the filter writes the error response.
  14. ImranLet me test that. The Partner app sends an order with an expired token, and the quantity is zero. I'd guess four hundred, because the body is clearly wrong.
  15. NadiaA fair guess, but it's four oh one. The guard runs before the pipes, so the body is never checked. And that's good for security. You don't want to tell an unknown caller which fields your API expects.
  16. ImranOkay. Now the warehouse departments from the analogy. Each one keeps its own tools.
  17. NadiaThose are modules. A module has four lists. Controllers, the classes that handle routes. Providers, the classes Nest builds and injects, like services. Imports, the other modules it needs. And exports, the providers it shares. A provider that isn't exported is private to its module.
  18. ImranAnd dependency injection is how a service gets the things it needs?
  19. NadiaYes. You write a constructor that asks for a price list service. TypeScript stores the parameter types as metadata on the class. At startup, Nest reads that list, finds a matching provider, builds it first, then builds your class.
  20. NadiaThe second diagram shows two modules stacked. Orders on top, price list below. The orders service may use the price list service, because the price list module exports it, and the orders module imports that module. A private price cache inside the price list module? Asking for it fails at startup.
  21. ImranI've seen that startup error. Nest can't resolve dependencies of the orders service. Our developers usually fix it by adding the missing service to the orders module's providers.
  22. NadiaThat's the tempting fix, and it's wrong. The error goes away, but now there are two separate instances, each with its own cache. The right fix is export from the owner, import in the user.
  23. ImranYou said instances. How many does Nest build?
  24. NadiaOne, by default. Providers are singletons, shared by every request. So never keep per-request data, like the current shop, in a field of a service. Two shops ordering at the same moment could overwrite each other. Pass the shop as an argument instead.
  25. ImranAnd the testing side?
  26. NadiaThat's the payoff. Because Nest looks providers up by token, a test can swap the real price list for a fake one, and the orders service never knows. The last lesson of this course builds on that.
  27. ImranWhere do the rules themselves go? Controller or service?
  28. NadiaService. A controller reads the checked input, calls one service method, and returns. In the background jobs lesson, a queue worker will place phone orders with no HTTP request at all. If the credit limit check lives in the controller, the worker skips it.
  29. ImranSo the service shouldn't throw a not found exception either? That's an HTTP thing.
  30. NadiaRight. It throws a domain error, like order not cancellable, with a stable code. One place, the exception filter, maps codes to status codes. Not found becomes four oh four, a state conflict becomes four oh nine.
  31. ImranNow the validation part. That's what would have caught the twelve hundred cartons.
  32. NadiaA DTO, a data transfer object, is a class that describes the request. Decorators on each field say what it must be. A whole number, at least one, at most nine hundred ninety-nine. The validation pipe turns the plain JSON into an instance of that class and runs the checks.
  33. NadiaOrder lines are nested objects, and here's the classic bug. You need two decorators together. Validate nested, which says check every line. And the Type decorator, which tells the library which class each line is. Forget the Type decorator, and the lines stay plain objects with none of their own rules.
  34. ImranWhat about the pipe's settings?
  35. NadiaThree matter. Whitelist removes any field without a decorator. Forbid non whitelisted turns that removal into a four hundred, so the client finds out. And transform hands the handler a real DTO, with real numbers. Query strings always arrive as text, so that conversion matters.
  36. ImranThat's what happened with Lagoon Digital, isn't it? The Mangrove story in the lesson.
  37. NadiaTwo things happened the same week. The reps' screen started showing that a quantity can't be greater than nine hundred ninety-nine, right next to the line, before the order was saved. And Lagoon Digital's test build started getting four hundreds for a price field inside the lines.
  38. ImranBecause the app had been sending its own cached prices.
  39. NadiaYes. The old API ignored them quietly, while the app showed them to Ravi as his total. Nobody had noticed for a year. With whitelist alone, it would still be hidden. Forbid non whitelisted made it visible.
  40. ImranAnd the error itself. What does the client actually get back?
  41. NadiaOne shape for everything, problem details from the first lesson. A type, a title, a status, a detail, the instance, and for validation an errors array with a pointer to each bad field, like the quantity of line zero. One global filter that catches every exception builds it.
  42. NadiaAnd the important case is the unknown error. Its message can hold a SQL fragment or a connection string. The filter logs the full error for you, and tells the client only that something went wrong on our side.
  43. ImranSame idea for config, I assume. Secrets.
  44. NadiaSame idea. The config module reads environment variables, and a validate function checks them at startup. If the database URL is missing, the app refuses to start and names the key. It must never print the value, because that puts the database password into your deploy logs.
  45. ImranWalk me through the Build it section.
  46. NadiaYou install the Nest command line tool, create a project called partner API, and add the validation and config packages. Then you generate an orders module, a controller and a service.
  47. NadiaYou write the order DTO with nested lines, a few domain errors, and a service that keeps orders in memory for now. The database arrives in the next lesson. The controller stays thin, and a pipe checks that the order ID in the path is a whole number.
  48. ImranThen the pipe and the filter.
  49. NadiaYes, and you register both as providers in the app module, not in the main file. That's a trap worth remembering. Integration tests never run the main file, so a pipe registered there means your tests run with no validation at all, and pass.
  50. NadiaFinally you call it with curl. A good order gets two oh one with a location header. Twelve hundred cartons and a price field get one four hundred listing both problems. A bad ID and an unknown order each get their own error, in the same shape.
  51. ImranFor my next review with the team, I'm writing down three questions. Does every nested object have both validate nested and the Type decorator? Could a queue worker need this rule, and if so, is it in the service?
  52. ImranAnd are the global pipe and filter registered in the module, so the tests use them too?
  53. NadiaGood list. I'd add one red flag: any log line or error response that prints a config value, or the message of an error you didn't expect.
  54. NadiaOne to think about. A teammate stores the current shop in a field of the orders service, set at the start of each request. What goes wrong? Take a second.
  55. ImranThe service is a singleton. Two requests overlap while one waits for the database, the second one changes the field, and the first one uses the wrong shop.
  56. NadiaExactly. You could make the service request scoped, but then Nest rebuilds it for every request. Passing the shop as an argument is simpler.
  57. ImranMy takeaways. One: the order is gate, clerk, picker. Guards, then pipes, then the handler, and one filter writes every error.
  58. ImranTwo: modules share only what they export, and the container builds one of each. Three: validate everything, nested lines included, and refuse unknown fields so the client finds out.
  59. NadiaAnd one addition. Rules live in services that throw domain errors. The filter alone picks status codes, and it never sends an unknown error's message to the client.
  60. NadiaNext time, the orders leave memory and move into PostgreSQL with Prisma, and we find out why fifty orders can quietly turn into fifty-one queries.
  61. ImranI'll count them.
Finished reading?Mark it complete to fill in its stop on your course line.

Use it at work

Unit-test starter

A function with a test file: the happy path, the edges and the errors, grouped and named clearly.

Open in the Code Lab
Next stopData with PostgreSQL and Prisma
Was this lesson helpful?
Questions and discussion

Loading the discussion…