Skip to main content
nestrs ships a synchronous, type-safe dependency injection container built on Rust’s TypeId system. When you call NestFactory::create::<AppModule>(), the framework traverses the module graph, builds a ProviderRegistry that maps each registered type to a factory, and injects dependencies by resolving Arc<T> fields from that registry. No runtime reflection, no string keys — every injection site is checked at compile time.

How the container works at runtime

ProviderRegistry is the heart of the DI system. It stores one entry per provider type, keyed by TypeId. When a type is requested, the registry calls the factory (the generated Injectable::construct), caches the result if the scope is Singleton, and returns an Arc<T>.
The Module trait’s build() method returns a (ProviderRegistry, Router) pair. NestFactory composes these pairs from the entire module graph into a single registry and a merged Axum router, then wraps the registry in an Arc that is injected as Axum State.

The three provider scopes

Set the scope with the #[injectable] attribute:

ModuleRef — dynamic resolution after build

ModuleRef is a thin handle to the root ProviderRegistry after the module graph is constructed. Use it when you need to resolve providers dynamically — for example, in plugins or conditional code — without injecting them as struct fields:
ModuleRef::get::<T>() follows the same scope rules as the container: singletons return the cached instance, transients produce a new one.

DiscoveryService

DiscoveryService exposes two introspection surfaces:
  • get_providers() — Vec<TypeId> of every registered provider
  • get_provider_type_names() — debug-friendly Vec<&'static str> type names
  • get_routes() — all HTTP routes from the global RouteRegistry (useful for OpenAPI generation and diagnostics)
nestrs discovery is TypeId- and route-list-oriented. Unlike NestJS, there is no reflection over arbitrary metadata attached to class decorators.

NestJS DI concept mapping

Overriding providers

Two surfaces exist for replacing a provider with a concrete instance, both backed by ProviderRegistry::override_provider::<T>(instance). Before controllers register — DynamicModuleBuilder. Overrides apply after the module graph’s providers are registered but before controllers are registered, so the generated handlers are wired against the overridden instances. This is how tests replace #[crud]’s hidden __PostCrudState provider with one backed by a real connection pool:
After the module graph is built — the application handle. NestApplication::registry() borrows the underlying Arc<ProviderRegistry> — the entry point tests use to reach override_provider when replacing a macro-generated #[injectable] state struct on an application they already hold. NestApplication::into_registry_and_router() yields the (registry, router) pair and from_registry_and_router() re-wraps it, so an application can be rebuilt around an overridden registry (the router is safe to reuse: controllers keep Clone Arcs of the state they were mounted with). An override preserves the provider’s declared scope (T::scope()): a request- or transient-scoped provider keeps its per-request / per-resolution semantics, and every resolution hands out the given instance — a request-scoped override therefore shares that one instance across requests. T must implement Injectable; the override entry keeps T’s lifecycle hooks.

Request-scoped providers

Request scope gives each HTTP request its own instance of a provider, isolated from other concurrent requests. This is useful for per-request caches, correlation IDs, or any state that must not leak between requests.

Enabling request scope

Call use_request_scope() on your NestApplication before listening:

Marking a provider as request-scoped

Extracting request-scoped providers in handlers

Use the RequestScoped<T> Axum extractor:
nestrs stores the request-scoped cache in a tokio::task_local! variable. Every request gets a fresh, empty cache; the first call to registry.get::<T>() for a Request-scoped type within that task constructs and caches the instance for the lifetime of that request.
Without a request scope active, resolving a Request-scoped provider behaves like any other unresolvable provider: registry.try_get::<T>() returns None, while registry.get::<T>() panics with a message naming the fixes. Always enable use_request_scope() on the application before using the RequestScoped extractor.

Background work: spawn_with_request_scope

Task-locals do not cross tokio::spawn, so a bare tokio::spawn inside a handler runs with no request scope. Use spawn_with_request_scope for background work that resolves Request-scoped providers:
  • Spawned inside a request, the child receives a snapshot of the request scope: it resolves the same request-scoped instances the request had at spawn time (including any in-flight transaction slot). Values constructed or inserted after the spawn stay private to whichever side created them.
  • Spawned outside any scope (scheduler, startup job), the child gets a fresh empty scope — request-scoped providers construct per spawned task and are isolated from every other task.
  • The ability / principal slots are deliberately not carried: row-level authz stays deny-closed in the spawned task unless you explicitly wrap the future with the ability helpers.

Circular provider dependencies

If two providers each depend on the other, nestrs detects the cycle during construction and panics:
Fixes:
  • Split the shared concern into a third type that both depend on.
  • Defer work to on_module_init so construct only wires Arcs without triggering the cycle.
  • Use register_use_factory with a closure that resolves one side lazily on first use.
There is no forwardRef for individual providers — cycles must be broken in code structure or initialization order.