Skip to content

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:

  1. One root module — handlers inline, one transport.
  2. Extract a feature — domain code moves to posts/ with its own prefix.
  3. Wire imports — static vs dynamic framework modules.
  4. 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"]

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:

apps/blog/src/module.rs
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
ListHoldsPurpose
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.

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 importsWhat activates
PostsModule onlyData layer — PostsService injectable, no HTTP routes
PostsHttpModuleHTTP 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

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:

PostsHttpModule

Static 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.

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:

apps/blog/src/module.rs
#[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.

App::builder().build().await runs four phases, in order:

  1. Seeds — runtime values from main via .seed(value). A seed wins over a module-built provider of the same type.
  2. Collect — async factories run (DB pools, …). A dynamic module’s collect() lives here.
  3. Factories — awaited outputs land in the container as global infrastructure.
  4. RegisterModule::register() builds every provider in dependency order.

Sync-only apps skip collect/factories and call App::new::<BlogModule>() directly.

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.

  • 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 transportHttpModule::for_root(...) end to end.