NestJS Architecture: Modules, Providers and DI Explained
NestJS borrows its architecture from Angular and Spring, and it pays off on large codebases. A grounded tour of modules, providers and dependency injection.
Developers coming to NestJS from Express often bounce off it. Where Express is a library you call, Nest is a framework that calls you, and its three core ideas — modules, providers and dependency injection — can feel like ceremony until the codebase grows past a certain size. Having built and maintained several large Nest platforms, we think the ceremony is the product. Here is what each piece is actually for.
Modules are ownership boundaries
A Nest module groups related controllers and providers and declares what it exposes to the rest of the application. Think of each module as a bounded context with a public API: BillingModule exports BillingService and keeps its Stripe client, retry logic and price tables private.
@Module({
imports: [HttpModule],
controllers: [BillingController],
providers: [BillingService, StripeClient],
exports: [BillingService],
})
export class BillingModule {}The discipline this enforces is subtle but real: another module cannot quietly import StripeClient and start making charges, because it is not exported. On a codebase with fifteen developers, the module graph becomes a living architecture diagram that the compiler enforces.
Providers and the injector
A provider is any class Nest can instantiate and hand to something else — services, repositories, clients. The @Injectable decorator marks the class, and constructor parameters declare what it needs. Nest's injector resolves the graph at startup, instantiating each provider once by default.
@Injectable()
export class InvoiceService {
constructor(
private readonly billing: BillingService,
private readonly logger: PinoLogger,
) {}
async issue(orderId: string) {
// business logic only — no wiring
}
}The practical benefit shows up in tests. Because InvoiceService receives its dependencies rather than importing them, a unit test constructs it with fakes directly — no module mocking, no jest.mock gymnastics. The classes you test are the classes you run.
Custom providers and tokens
Not everything is a class. Configuration objects, third-party SDK instances and feature flags are registered with useValue or useFactory under an injection token. This is the escape hatch that lets the DI container manage things it did not construct, and it is how you swap a real S3 client for an in-memory one in integration tests with a single overrideProvider call.
Configuration deserves the same treatment. Rather than reading process.env throughout the codebase, load and validate environment variables once at bootstrap — @nestjs/config with a validation schema does this well — and inject typed config objects where they are needed. Misconfiguration then fails at startup with a named missing variable, instead of surfacing as an undefined deep inside a request three hours after the deploy.
Where teams go wrong
- One giant SharedModule that exports everything — it recreates the global namespace DI was meant to kill.
- Injecting the ORM's repository into controllers, skipping the service layer entirely.
- Request-scoped providers used casually: they disable singleton caching and can cost real throughput.
- Circular imports between modules, papered over with forwardRef instead of fixing the design.
Each of these is the framework telling you something about your boundaries. forwardRef in particular is nearly always a sign that two modules want to be one, or that a third module wants extracting.
It is also worth learning the request pipeline that surrounds DI: guards for authorisation, pipes for validation, interceptors for cross-cutting concerns such as timing and response shaping. Teams that put this logic in controllers because they have not met the pipeline end up with exactly the fat handlers Nest was designed to prevent.
NestJS is not the right tool for every service, and we say so in our framework comparisons. But when the codebase is big, the team is growing and the product will live for years, its architecture is not overhead — it is the thing that keeps velocity flat while everything else grows.
Inherited a NestJS platform that has drifted? We offer architecture reviews — talk to STRCLI.