Modules
How @Module groups a feature's controllers and providers behind one declaration, and why registerModule composes them into a single app — without yet enforcing what a module exports.
A small class-based app registers a flat list of controllers, and each controller's services register themselves through @Service. That works until the app grows a "users" feature and a "billing" feature side by side — each with its own controllers, its own services, and its own sub-features it depends on. Nothing today says "this is the users feature and here is everything it owns"; the boundary lives only in which files happen to sit in the same folder.
What you'll learn
- Understand why a flat controller list stops expressing a feature's boundary as an app grows
- Understand how
@Modulegroups controllers and providers, and howimportscomposes feature modules into a graph - Recognize what
registerModuleactually does — walk the graph, register providers, then hand off to the existing controller pipeline - Choose modules for feature composition, and know why
exportsis not yet an encapsulation boundary
The problem
Without modules, a class-based app registers its controllers directly and lets @Service classes register themselves as a side effect of import:
// Every feature's controllers get flattened into one list — no boundary between them.
import { createApp, listen } from 'nextrush';
import { Controller, Get, Service, registerControllers } from 'nextrush/class';
@Service()
class UserService {
findAll() {
return [{ id: 1, name: 'Alice' }];
}
}
@Controller('/users')
class UserController {
constructor(private users: UserService) {}
@Get() findAll() { return this.users.findAll(); }
}
@Service()
class BillingService {
total() {
return { amount: 4200 };
}
}
@Controller('/billing')
class BillingController {
constructor(private billing: BillingService) {}
@Get() total() { return this.billing.total(); }
}
const app = createApp();
await registerControllers(app, {
controllers: [UserController, BillingController], // users and billing, indistinguishable
});
listen(app, 8080);UserController and BillingController sit in the same array with no marker that they belong to different features. Add a third feature that depends on "users" — say, "orders" needs UserService — and there's no declared relationship to read; you'd only discover it by tracing imports across files.
Why this matters
A feature boundary that exists only in folder layout is invisible to the framework and erodes as a team grows. "Which controllers belong to billing?" and "does orders depend on users?" become questions answered by reading source, not by reading a declaration. That cost is small in a demo app and compounds precisely where it matters most — a codebase with enough features that composing them by hand is itself becoming the risk.
The solution
NextRush's @Module decorator groups a feature's controllers and providers behind one class, and its imports field composes other @Module classes into a graph. A single call to registerModule(app, RootModule) walks that graph, registers every module's providers into the DI container, then hands the whole flattened controller list to the same registerControllers pipeline every class-based app already uses. Modules don't replace that pipeline — they're a composition layer in front of it.
Core idea
Think of a module as a labeled group, not a walled compartment. @Module says "these controllers and these providers belong together, and these other modules are part of this feature too" — but it does not (yet) say "and nothing outside this group can see what's inside." Composition is real; isolation is future work, covered honestly below.
Mental model
Read the arrows from AppModule into UserModule and BillingModule as imports — AppModule composes both without owning their internals. The arrows into the container matter more than they look: both modules' providers land in the same shared container, which is exactly the limitation the next section is honest about.
Quick example
The smallest module graph is one feature module imported by a root module. registerModule wires both in a single call:
import { createApp, listen } from 'nextrush';
import { Module, Controller, Get, Service, registerModule } from 'nextrush/class';
@Service()
class UserService {
findAll() {
return [{ id: 1, name: 'Alice' }];
}
}
@Controller('/users')
class UserController {
constructor(private users: UserService) {}
@Get() findAll() { return this.users.findAll(); }
}
@Module({
controllers: [UserController],
providers: [UserService],
})
class UserModule {}
@Module({ imports: [UserModule] }) // composes UserModule into the app
class AppModule {}
const app = createApp();
await registerModule(app, AppModule, { prefix: '/api' });
listen(app, 8080);AppModule declares no controllers or providers of its own — it composes UserModule through imports. One registerModule call registers UserService into the container and wires UserController's route, exactly as registerControllers would have with an explicit controllers array.
How it works
Example — a root module importing two sibling feature modules, each with its own controller and service:
import { Module, Controller, Get, Service, registerModule } from 'nextrush/class';
import { createApp, listen } from 'nextrush';
@Service()
class UserService {
findAll() { return [{ id: 1, name: 'Alice' }]; }
}
@Controller('/users')
class UserController {
constructor(private users: UserService) {}
@Get() findAll() { return this.users.findAll(); }
}
@Module({ controllers: [UserController], providers: [UserService] })
class UserModule {}
@Service()
class BillingService {
total() { return { amount: 4200 }; }
}
@Controller('/billing')
class BillingController {
constructor(private billing: BillingService) {}
@Get() total() { return this.billing.total(); }
}
@Module({ controllers: [BillingController], providers: [BillingService] })
class BillingModule {}
@Module({ imports: [UserModule, BillingModule] })
class AppModule {}
const app = createApp();
await registerModule(app, AppModule, { prefix: '/api' });
listen(app, 8080);Observation — AppModule never touches UserService or BillingController directly. Both features register through one registerModule(app, AppModule) call, and both /api/users and /api/billing routes end up on the same app.
Explanation — a few rules from the module registrar produce that behavior:
- The import graph is walked post-order, once per module.
collectModuleGraphvisitsimportsdepth-first and collects each module after its own imports, so a feature module's providers register before the module that imports it. A module reached twice — through a diamond import, or listed twice — is registered exactly once; a back-edge that would form a cycle (A imports B imports A) is skipped rather than recursed into. - Providers register into one shared container. For every module in graph order,
registerModuleregisters each of itsproviders— a bare class keeps its own@Servicescope (orsingletonif undecorated); a provider config ({ provide, useClass | useValue | useFactory }) registers under its token with the matching kind. All of it lands in the same container: an explicitoptions.container, else a fresh one underoptions.isolate, elseapp.container, else the global container. - Controllers flatten into the existing pipeline. Every module's
controllersare collected and deduplicated across the whole graph, then handed toregisterControllersas if they'd been listed by hand — the same route building, DI validation, guard checks, and lifecycle-hook bridging that a plainregisterControllers(app, { controllers })call already does.registerModuleadds composition in front of that pipeline; it does not reimplement it. - A class missing
@Modulefails loudly. If the root or anything in animportsarray isn't@Module-decorated,collectModuleGraphthrowsNotAModuleErrorbefore anything registers — a typo in an import list doesn't silently register nothing.
The exact metadata shape and provider-registration code live in the @nextrush/class reference — a concept teaches the model, not every field.
`exports` is recorded, not enforced
@Module({ exports: [...] }) accepts a list of providers today, and getModuleMetadata returns
it — but nothing reads it to restrict visibility. Every provider registered anywhere in the graph
is resolvable by every controller in that graph, whether or not it appears in exports. See
Trade-offs below for why, and what changes when this is enforced.
Typical use cases
Reach for a module when a feature is big enough that its composition is worth declaring:
- Grouping a feature's own controllers and services — one
@Moduleper feature keeps its pieces named together instead of scattered across a flatcontrollers: [...]array. - Composing sub-features into a larger app —
importsletsAppModulesay "the app is users plus billing plus orders" without listing every controller those features own. - Reusing the same feature across apps — a self-contained module (its own controllers, its own providers) can be imported wherever the feature is needed, since
registerModulewalks whatever graph it's given.
A single-controller app, or one where every controller already shares one flat namespace, gains little from a module — see the decision guide.
Configuration
registerModule takes the same registration options as registerControllers, minus the ones module composition replaces (root, controllers):
import { Module, registerModule } from 'nextrush/class';
import { createApp, listen } from 'nextrush';
@Module({ imports: [] })
class AppModule {}
const app = createApp();
await registerModule(app, AppModule, {
prefix: '/api', // route prefix applied to every controller in the graph
isolate: true, // register providers into a fresh container, not the global one
validate: true, // eagerly validate the DI graph before serving requests
});
listen(app, 8080);isolate and an explicit container change which container the whole graph's providers land in — not whether they're shared once there. Two sibling modules registered into the same call always share one container, isolate or not; the option controls isolation from other registerModule/registerControllers calls, not isolation between modules in the same graph. The full option table lives in the reference.
Performance
- Complexity — the import graph is walked once, at
registerModuletime, not per request. Graph size is the number of distinct modules reachable throughimports, and the walk is linear in that count plus the number of import edges. - Memory — provider instances follow the same scope rules as dependency injection — a singleton provider registered by a module is built once and shared, regardless of which module registered it.
- Scaling — module composition is a startup-time cost, not a request-time one; once
registerModuleresolves, request handling goes through the same route tableregisterControllerswould have built directly. Measure withapps/benchmarkbefore tuning.
Security
Modules change how a graph is assembled, not how a request is authorized — but the shared-container reality below has a real consequence worth stating plainly.
- Assuming
exportslimits what a provider can reach. Threat: a team writes an internal, security-sensitive service (a credential store, an admin-only client) into a module and assumes omitting it fromexportskeeps it private. Why:exportsis recorded metadata only — nothing in the current registrar reads it to restrict resolution. Safe default: treat every provider registered anywhere in aregisterModulegraph as globally resolvable within that container, and gate access with a guard or by not registering the sensitive dependency into a shared graph at all. Avoid: relying onexportsas an access-control boundary — it is not one yet. - Diamond imports registering a provider twice with different config. Threat: two modules import the same feature module with conflicting provider configs, and only the first registration wins silently. Why:
collectModuleGraphdedupes by module identity and provider registration skips a token that's already registered. Safe default: register a shared provider's canonical config in exactly one module that others import, rather than duplicating a similar-but-different config across modules. Avoid: assuming the last module inimportsorder wins — the first completed registration does.
Everything a registerModule graph registers is visible to everything else in that graph. If a
provider must not be reachable from a given controller, keep it out of that registerModule call
entirely — exports/imports describe composition today, not a security boundary.
Trade-offs
Why composition first, encapsulation later — modules solve the 90% value (naming and wiring a feature's pieces in one declaration) without requiring the much larger machinery a hard module-private boundary needs.
- Benefits — a feature's controllers and providers are named together instead of scattered;
importslets a root module compose sub-features without listing their internals; diamond imports and import cycles are handled for you instead of by convention;registerModulereusesregisterControllersverbatim, so route building, validation, and lifecycle hooks behave identically whether or not modules are used. - Costs —
exportsis recorded but not enforced, so modules group, they do not yet encapsulate — every provider in aregisterModulegraph is visible to every controller in that graph through the shared container, regardless ofexports; there is no dynamic-module pattern (forRoot/forFeature) yet. - Alternatives — a flat
controllers: [...]array viaregisterControllersis simpler and loses nothing for a small app with no meaningful feature boundaries;isolate: trueon a separateregisterModule/registerControllerscall gives real container isolation between unrelated app slices, at the cost of no shared singletons between them. - Why NextRush chose this — true per-module encapsulation needs per-module child containers and export-aware resolution — a change to
@nextrush/di's hierarchical container model large enough to risk the shared-container fast path every existing caller depends on. Shipping composition now, withexportsalready captured in metadata, means enabling enforcement later is a DI-layer change, not a rewrite of every@Moduledeclaration. See RFC-NEXTRUSH-MODULES §5 for the full reasoning.
Decision guide
Choose a module when:
- ✓ A feature has enough controllers and providers that naming them together, in one declaration, is worth the indirection
- ✓ You're composing an app from sub-features (
AppModuleimportingUserModule,BillingModule, …) rather than listing every controller flat - ✓ You want diamond-import and import-cycle handling done for you instead of by convention
Avoid a module when:
- ✗ The app has one or two controllers with no real feature boundary — a flat
controllers: [...]array viaregisterControllersis simpler - ✗ You need a provider to be genuinely inaccessible outside its module — that boundary doesn't exist yet; use a separate
registerModule/registerControllerscall withisolate: trueinstead
Common mistakes
- Treating
exportsas a privacy boundary. Why it happens:exportsreads like the NestJS-style encapsulation feature it's modeled after. Correct approach: know that every provider registered anywhere in aregisterModulegraph is currently resolvable from anywhere else in that same graph. If ignored: a service meant to be module-private is silently reachable by an unrelated controller, and the mistake surfaces only when something depends on it that shouldn't. - Forgetting
@Moduleon a class listed inimports. Why it happens: a plain class looks like it should compose the same way a module does. Correct approach: every entry inimports(and the root passed toregisterModule) must carry@Modulemetadata. If ignored:collectModuleGraphthrowsNotAModuleErrorbefore anything registers — loud, not silent, but a trap during refactoring, when an@Moduledecorator gets stripped along with everything else on a class being simplified. - Expecting
isolateto separate modules within one graph. Why it happens:isolatesounds like per-module isolation. Correct approach:isolate: truegives the entireregisterModulecall a fresh container, separate from other calls — every module inside that one call still shares it. If ignored: two modules in the sameregisterModulecall are assumed to be isolated from each other when they never were.
Key takeaways
@Modulegroups a feature'scontrollersandprovidersunder one declaration;importscomposes other@Moduleclasses into a graph.registerModule(app, RootModule)walks the import graph post-order, registers every module's providers into one shared container, then flattens all controllers into the existingregisterControllerspipeline — no route-building or validation logic is duplicated.- Diamond imports register once; import cycles are guarded rather than recursed into; a non-
@Moduleentry inimportsthrowsNotAModuleError. - Modules group, they do not yet encapsulate —
exportsis recorded in metadata but nothing enforces it, so every provider in a graph is visible to every controller in that graph regardless ofexports. - Choose a module when a feature's composition is worth naming; a small app with no real feature boundary loses nothing by staying with a flat
controllersarray. - True per-module encapsulation (export-gated resolution, per-module containers) is deferred follow-up work, not a hidden current behavior — treat any provider in a shared graph as globally reachable today.
Continue learning
Dependency Injection
How NextRush resolves a class's constructor dependencies for you, and what singleton, transient, and request scope each guarantee about instance lifetime.
Interceptors
How NextRush wraps a controller method with code that runs before and after the handler — to time it, reshape its result, or return a cached value.