NestJS Module Architecture
Nest gives you modules, providers and decorators, and no opinion at all about how to use them. Two
teams with the same framework produce a clean bounded-context application and an inextricable web of
forwardRef calls. The difference is a handful of decisions made early, all of them reversible only
at great cost later.
Modules are bounded contexts
One module per business capability — OrdersModule, BillingModule — not per technical layer. A
module owns its controllers, its services, its repositories and its DTOs, and exports the narrowest
surface another module could need.
The SharedModule anti-pattern. A module named after its lack of a boundary accumulates
everything and is imported by everything, so it becomes the cycle magnet and the reason your test
bootstrap takes nine seconds. Two shapes replace it:
- Infrastructure modules with a real name —
DatabaseModule,HttpClientModule,ClockModule. Stateless, no business logic, imported freely. - A
@Global()module used exactly once, for genuinely cross-cutting config and logging. If you reach for a second one, the boundary is wrong.
forwardRef is a smell, not a solution
forwardRef silences a circular dependency; it does not remove one, and it will resurface as an
undefined provider at runtime in an order that depends on file layout. When you reach for it, pick
one of these instead:
- Emit an event. If
OrdersneedsBillingto do something after the fact, publishOrderPlacedand letBillingsubscribe. Most cycles are a notification wearing a method call. - Extract the shared interface into a third module both depend on. The cycle was two modules sharing a concept neither owns.
- Extract the shared behaviour into a module both import. Slower, and correct when the shared thing is logic rather than a type.
Layers inside the module
Controller → use-case service → repository.
- Controllers are thin. Route, validate, delegate, map the result. No business logic, no repository access, no transaction handling.
- Services hold one use case each where practical. A
OrdersServicewith nineteen public methods is a namespace, not a service. - DTOs in, mappers out. A request DTO with
class-validatordecorators comes in; an explicit response object goes out. Never return an ORM entity from a controller — it leaks the schema, serialises lazy relations unpredictably, and turns a column rename into a breaking API change.
Wire the pipeline once, globally
- A global
ValidationPipewithwhitelist: true,forbidNonWhitelisted: trueandtransform: true. Withoutwhitelist, unexpected properties pass silently into your handlers. - A typed config module validating
process.envat boot against a schema, so a missing variable fails at startup rather than at 3am. Banprocess.enveverywhere else — it is the easiest rule to enforce with a lint rule and one of the most valuable. - A global exception filter mapping domain errors to HTTP status codes in one place.
- An interceptor attaching a request id and structured logging.
Dependency-injection scopes
Providers are singletons by default, which is what you want. Scope.REQUEST is the trap.
A request-scoped provider forces Nest to instantiate a new instance per request — and the scope bubbles up the entire injection chain: everything that depends on it becomes request-scoped too, often including your controller. A single innocuous request-scoped logger can turn a whole module transient and measurably cut throughput.
Use it when you genuinely need per-request state and cannot pass it as an argument. Prefer
AsyncLocalStorage for request context, which gives you the same thing without the scope cascade.
Testing
- Unit —
Test.createTestingModulewith the collaborators mocked. Fast, and the right place for business rules and branching. - End-to-end — the real application against a real database in testcontainers, hitting HTTP. The right place for validation pipes, guards, serialisation and transactions, none of which a unit test exercises.
- Do not mock the repository and then claim the query works. That test asserts your mock.
Verification gate
- Build passes and the application boots — a DI error only appears at boot, never at compile time.
- No
forwardRefwas added. If one survives, the reason is written next to it. - No controller returns an ORM entity.
-
grep -rn "process.env" src/ --include=*.tshits only the config module. - Every new
Scope.REQUESTprovider is justified in a comment naming what it holds. - Unit and e2e suites both pass; paste the output rather than describing it.