Skip to content
Knowledge Hub

NestJS architecture: modules, providers and controllers Basic

Estimated reading time: 7 min read

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 for new: 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.

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.

orders.service.ts
@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).

From the module at bootstrap to the controller and provider when a request arrives
From the module at bootstrap to the controller and provider when a request arrivesStep 1/5: At bootstrap, a module declares controllers, providers, and imports/exports. Nest builds the dependency graph and creates providers according to their scope.HTTP clientControllerProviderData source
Controller · Provider
Step 1/5

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.

When OrdersService needs PaymentsService from another module, three conditions must all hold:

  1. PaymentsService is in the providers of PaymentsModule.
  2. PaymentsModule declares exports: [PaymentsService].
  3. OrdersModule declares imports: [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-42

How 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().

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

  1. Controllers only receive and return HTTP; SQL, transactions and integrations live in providers.
  2. A provider used in another module has all three: providers and exports at the source, imports at the target.
  3. Export only a small public API; clients and internal config stay unexported.
  4. @Global() only for things truly used everywhere (config, logger).
  5. Don’t store request state (currentUser) in fields of default-scoped providers.
  6. Split modules by business boundary, not one per endpoint; a circular import needing forwardRef signals a wrong boundary.

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:

Terminal window
npx --prefix examples ts-node examples/src/nestjs-architecture/exports-demo.ts
npx --prefix examples ts-node examples/src/nestjs-architecture/main.ts
# in another terminal:
curl -i http://127.0.0.1:3002/orders/42