NestJS architecture: modules, providers and controllers Basic
Estimated reading time: 7 min read
The problem
Section titled “The problem”The GET /orders/42 route initially just returns a fixed object. A few sprints later, the handler has to read the database, check permissions and call a payment service.
If everything lives in the controller, testing any route means building a pile of dependencies, and changing the data source touches HTTP code.
NestJS splits three roles to put responsibilities in the right place:
| Component | Answers the question | Should not be given |
|---|---|---|
| Controller | Where does the HTTP request go in, and what does the response look like? | SQL, transactions, lengthy integrations |
| Provider | Which logic is reusable, which dependencies need injecting? | Per-request state (when in the default scope) |
| Module | Which components share a boundary, and what is published to the outside? | Acting as a handler or storing runtime data |
Two common misunderstandings:
- A module is not a separate “running box” per request: it is metadata used to build the graph at startup.
@Injectable()is not shorthand fornew: the container manages the instance, which by default is shared across the whole application.
The token mechanics and provider registration are covered in the DI container article.
A complete module
Section titled “A complete module”This is the real code from examples/src/nestjs-architecture/, running on NestJS 11 with @nestjs/platform-express. Data is kept in a Map so the architecture is visible; there is no database or validation yet.
@Injectable()export class OrdersService { private readonly orders = new Map([[42, { id: 42, status: 'created' }]]);
findById(id: number) { const order = this.orders.get(id); if (!order) throw new NotFoundException('Order not found'); return order; }}
// orders.controller.ts@Controller('orders')export class OrdersController { constructor(private readonly orders: OrdersService) {}
@Get(':id') findById(@Param('id') rawId: string) { const id = Number(rawId); if (!Number.isInteger(id)) throw new BadRequestException('id must be an integer'); return this.orders.findById(id); }}
// orders.module.ts@Module({ controllers: [OrdersController], providers: [OrdersService] })export class OrdersModule {}
// main.ts@Module({ imports: [OrdersModule] })class AppModule {}const app = await NestFactory.create(AppModule);await app.listen(3002);GET /orders/42 returns { id: 42, status: 'created' }; /orders/99 returns 404 via NotFoundException. In production, replace Number() with a pipe or DTO (request lifecycle).
At bootstrap, a module declares controllers, providers, and imports/exports. Nest builds the dependency graph and creates providers according to their scope.
Modules only do work at bootstrap: Nest reads metadata, builds the graph, creates providers and wires them into constructors. When a request arrives, it goes through the router to the already-wired controller, and the module does not run again.
Import and export between two modules
Section titled “Import and export between two modules”When OrdersService needs PaymentsService from another module, three conditions must all hold:
PaymentsServiceis in theprovidersofPaymentsModule.PaymentsModuledeclaresexports: [PaymentsService].OrdersModuledeclaresimports: [PaymentsModule].
Loading diagram…
@Module({ providers: [PaymentsService], exports: [PaymentsService] })class PaymentsModule {}
@Module({ imports: [PaymentsModule], providers: [OrdersService] })class OrdersModule {}Remove exports and Nest fails at bootstrap. Real output from exports-demo.ts (Nest 11):
Missing exports -> Nest can't resolve dependencies of the OrdersService (?). Please make sure that the argument PaymentsService at index [0] is available in the BrokenOrdersModule module.With exports -> charged-42How to read the message: the line gives two pieces of information, which token is missing (PaymentsService at index 0) and which module is creating the class that needs it (BrokenOrdersModule). Answer those two questions before reaching for @Global().
When to split a module, and when not to
Section titled “When to split a module, and when not to”| Split a module when | Don’t when |
|---|---|
| There is a clear business boundary (orders, payments) and a small public API | One module per endpoint |
| A provider must be hidden from the rest (not exported) | Just to “keep the folder tidy” |
| You want to swap or test one part separately | Two modules import each other in a cycle (needing forwardRef is a sign of a wrong boundary) |
@Global() only fits things truly used everywhere (config, logger). The Nest docs state plainly that making everything global “is not a recommended design practice”.
Real-world example
Section titled “Real-world example”Checklist for reviewing a module
Section titled “Checklist for reviewing a module”- Controllers only receive and return HTTP; SQL, transactions and integrations live in providers.
- A provider used in another module has all three:
providersandexportsat the source,importsat the target. - Export only a small public API; clients and internal config stay unexported.
@Global()only for things truly used everywhere (config, logger).- Don’t store request state (
currentUser) in fields of default-scoped providers. - Split modules by business boundary, not one per endpoint; a circular import needing
forwardRefsignals a wrong boundary.
Common mistakes
Section titled “Common mistakes”Summary
Section titled “Summary”Self-check: (1) OrdersController reports a missing OrdersService: which module fields do you check? (2) Why not store currentUser in a default provider? (3) Does OrdersModule run again on every request?
Run the examples:
npx --prefix examples ts-node examples/src/nestjs-architecture/exports-demo.tsnpx --prefix examples ts-node examples/src/nestjs-architecture/main.ts# in another terminal:curl -i http://127.0.0.1:3002/orders/42