NestJS asks for a lot before it gives anything back. Decorators on classes, a module declaring the classes, imports declaring the modules. For a first endpoint that is clearly more ceremony than an Express route, and the reasonable reaction is to wonder what it is buying.
It buys two things, and both of them only show up later. Explicit boundaries between features, and substitutable dependencies. Everything else in the framework is the price of those two.
A module is a boundary, not a folder
import { Module } from "@nestjs/common";
import { LeadsController } from "./leads.controller";
import { LeadsService } from "./leads.service";
import { DatabaseModule } from "../database/database.module";
@Module({
imports: [DatabaseModule], // what this feature needs
controllers: [LeadsController], // its HTTP surface
providers: [LeadsService], // what it owns
exports: [LeadsService], // what other features may use
})
export class LeadsModule {}Those four lines are the whole value proposition. LeadsService is private to this module unless it appears in exports, so another feature cannot quietly start depending on it — it has to import LeadsModule, and that import is visible in review.
In an unstructured codebase the equivalent is an import statement nobody noticed, and six months later every feature reaches into every other one. The module system does not prevent coupling; it makes coupling something you have to write down.
Injection is for substitution
import { Injectable } from "@nestjs/common";
import { LeadRepository } from "./lead.repository";
@Injectable()
export class LeadsService {
constructor(private readonly repo: LeadRepository) {}
async capture(input: CreateLeadDto) {
const existing = await this.repo.findBySourceRef(input.source, input.sourceRef);
if (existing) return existing; // same delivery arriving twice
return this.repo.insert(input);
}
}LeadsService never constructs a repository; it declares that it needs one. Which means the test provides a different one and the service is unchanged:
const module = await Test.createTestingModule({
providers: [
LeadsService,
{ provide: LeadRepository, useValue: { findBySourceRef: async () => null, insert: async (x) => ({ id: 1, ...x }) } },
],
}).compile();No database, no mocking library reaching into module internals, no network. That is the entire point of the container, and if a codebase is not getting that benefit, the decorators are overhead with no return.
Worth saying plainly: the if (existing) return existing above is a convenience, not idempotency. Two concurrent deliveries can both pass that check — the guarantee has to be a unique constraint in the database, for the reasons in idempotent APIs and webhooks.
Controllers stay thin
@Controller("leads")
export class LeadsController {
constructor(private readonly leads: LeadsService) {}
@Post()
@HttpCode(201)
create(@Body() dto: CreateLeadDto) {
return this.leads.capture(dto); // no logic, no res, no try/catch
}
}Validation is a pipe, not an if:
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // strip properties not on the DTO
forbidNonWhitelisted: true, // or reject them outright
transform: true, // "12" -> 12 where the DTO says number
}));whitelist is the setting worth knowing about: without it, extra properties in the body pass straight through to whatever consumes the DTO, which is how a request assigns a field nobody meant to expose.
The service, meanwhile, knows nothing about HTTP — no request, no response, no status codes. The same rule that keeps an Express handler boring, for the same reason: the next caller is a queue consumer, and it has no req.
Adopt the core, defer the rest
NestJS has a large surface and most of it should stay unused until something asks for it by name.
Use the core from day one. @Module, @Injectable and constructor injection, obviously. ValidationPipe globally, with DTOs doing the work of a schema. A ConfigModule that reads and validates environment variables once at boot rather than wherever someone needs a value. And exception filters, so error responses have one shape instead of one per developer.
Wait for a real need before reaching for the rest. Custom providers — useFactory, useClass, useValue — earn their place when construction has logic in it: a client assembled from config, a feature flag, a stub in tests. Dynamic modules with forRoot and forFeature are for when a module genuinely has to be configured differently by different consumers, and writing one before that happens is a lot of generics in service of a single call site. Interceptors suit cross-cutting concerns like timing, caching and response shaping. Guards suit authorisation that applies across many routes.
Two things deserve more caution than they usually get.
Request-scoped providers give you a new instance per request, which defeats pooling and sharing, and the cost is easy to miss until memory climbs over a week. That is the shape of growth described in finding a Node memory leak, and it is tedious to trace back to a decorator.
Circular dependencies are the other one. forwardRef will make them compile, and compiling is not the same as being a good idea. Two modules that need each other are usually one module, or they need a third that both depend on.
Where the ceremony stops being worth it
NestJS is an argument that boundaries and substitutability are worth some ceremony. On a service with several developers and a long life ahead of it, that argument wins. On a four-endpoint API maintained by one person, the ceremony arrives long before the benefit does, and you spend a year paying for insurance against a risk you do not have.
If you do use it, use the small core properly. Modules that export deliberately rather than by default. Services that take their dependencies as constructor arguments. Controllers that do nothing at all. Then leave the advanced surface alone until a concrete problem asks for it, because every piece of it you adopt early is a piece you will be explaining to someone in a year.