ConceptsClass Runtime

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 @Module groups controllers and providers, and how imports composes feature modules into a graph
  • Recognize what registerModule actually does — walk the graph, register providers, then hand off to the existing controller pipeline
  • Choose modules for feature composition, and know why exports is 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

Loading diagram...

Read the arrows from AppModule into UserModule and BillingModule as importsAppModule 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:

app.ts
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:

composing-features.ts
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);

ObservationAppModule 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. collectModuleGraph visits imports depth-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, registerModule registers each of its providers — a bare class keeps its own @Service scope (or singleton if 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 explicit options.container, else a fresh one under options.isolate, else app.container, else the global container.
  • Controllers flatten into the existing pipeline. Every module's controllers are collected and deduplicated across the whole graph, then handed to registerControllers as if they'd been listed by hand — the same route building, DI validation, guard checks, and lifecycle-hook bridging that a plain registerControllers(app, { controllers }) call already does. registerModule adds composition in front of that pipeline; it does not reimplement it.
  • A class missing @Module fails loudly. If the root or anything in an imports array isn't @Module-decorated, collectModuleGraph throws NotAModuleError before 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 @Module per feature keeps its pieces named together instead of scattered across a flat controllers: [...] array.
  • Composing sub-features into a larger appimports lets AppModule say "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 registerModule walks 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):

registration-options.ts
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 registerModule time, not per request. Graph size is the number of distinct modules reachable through imports, 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 registerModule resolves, request handling goes through the same route table registerControllers would have built directly. Measure with apps/benchmark before 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 exports limits 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 from exports keeps it private. Why: exports is recorded metadata only — nothing in the current registrar reads it to restrict resolution. Safe default: treat every provider registered anywhere in a registerModule graph 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 on exports as 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: collectModuleGraph dedupes 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 in imports order 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; imports lets a root module compose sub-features without listing their internals; diamond imports and import cycles are handled for you instead of by convention; registerModule reuses registerControllers verbatim, so route building, validation, and lifecycle hooks behave identically whether or not modules are used.
  • Costsexports is recorded but not enforced, so modules group, they do not yet encapsulate — every provider in a registerModule graph is visible to every controller in that graph through the shared container, regardless of exports; there is no dynamic-module pattern (forRoot/forFeature) yet.
  • Alternatives — a flat controllers: [...] array via registerControllers is simpler and loses nothing for a small app with no meaningful feature boundaries; isolate: true on a separate registerModule/registerControllers call 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, with exports already captured in metadata, means enabling enforcement later is a DI-layer change, not a rewrite of every @Module declaration. 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 (AppModule importing UserModule, 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 via registerControllers is simpler
  • ✗ You need a provider to be genuinely inaccessible outside its module — that boundary doesn't exist yet; use a separate registerModule/registerControllers call with isolate: true instead

Common mistakes

  • Treating exports as a privacy boundary. Why it happens: exports reads like the NestJS-style encapsulation feature it's modeled after. Correct approach: know that every provider registered anywhere in a registerModule graph 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 @Module on a class listed in imports. Why it happens: a plain class looks like it should compose the same way a module does. Correct approach: every entry in imports (and the root passed to registerModule) must carry @Module metadata. If ignored: collectModuleGraph throws NotAModuleError before anything registers — loud, not silent, but a trap during refactoring, when an @Module decorator gets stripped along with everything else on a class being simplified.
  • Expecting isolate to separate modules within one graph. Why it happens: isolate sounds like per-module isolation. Correct approach: isolate: true gives the entire registerModule call a fresh container, separate from other calls — every module inside that one call still shares it. If ignored: two modules in the same registerModule call are assumed to be isolated from each other when they never were.

Key takeaways

  • @Module groups a feature's controllers and providers under one declaration; imports composes other @Module classes 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 existing registerControllers pipeline — no route-building or validation logic is duplicated.
  • Diamond imports register once; import cycles are guarded rather than recursed into; a non-@Module entry in imports throws NotAModuleError.
  • Modules group, they do not yet encapsulateexports is recorded in metadata but nothing enforces it, so every provider in a graph is visible to every controller in that graph regardless of exports.
  • 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 controllers array.
  • 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

Was this helpful?

On this page