Modules
Every NestRS binary is a tree of modules. This page walks blog
through four steps — step ① inline, then the composed root the
tutorial implements:
- One root module — handlers inline, one transport.
- Extract a feature — domain code moves to
posts/with its own prefix. - Wire imports — static vs dynamic framework modules.
- Compose — the root lists imports only; adapters own the handlers.
The snippets are illustrative. Step ① matches the
Fundamentals tour — the same BlogModule grows on the
rest of this page.
flowchart LR A["① BlogModule<br/>inline handlers"] --> B["② posts/ feature<br/>PostsModule + adapters"] B --> C["③ framework imports<br/>DB, HTTP, config"] C --> D["④ root composes<br/>no inline providers"]
Start with one module
Section titled “Start with one module”A module is a struct decorated with #[module(...)]. It declares two
things: the providers it owns (built and registered into the
container) and the modules it imports (whose providers it can
inject).
At the smallest scale, handlers live in the app itself:
use nest_rs_core::module;use nest_rs_http::{HttpConfig, HttpModule};use crate::controller::BlogController;use crate::service::BlogService;
#[module( imports = [HttpModule::for_root(HttpConfig { port: 3005, ..Default::default() })], providers = [BlogService, BlogController],)]pub struct BlogModule;BlogModule activates HTTP and registers its own service and controller.
Providers in the same module share its name prefix — here, BlogService
and BlogController.
flowchart TB BM[BlogModule] --> HM["HttpModule::for_root()"] BM --> BS[BlogService] BM --> BC[BlogController] BC -. "injects" .-> BS
Two lists, one purpose
Section titled “Two lists, one purpose”| List | Holds | Purpose |
|---|---|---|
providers = [...] | Injectable structs this module builds — services, controllers, … | Registered into the flat container |
imports = [...] | Other modules — by type or by Module::for_root(opts) | Their providers become injectable here |
A provider listed by another module cannot be redeclared — the container
is flat. The access graph (see Providers)
ensures every #[inject] resolves through imports.
List order does not affect registration. We still list imports in a
readable order: config → infrastructure → transports → auth wiring →
feature adapters. The app root imports PostsHttpModule, not
PostsModule — the port rides along inside the adapter’s own imports.
Business modules sit at the bottom of the list.
Extract a feature
Section titled “Extract a feature”When a domain outgrows the app root, it moves to crates/features/ with
its own name prefix. The blog handlers become PostsService and
PostsController under PostsModule — same domain, feature naming, not
the app-root Blog* prefix anymore.
Directorycrates/features/src/posts/
- module.rs (PostsModule — the port)
- service.rs (PostsService)
- entity.rs
- dto.rs
- error.rs
Directoryhttp/
- module.rs (PostsHttpModule — imports PostsModule)
- controller.rs (PostsController)
One feature, several modules — never one umbrella import. The port
(PostsModule) holds the data layer — PostsService, entity, repo wiring.
It registers no controller, resolver, or gateway. Each transport
adapter (PostsHttpModule, …) imports the port and mounts its edge
only.
Why not put the controller in PostsModule?
Section titled “Why not put the controller in PostsModule?”If PostsController lived in PostsModule, every binary that imported
PostsModule would mount HTTP routes — a worker that only enqueues jobs,
another feature that injects PostsService, a headless test harness.
Importing the port would always drag the transport with it.
Splitting keeps transport opt-in per binary:
| Root imports | What activates |
|---|---|
PostsModule only | Data layer — PostsService injectable, no HTTP routes |
PostsHttpModule | HTTP adapter — imports PostsModule transitively, mounts PostsController |
BlogModule lists PostsHttpModule, not PostsModule. The adapter
already imports the port; listing both would be redundant. A
worker binary would import PostsModule (or a queue adapter)
without ever pulling in HTTP.
flowchart TB PM[PostsModule] --> PS[PostsService] PHM[PostsHttpModule] --> PM PHM --> PC[PostsController] PC -. "injects" .-> PS
Static and dynamic imports
Section titled “Static and dynamic imports”Framework and feature modules appear in imports two ways.
Dynamic — a call expression returning a DynamicModule, usually
with configuration at the import site:
DatabaseModule::for_root(None)
HttpModule::for_root(HttpConfig { host: "0.0.0.0".into(), port: 4000, ..Default::default()})Static — a bare type, no parameters. Feature adapters like
PostsHttpModule import this way:
PostsHttpModuleStatic modules dedupe automatically: if two branches of the import tree
both reach DatabaseModule, its providers are built once. Dynamic
modules do not dedupe — each call carries its own config.
Compose the root module
Section titled “Compose the root module”Once blog needs persistence, the inline
BlogService / BlogController pair moves into posts/ (renamed
PostsService / PostsController). The root module no longer lists
providers — it only composes imports:
#[module( imports = [ ConfigModule::for_root(), DatabaseModule::for_root(None), HttpModule::for_root(HttpConfig { port: 3005, ..Default::default() }), PostsHttpModule, ],)]pub struct BlogModule;HttpModule starts the transport. PostsHttpModule registers
PostsController on it. Framework first, feature adapter second — same
pattern for every HTTP feature.
PostsHttpModule pulls in PostsModule through its own imports list.
The root never lists the port when it only wants this transport — and
never lists a transport it does not serve.
Each import contributes its providers. HTTP mounts only what the import tree reaches. Linking a crate without importing its module keeps its providers inert — compiled in, not activated.
That is the usual shape of a root module: compose features and framework modules; let adapters own the handlers.
flowchart TB BM[BlogModule] BM --> CM[ConfigModule] BM --> DM[DatabaseModule] BM --> HM[HttpModule] BM --> PHM[PostsHttpModule] PHM --> PM[PostsModule] PM --> PS[PostsService] PHM --> PC[PostsController]
Solid arrows from BlogModule are direct imports. PostsModule is
reachable only through PostsHttpModule — that is the point.
Boot sequence
Section titled “Boot sequence”App::builder().build().await runs four phases, in order:
- Seeds — runtime values from
mainvia.seed(value). A seed wins over a module-built provider of the same type. - Collect — async factories run (DB pools, …). A dynamic module’s
collect()lives here. - Factories — awaited outputs land in the container as global infrastructure.
- Register —
Module::register()builds every provider in dependency order.
Sync-only apps skip collect/factories and call
App::new::<BlogModule>() directly.
Safe to import twice
Section titled “Safe to import twice”The #[module] macro registers a module the first time register()
runs, then short-circuits on later calls. A diamond import — two
branches that both reach the same module — still builds that module’s
providers once:
#[module(imports = [Common])]pub struct B;
#[module(imports = [Common])]pub struct C;
#[module(imports = [B, C])]pub struct A;flowchart TB A --> B A --> C B --> Common C --> Common
Common’s providers are registered once, not twice.
Going further
Section titled “Going further”- Fundamentals — minimal
BlogModule, boot-time access graph, breaking wiring on purpose. - Providers — injectable types, scopes, the access graph in detail.
- Configuration — env vars and dynamic modules.
- HTTP transport —
HttpModule::for_root(...)end to end.