TheSkillz

NestJS Module Architecture

Module boundaries, thin controllers and the DI scopes that halve throughput

TheSkillz Team TheSkillz Team No reviews yet1 installsv1.0.0
Scan passed · 100/100Human reviewedOfficial · TheSkillz
☆ Star 0

One module per bounded context with the SharedModule anti-pattern replaced by named infrastructure modules, forwardRef treated as a smell with the three refactors that actually remove the cycle, DTOs in and response mappers out so an ORM entity never reaches the wire, and the request-scope cascade explained before it costs you throughput.

SKILL.md

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:

  1. Emit an event. If Orders needs Billing to do something after the fact, publish OrderPlaced and let Billing subscribe. Most cycles are a notification wearing a method call.
  2. Extract the shared interface into a third module both depend on. The cycle was two modules sharing a concept neither owns.
  3. 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 OrdersService with nineteen public methods is a namespace, not a service.
  • DTOs in, mappers out. A request DTO with class-validator decorators 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 ValidationPipe with whitelist: true, forbidNonWhitelisted: true and transform: true. Without whitelist, unexpected properties pass silently into your handlers.
  • A typed config module validating process.env at boot against a schema, so a missing variable fails at startup rather than at 3am. Ban process.env everywhere 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

  • UnitTest.createTestingModule with 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 forwardRef was added. If one survives, the reason is written next to it.
  • No controller returns an ORM entity.
  • grep -rn "process.env" src/ --include=*.ts hits only the config module.
  • Every new Scope.REQUEST provider is justified in a comment naming what it holds.
  • Unit and e2e suites both pass; paste the output rather than describing it.

Reviews

Sign in to leave a review.

  • Be the first to review this skill.

More in coding