Skip to main content
Node.js · lesson 18 of 20
Node.js advanced Lesson 18 of 20

NestJS: Modules, Controllers, and Providers

Build a NestJS module, controller, and service, fix a null response that should 404, then see a missing compiler flag turn dependency injection into a 500.

By SkillByExample · Updated

nodejs nestjs typescript dependency-injection

A NestJS application is built from three decorated pieces: a @Module that groups them, a @Controller that maps requests to methods, and @Injectable providers the controller’s constructor asks for by type instead of creating by hand. Decorators in TypeScript covers the legacy experimentalDecorators system NestJS runs on, including the emitDecoratorMetadata flag this lesson’s last section depends on — this lesson doesn’t re-teach decorators, only what NestJS does with them. Every command below runs against node:26.13 in Docker, NestJS 12.3.2, and TypeScript 6.0.3 — the exact version NestJS 12 requires, since npm’s own latest tag for typescript has already moved to 7.

A Module, a Controller, and a Provider

UsersService is the provider: plain data and a lookup method, with no knowledge of HTTP at all.

// src/users.service.ts
import { Injectable } from "@nestjs/common";

export interface User {
  id: number;
  name: string;
}

@Injectable()
export class UsersService {
  private readonly users: User[] = [
    { id: 1, name: "Ada" },
    { id: 2, name: "Linus" },
  ];

  findOne(id: number): User | undefined {
    return this.users.find((u) => u.id === id);
  }
}

UsersController is the controller: it never constructs a UsersService itself, only declares it as a constructor parameter, and ParseIntPipe converts the :id route param from a string to a number before findOne ever sees it:

// src/users.controller.ts
import { Controller, Get, Param, ParseIntPipe } from "@nestjs/common";
import { UsersService } from "./users.service.js";

@Controller("users")
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get(":id")
  findOne(@Param("id", ParseIntPipe) id: number) {
    return this.usersService.findOne(id);
  }
}

UsersModule is what ties the two together — it’s the unit NestJS actually builds, declaring which controller belongs to it and which provider it owns:

// src/users.module.ts
import { Module } from "@nestjs/common";
import { UsersController } from "./users.controller.js";
import { UsersService } from "./users.service.js";

@Module({
  controllers: [UsersController],
  providers: [UsersService],
})
export class UsersModule {}

AppModule is the root module every NestJS application needs — here it only imports UsersModule, but a real application would list every feature module here:

// src/app.module.ts
import { Module } from "@nestjs/common";
import { UsersModule } from "./users.module.js";

@Module({
  imports: [UsersModule],
})
export class AppModule {}

main.ts is the entry point: NestFactory, from @nestjs/core, reads AppModule and builds the whole dependency graph before listen ever binds a port:

// src/main.ts
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module.js";

const app = await NestFactory.create(AppModule);
await app.listen(3000);
console.log("listening on :3000");

package.json pins every version exactly, no ^ range, and needs "type": "module" — without it, tsc rejects the top-level await above:

{
  "name": "nestjs-modules-controllers-providers",
  "version": "1.0.0",
  "type": "module",
  "dependencies": {
    "@nestjs/common": "12.3.2",
    "@nestjs/core": "12.3.2",
    "@nestjs/platform-express": "12.3.2",
    "reflect-metadata": "0.2.2",
    "rxjs": "7.8.2"
  },
  "devDependencies": {
    "typescript": "6.0.3"
  }
}

@nestjs/platform-express is the HTTP layer NestFactory.create uses by default — it’s never imported by name anywhere above, but it’s what turns every @Get/@Controller mapping into a real Express route underneath. tsconfig.json needs both experimentalDecorators (NestJS’s decorator style) and emitDecoratorMetadata (covered in full depth, with the __metadata output it generates, in Decorators in TypeScript):

{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "es2022",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"]
}

node_modules lives in its own named Docker volume, nodejs-nestjs-modules-controllers-providers-modules, for the same reason every lesson in this topic uses one — a bind mount shares files with the Linux VM too slowly for node_modules’ thousands of small files:

$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-modules-controllers-providers-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npm install --no-audit --no-fund

added 96 packages in 10s

Why tsc, Not node Directly

NestJS’s own style guide writes a provider’s constructor exactly the way UsersController above does — constructor(private readonly usersService: UsersService) {} — a parameter property that both declares a field and assigns it in one line. Node’s built-in TypeScript type-stripping only erases type annotations; it has no transform for TypeScript-only runtime syntax like that, so running the controller file directly fails before a single route is registered:

$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-modules-controllers-providers-modules:/app/node_modules -w /app node:26.13 node src/users.controller.ts
node:internal/modules/run_main:111
    triggerUncaughtException(
    ^

file:///app/src/users.controller.ts:7
export class UsersController {
  constructor(private readonly usersService: UsersService) {}
                               ^^^^^^^^^^^^^^^^^^^^^^^^^^

SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript parameter property is not supported in strip-only mode
    at parseTypeScript (node:internal/modules/typescript:57:40)
    at processTypeScriptCode (node:internal/modules/typescript:129:37)
    at Object.stripTypeScriptModuleTypes (node:internal/modules/typescript:172:22)
    at stripTypeScriptModuleTypes (node:internal/modules/esm/translators:35:49)
    at ModuleLoader.<anonymous> (node:internal/modules/esm/translators:688:29)
    at #translate (node:internal/modules/esm/loader:441:20)
    at afterLoad (node:internal/modules/esm/loader:509:29)
    at ModuleLoader.loadAndTranslate (node:internal/modules/esm/loader:514:12)
    at #getOrCreateModuleJobAfterResolve (node:internal/modules/esm/loader:567:36)
    at afterResolve (node:internal/modules/esm/loader:614:52) {
  code: 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX'
}

Node.js v26.13.0

ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX points at the exact line and the exact span — the parameter property on users.controller.ts:7. This is a hard error, not a warning: a NestJS project always goes through tsc (or the Nest CLI’s own build, which wraps tsc), never node reading the .ts source directly.

Building and Running the Server

tsc compiles every file under src/ into dist/, following tsconfig.json’s rootDir/outDir:

$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-modules-controllers-providers-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx tsc
$ ls dist
app.module.js
main.js
users.controller.js
users.module.js
users.service.js

No output at all from tsc means no errors. node dist/main.js runs the compiled output, published on 3000 so curl from the host can reach it directly — Nest’s own logger colours its startup lines, which a real terminal shows in colour; the sed below strips the ANSI codes so the block below holds plain text:

$ docker run -d --name nodejs-nest-users -p 3000:3000 -v "$(pwd)":/app -v nodejs-nestjs-modules-controllers-providers-modules:/app/node_modules -w /app node:26.13 node dist/main.js
0d68a5e6dda2f8b24df3b2bed1a94781c7854bee30468d10e959d68072484740
$ until docker logs nodejs-nest-users 2>&1 | grep -q 'listening on'; do sleep 0.2; done
$ docker logs nodejs-nest-users 2>&1 | sed 's/\x1b\[[0-9;]*m//g'
[Nest] 1  - 11/04/2026, 8:15:00 AM     LOG [NestFactory] Starting Nest application...
[Nest] 1  - 11/04/2026, 8:15:00 AM     LOG [InstanceLoader] AppModule dependencies initialized +14ms
[Nest] 1  - 11/04/2026, 8:15:00 AM     LOG [InstanceLoader] UsersModule dependencies initialized +1ms
[Nest] 1  - 11/04/2026, 8:15:00 AM     LOG [RoutesResolver] UsersController {/users}: +5ms
[Nest] 1  - 11/04/2026, 8:15:00 AM     LOG [RouterExplorer] Mapped {/users/:id, GET} route +3ms
[Nest] 1  - 11/04/2026, 8:15:00 AM     LOG [NestApplication] Nest application successfully started +4ms
listening on :3000

Every line but the last starts with [Nest], a process ID, and a timestamp — masked above because both change between runs; a real terminal shows the process ID as 1 (NestJS is the container’s main process) and the current date and time. RoutesResolver names UsersController and its base path, /users; RouterExplorer confirms the one route this project declared, {/users/:id, GET}, actually got mapped. NestApplication successfully started and the final listening on :3000 (this lesson’s own console.log, not Nest’s logger — no [Nest] prefix) both printed, so the HTTP layer is up before any request is sent.

A Request That Works, and One That Doesn’t

GET /users/1 resolves through UsersController.findOne, into UsersService.findOne, and back out as JSON:

$ curl -si http://localhost:3000/users/1
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 21
ETag: W/"15-Uz5tLeD/GUuik8TTq8kwMsgQWKc"
Date: Wed, 04 Nov 2026 08:15:02 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"id":1,"name":"Ada"}

200 OK, X-Powered-By: Express (the platform @nestjs/platform-express installed, visible even though nothing in this project imported Express by name), and the exact object UsersService.findOne returned. ParseIntPipe runs before the handler ever sees id — a value that isn’t a numeric string fails that conversion instead of reaching findOne at all:

$ curl -s -w '\nSTATUS:%{http_code}\n' http://localhost:3000/users/abc
{"message":"Validation failed (numeric string is expected)","error":"Bad Request","statusCode":400}
STATUS:400

400, with ParseIntPipe’s own message naming exactly what it expected — numeric string — and never a stack trace: ParseIntPipe throws that BadRequestException itself, before the handler body ever runs, rather than an exception escaping from inside findOne.

A Missing User Is a 200, Not a 404

UsersService.findOne returns undefined for an ID that isn’t in the array, and UsersController.findOne just passes that straight back:

$ curl -si http://localhost:3000/users/9
HTTP/1.1 200 OK
X-Powered-By: Express
Date: Wed, 04 Nov 2026 08:15:02 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Content-Length: 0

200 OK, Content-Length: 0, no body at all. NestJS has no concept of “this handler’s return value means not found” — it only knows a handler ran to completion without throwing, so it serializes whatever came back (undefined serializes to nothing) and reports success. A client can’t tell this response apart from a handler that intentionally succeeds with nothing to return — both arrive as a 200 with an empty body; only the handler’s own code decides whether a miss should look any different.

The Fix: NotFoundException

UsersService.findOne now throws when the lookup misses, instead of returning undefined:

// src/users.service.ts (findOne throws instead of returning undefined)
import { Injectable, NotFoundException } from "@nestjs/common";

export interface User {
  id: number;
  name: string;
}

@Injectable()
export class UsersService {
  private readonly users: User[] = [
    { id: 1, name: "Ada" },
    { id: 2, name: "Linus" },
  ];

  findOne(id: number): User {
    const user = this.users.find((u) => u.id === id);
    if (!user) {
      throw new NotFoundException(`user ${id} not found`);
    }
    return user;
  }
}

UsersController doesn’t change at all — it never catches anything, because NotFoundException is an HttpException NestJS’s own exception layer already catches, outside the controller, and turns into the matching response. Rebuilding and restarting the same container with the new code gives a real 404:

$ docker rm -f nodejs-nest-users
nodejs-nest-users
$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-modules-controllers-providers-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx tsc
$ docker run -d --name nodejs-nest-users -p 3000:3000 -v "$(pwd)":/app -v nodejs-nestjs-modules-controllers-providers-modules:/app/node_modules -w /app node:26.13 node dist/main.js
3c77adb19b957cee1700fb86e3f92c674575ea5772b999750f771aeab6d893f0
$ until docker logs nodejs-nest-users 2>&1 | grep -q 'listening on'; do sleep 0.2; done
$ curl -si http://localhost:3000/users/9
HTTP/1.1 404 Not Found
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 67
ETag: W/"43-RqTHNBwS4K6RRhTJiUMQ2FVUt/w"
Date: Wed, 04 Nov 2026 08:15:11 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"message":"user 9 not found","error":"Not Found","statusCode":404}
$ curl -si http://localhost:3000/users/1
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 21
ETag: W/"15-Uz5tLeD/GUuik8TTq8kwMsgQWKc"
Date: Wed, 04 Nov 2026 08:15:11 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"id":1,"name":"Ada"}

404 Not Found, "message":"user 9 not found" — the exact string NotFoundException’s constructor was given — and "statusCode":404 in the body too, since NestJS’s default exception filter serializes the same status it sends as a header. /users/1 still returns 200 with Ada, unchanged: this fix only changed what happens when findOne can’t find anything.

The Real Failure: A Missing Compiler Flag

emitDecoratorMetadata is what makes constructor(private readonly usersService: UsersService) tell NestJS what to inject — without it, TypeScript still compiles the class, because the flag affects what gets emitted, not whether the syntax is valid. Removing just that one line is enough:

{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "es2022",
    "experimentalDecorators": true,
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"]
}
$ docker rm -f nodejs-nest-users
nodejs-nest-users
$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-modules-controllers-providers-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx tsc
$ docker run -d --name nodejs-nest-users -p 3000:3000 -v "$(pwd)":/app -v nodejs-nestjs-modules-controllers-providers-modules:/app/node_modules -w /app node:26.13 node dist/main.js
41a67e9d3d7481ad4feb8c8433844128231eaae266ecbe4a231b7c75a722307b
$ until docker logs nodejs-nest-users 2>&1 | grep -q 'listening on'; do sleep 0.2; done
$ docker logs nodejs-nest-users 2>&1 | sed 's/\x1b\[[0-9;]*m//g'
[Nest] 1  - 11/04/2026, 8:15:19 AM     LOG [NestFactory] Starting Nest application...
[Nest] 1  - 11/04/2026, 8:15:19 AM     LOG [InstanceLoader] AppModule dependencies initialized +13ms
[Nest] 1  - 11/04/2026, 8:15:19 AM     LOG [InstanceLoader] UsersModule dependencies initialized +1ms
[Nest] 1  - 11/04/2026, 8:15:19 AM     LOG [RoutesResolver] UsersController {/users}: +6ms
[Nest] 1  - 11/04/2026, 8:15:19 AM     LOG [RouterExplorer] Mapped {/users/:id, GET} route +7ms
[Nest] 1  - 11/04/2026, 8:15:19 AM     LOG [NestApplication] Nest application successfully started +3ms
listening on :3000

Identical to the working startup log, line for line: AppModule and UsersModule both initialize, UsersController’s /users base path resolves, /users/:id maps, and the application reports it started successfully. That’s misleading, not reassuring — Nest does read UsersController’s constructor parameter types at startup, exactly while building this dependency graph, but without emitDecoratorMetadata there’s no design:paramtypes metadata left to read. Nest treats the constructor as needing nothing, builds UsersController with no arguments right then, and moves on; the log looks identical because, from Nest’s own point of view, nothing went wrong. The gap — usersService left undefined — opens at that construction step, already during startup. A request is only where it’s noticed:

$ curl -si http://localhost:3000/users/1
HTTP/1.1 500 Internal Server Error
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 52
ETag: W/"34-rlKccw1E+/fV8niQk4oFitDfPro"
Date: Wed, 04 Nov 2026 08:15:21 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"statusCode":500,"message":"Internal server error"}
$ docker logs nodejs-nest-users 2>&1 | sed 's/\x1b\[[0-9;]*m//g' | tail -5
[Nest] 1  - 11/04/2026, 8:15:21 AM   ERROR [ExceptionsHandler] TypeError: Cannot read properties of undefined (reading 'findOne')
    at UsersController.findOne (file:///app/dist/users.controller.js:18:34)
    at file:///app/node_modules/@nestjs/core/router/router-execution-context.js:57:37
    at async file:///app/node_modules/@nestjs/core/router/router-execution-context.js:88:62
    at async UsersController.findOne (file:///app/node_modules/@nestjs/core/router/router-proxy.js:6:17)

500, with a body that says nothing about the cause — NestJS’s default exception filter replaces any error it didn’t throw itself with the same generic Internal server error message, on purpose, so an unhandled bug never leaks its own text to a client. The server’s own log is where the real cause is: TypeError: Cannot read properties of undefined (reading 'findOne'), pointing at UsersController.findOne calling this.usersService.findOne(...). this.usersService is undefined — NestJS’s injector reads a constructor’s parameter types from the design:paramtypes metadata emitDecoratorMetadata emits, and it reads that metadata at startup, while building the dependency-injection graph, not when a request arrives. With the flag gone, that read already came up empty back when the application was starting: the injector saw a constructor it couldn’t resolve a type for, treated it as having no dependencies, and constructed UsersController with none. The metadata read isn’t what waits for a request — that already happened. What waits is the first line of code that actually dereferences the result, this.usersService.findOne(...), which is why the crash doesn’t show up until a request does.

Restoring the Flag

Putting emitDecoratorMetadata: true back in tsconfig.json and rebuilding is the only change needed — nothing about UsersService, UsersController, or UsersModule was ever wrong:

{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "es2022",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"]
}
$ docker rm -f nodejs-nest-users
nodejs-nest-users
$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-modules-controllers-providers-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx tsc
$ docker run -d --name nodejs-nest-users -p 3000:3000 -v "$(pwd)":/app -v nodejs-nestjs-modules-controllers-providers-modules:/app/node_modules -w /app node:26.13 node dist/main.js
93a1355d8441742ad918c60aa5ab8432ceb15b982fa1f7cb8a46288d980c3591
$ until docker logs nodejs-nest-users 2>&1 | grep -q 'listening on'; do sleep 0.2; done
$ curl -si http://localhost:3000/users/1
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 21
ETag: W/"15-Uz5tLeD/GUuik8TTq8kwMsgQWKc"
Date: Wed, 04 Nov 2026 08:15:30 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"id":1,"name":"Ada"}
$ docker rm -f nodejs-nest-users
nodejs-nest-users

200 again, Ada again — the exact same source file, the only difference being whether tsc had the one flag that tells it to emit the metadata UsersController’s own constructor needs at runtime.

Practice

1. Add GET /users (no :id) backed by a new findAll() method on UsersService, returning the full array.

Solution
// src/users.service.ts (adds findAll)
import { Injectable, NotFoundException } from "@nestjs/common";

export interface User {
  id: number;
  name: string;
}

@Injectable()
export class UsersService {
  private readonly users: User[] = [
    { id: 1, name: "Ada" },
    { id: 2, name: "Linus" },
  ];

  findAll(): User[] {
    return this.users;
  }

  findOne(id: number): User {
    const user = this.users.find((u) => u.id === id);
    if (!user) {
      throw new NotFoundException(`user ${id} not found`);
    }
    return user;
  }
}
// src/users.controller.ts (adds GET /users)
import { Controller, Get, Param, ParseIntPipe } from "@nestjs/common";
import { UsersService } from "./users.service.js";

@Controller("users")
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get()
  findAll() {
    return this.usersService.findAll();
  }

  @Get(":id")
  findOne(@Param("id", ParseIntPipe) id: number) {
    return this.usersService.findOne(id);
  }
}
$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-modules-controllers-providers-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx tsc
$ docker run -d --name nodejs-nest-practice1 -p 3000:3000 -v "$(pwd)":/app -v nodejs-nestjs-modules-controllers-providers-modules:/app/node_modules -w /app node:26.13 node dist/main.js
ce3c647b8b6062c59b43d3f2810c3ecc1b5a5c215e58ca0731c99f7da56ad36a
$ until docker logs nodejs-nest-practice1 2>&1 | grep -q 'listening on'; do sleep 0.2; done
$ curl -s http://localhost:3000/users
[{"id":1,"name":"Ada"},{"id":2,"name":"Linus"}]
$ docker rm -f nodejs-nest-practice1
nodejs-nest-practice1

Both users come back, in the array order UsersService declares them — findAll needed no pipe and no parameter at all, since GET /users carries nothing to validate.

2. Add a check in UsersService.findOne that rejects a non-positive ID with BadRequestException, before the lookup runs. This solution builds on the main lesson’s final version, not on Practice 1 — UsersController below is unchanged, with no findAll route.

Solution
// src/users.service.ts (rejects non-positive IDs)
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";

export interface User {
  id: number;
  name: string;
}

@Injectable()
export class UsersService {
  private readonly users: User[] = [
    { id: 1, name: "Ada" },
    { id: 2, name: "Linus" },
  ];

  findOne(id: number): User {
    if (id <= 0) {
      throw new BadRequestException(`id must be positive, got ${id}`);
    }
    const user = this.users.find((u) => u.id === id);
    if (!user) {
      throw new NotFoundException(`user ${id} not found`);
    }
    return user;
  }
}
// src/users.controller.ts (unchanged from the main lesson — no findAll route here)
import { Controller, Get, Param, ParseIntPipe } from "@nestjs/common";
import { UsersService } from "./users.service.js";

@Controller("users")
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get(":id")
  findOne(@Param("id", ParseIntPipe) id: number) {
    return this.usersService.findOne(id);
  }
}
$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-modules-controllers-providers-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx tsc
$ docker run -d --name nodejs-nest-practice2 -p 3000:3000 -v "$(pwd)":/app -v nodejs-nestjs-modules-controllers-providers-modules:/app/node_modules -w /app node:26.13 node dist/main.js
c482362f76dbad37903324412d0233c5c1f43a2d6f6c446fdacd0cfb1787572a
$ until docker logs nodejs-nest-practice2 2>&1 | grep -q 'listening on'; do sleep 0.2; done
$ curl -s -w '\nSTATUS:%{http_code}\n' http://localhost:3000/users/0
{"message":"id must be positive, got 0","error":"Bad Request","statusCode":400}
STATUS:400
$ curl -s -w '\nSTATUS:%{http_code}\n' http://localhost:3000/users/-1
{"message":"id must be positive, got -1","error":"Bad Request","statusCode":400}
STATUS:400
$ docker rm -f nodejs-nest-practice2
nodejs-nest-practice2

ParseIntPipe already accepts a leading - as part of a valid integer, so /users/-1 reaches findOne as -1, not a ParseIntPipe rejection — the new check in findOne is what turns both 0 and -1 into 400, before the array lookup that would otherwise just fail to find them.

3. Remove UsersService from UsersModule’s providers array (leave it imported and still listed in UsersController’s constructor) and see what NestJS does at startup.

Solution
// src/users.module.ts (UsersService removed from providers only — the import stays, left broken on purpose)
import { Module } from "@nestjs/common";
import { UsersController } from "./users.controller.js";
import { UsersService } from "./users.service.js";

@Module({
  controllers: [UsersController],
  providers: [],
})
export class UsersModule {}
$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-modules-controllers-providers-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx tsc
$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-modules-controllers-providers-modules:/app/node_modules -w /app node:26.13 node dist/main.js 2>&1 | sed 's/\x1b\[[0-9;]*m//g' | head -3
[Nest] 1  - 11/04/2026, 8:15:54 AM     LOG [NestFactory] Starting Nest application...
[Nest] 1  - 11/04/2026, 8:15:54 AM     LOG [InstanceLoader] AppModule dependencies initialized +10ms
[Nest] 1  - 11/04/2026, 8:15:54 AM   ERROR [ExceptionHandler] UnknownDependenciesException [Error]: Nest can't resolve dependencies of the UsersController (?). Please make sure that the argument UsersService at index [0] is available in the UsersModule module.

Process exits before listening on :3000 ever prints — a different failure mode entirely from removing emitDecoratorMetadata. Here NestJS catches the missing provider while building the dependency graph, before the application ever starts listening; UnknownDependenciesException names UsersController, the index of the missing argument, and UsersService by name. The emitDecoratorMetadata failure above is worse precisely because it has no equivalent check: the application starts and reports success, and only a request that actually reaches the handler reveals anything is wrong.

4. Restore UsersModule’s providers array and confirm the startup error from Practice 3 is gone.

Solution
// src/users.module.ts (restored)
import { Module } from "@nestjs/common";
import { UsersController } from "./users.controller.js";
import { UsersService } from "./users.service.js";

@Module({
  controllers: [UsersController],
  providers: [UsersService],
})
export class UsersModule {}
$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-modules-controllers-providers-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx tsc
$ docker run -d --name nodejs-nest-practice4 -p 3000:3000 -v "$(pwd)":/app -v nodejs-nestjs-modules-controllers-providers-modules:/app/node_modules -w /app node:26.13 node dist/main.js
dbca6a30ccbf4552274e8a04efcf037ec9bde0d3ea8bc623ea31138ebbdabf54
$ until docker logs nodejs-nest-practice4 2>&1 | grep -q 'listening on'; do sleep 0.2; done
$ curl -s http://localhost:3000/users/1
{"id":1,"name":"Ada"}
$ docker rm -f nodejs-nest-practice4
nodejs-nest-practice4

UsersService back in providers, and UsersController resolves it again — the exact same Ada response the very first example in this lesson showed.

Frequently Asked Questions

Which NestJS package provides @Controller, @Injectable, and NotFoundException?
@nestjs/common. It holds every decorator a route handler or a provider needs (@Controller, @Get, @Param, @Injectable, @Module), the built-in pipes like ParseIntPipe, and the exception classes like NotFoundException. @nestjs/core provides NestFactory, the function that actually builds and starts the application from a root module. @nestjs/platform-express wires that application to an underlying Express server — swapping it for @nestjs/platform-fastify changes the HTTP layer without touching a single decorator.
Why does a route that returns null or undefined come back as 200 instead of 404?
NestJS has no idea null means "not found" for your particular API — it only knows a handler returned successfully, so it serializes whatever that handler gave back and sends 200. A missing resource is only a 404 when the handler says so explicitly, by throwing a NotFoundException (or any other HttpException subclass) that NestJS's own exception layer turns into the matching status code.
Why does the server start successfully and only fail once a request arrives, when emitDecoratorMetadata is missing?
NestJS actually reads a constructor's design:paramtypes metadata at startup, while it builds the dependency-injection graph — not lazily at request time. Without emitDecoratorMetadata there's no metadata to read, so the injector treats the constructor as having no dependencies and builds the controller with none right then, already at startup; what's deferred to request time is only the symptom, a plain TypeError the moment the handler dereferences the now-undefined service.
Can I run a NestJS project with node's built-in TypeScript type stripping instead of compiling it with tsc?
No. Node's type-stripping mode only erases type annotations; it doesn't transform TypeScript-only runtime syntax like a constructor parameter property (constructor(private readonly x: X) {}), which NestJS's own style guide uses constantly. Running a file with one directly throws a SyntaxError naming the exact unsupported construct, so a NestJS project has to go through tsc (or the Nest CLI's own build, which also wraps tsc) before node ever sees it.
What's the difference between a module, a controller, and a provider in NestJS?
A controller (@Controller) maps incoming requests to methods and is responsible only for routing — reading parameters in, returning a value out. A provider (@Injectable) holds logic or state and gets handed to whatever constructor asks for it, by type, instead of being instantiated by hand. A module (@Module) is the unit NestJS actually builds: it declares which controllers belong to it, which providers it owns, and which other modules it imports, so the framework can wire every constructor's dependencies correctly before a single request arrives.