navree

Providers and dependency injection

The type-keyed container: #[injectable] services, singleton, request and transient scopes, lazy handles, multi-bindings and overrides for tests.

On this page

Introduction

A provider is anything the container can hand out: a service, a repository, a configuration section, a client for another API. Modules register providers, and other providers ask for them by type. Navree builds each provider when it's needed (singletons when the app boots) and passes it its dependencies: this is constructor injection, as in NestJS or Laravel's service container.

The container is keyed by type. Asking for Arc<DealService> gives you the DealService provider; asking for Arc<dyn Clock> gives you whatever the app registered as its dyn Clock. A key can also carry a name, so two providers of the same type can coexist. There are no global statics: each booted app has its own container.

Defining providers

Put #[injectable] on a struct, and every field is filled from the container:

src/modules/deals/service.rs

Each field type says how it's filled:

FieldFilled withA dependency of the provider?
Arc<T> (also Arc<dyn Trait>)The provider of TYes
#[inject(name = "x")] Arc<T>The provider of T named xYes
Lazy<T>A handle that resolves T on its first getNo (see lazy handles)
#[inject(default)] TDefault::default()No

#[injectable] implements the Injectable trait. It lists the dependencies in field order, so the boot-time graph check knows them. Unit structs (pub struct DealRepository;) and generic structs work too. Anything else is a compile error that points at the field: a plain field without #[inject(default)], a tuple struct, an enum, an unknown #[inject] argument, or name combined with default or used on a Lazy field.

Then register the type in a module, with providers = [DealService] in #[module] or m.provider::<DealService>().

Scopes

A provider is a singleton unless you choose another scope:

Rust
ScopeBuiltShared
"singleton" (default)Once per app, eagerly at boot, in module dependency orderBy everyone, as the same Arc<T>
"request"On first use within a request scope: one per HTTP request, or one you create for a job or a commandWithin that scope
"transient"On every resolutionNever

Request-scoped providers have rules of their own, below.

Implementing Injectable by hand

#[injectable] covers structs whose fields all come from the container. When construction needs logic, implement Injectable yourself, or register a factory:

Rust

scope and dependencies have defaults (a singleton with no declared dependencies). Declare what construct resolves: the graph check, the cycle detection and the scope rules only know about declared dependencies.

Registering providers

Besides Injectable types, a module can register ready values and factories. These go in the module's register function (see modules):

Rust
MethodRegisters
m.provider::<T>()T: Injectable, under its type
m.provider_named::<T>("x")T: Injectable, under the name x
m.value(v)A singleton that already exists
m.value_arc(arc)The same, from an Arc, which is how you register a trait object: m.value_arc::<dyn Clock>(Arc::new(SystemClock))
m.factory(Factory::<T>::new(..))A provider built by an async closure
m.multi(Factory::<T>::new(..))One more multi-binding of T

Registering the same key twice in one module fails the boot. Two modules may register the same key; a module that sees both (through two imports) gets an "ambiguous provider" error and has to pick one.

Factories

A Factory<T> builds an Arc<T> with an async closure that receives a Resolver. Use it for trait objects, for values that need async setup, and for named providers:

Rust

The closure returns Result<Arc<T>, BoxError>, so ? works on any error; a failure becomes a failed to construct .. error that names the provider. .deps(..) declares what the closure resolves, .scope(..) sets the scope (singleton by default) and .named(..) registers it under a name. The names are Cow<'static, str>, so they can come from configuration.

Trait objects

Depend on traits where you want to swap implementations, for tests or per environment. Register the implementation as dyn Trait with value_arc or a Factory, export dyn Trait, and inject Arc<dyn Trait>. The framework does this for its cross-module contracts: navree-audit registers a dyn AuditSink, navree-auth a dyn Authenticator, navree-access a dyn RouteAuthorizer. The CRM registers the app's dyn Clock, which every CRM service injects for "now", so tests can fix the time.

Named providers

A name lets two providers of one type coexist: Arc<ApiClient> and Arc<ApiClient> named "billing" are different keys. Inject a named provider with #[inject(name = "billing")], or resolve it with get_named::<ApiClient>("billing").

Resolving providers

Prefer constructor injection: declare what a provider needs as fields, and let the container fill them. Controllers are providers too, so a controller's fields are injected the same way and its handlers use them through self.

Inside a factory or a hand-written construct, the Resolver resolves with the visibility of the module that owns the provider:

Resolver methodReturns
get::<T>()The provider of T
get_named::<T>("x")The provider of T named x
get_all::<T>()Every multi-binding of T
lazy::<T>()A Lazy<T> handle
app()The AppContext

The service locator

Outside providers (lifecycle hooks, console commands, layers, extractors, tests), resolve through the AppContext:

Rust

get, get_named, try_get and get_all on AppContext ignore module visibility: they find the provider wherever it's registered (preferring an exported one when several modules register it). try_get returns None when no provider exists, and logs and returns None when one exists but fails to build. The framework uses try_get for optional collaborators, such as the audit sink.

Request-scoped providers

A request-scoped provider lives for one request scope. In an HTTP handler, ask for it with the Scoped<T> extractor:

Rust

The request pipeline keeps one RequestScope per request. Every Scoped<UserCache> in that request, and every request-scoped provider that depends on UserCache, gets the same instance; the next request gets a new one. Scoped<T> resolves with the visibility of the module that registered the route. A provider that module can't see, or one that fails to build, is a 500 error. Scoped<T> works for singletons and transient providers too.

Outside HTTP (a queue job, a console command), make the scope yourself:

Rust

Two rules keep request state from leaking:

  • A singleton can't depend on a request-scoped provider, directly or through transient providers. The boot fails:

    Text

    The check also runs while resolving, so an undeclared dependency hidden in a factory is caught too.

  • A request-scoped provider needs a scope. Resolving one through AppContext::get fails with request-scoped provider ... was resolved outside a request scope; resolve it through AppContext::request_resolver.

Request-scoped and transient providers are never built at boot.

Lazy handles and cycles

A Lazy<T> field holds a handle instead of the provider. Its first get().await resolves the provider (with the visibility of the module that created the handle) and caches it:

Rust

A lazy handle isn't a dependency edge, which makes it the way out of a provider cycle: when A needs B and B needs A, make one of the two fields a Lazy. Without it the boot fails with provider dependency cycle: app::A -> app::B -> app::A. Resolve lazy handles after boot, in methods, never inside a constructor. A handle holds the app weakly, so it doesn't keep a dropped app alive. Lazy handles resolve unnamed providers only; there is no named lazy handle.

Multi-bindings

Most keys have exactly one provider. A multi-binding is a key that collects one provider from every module that adds one, like a plugin list. Register with m.multi(..):

Rust

and collect with get_all:

Rust

Multi-bindings are app-wide: get_all returns every one, in module dependency order, whatever the visibility. get::<T>() never sees them. The framework collects readiness checks (dyn HealthCheck), seeders, authentication strategies and change observers this way.

Contributions

A contribution is a plain value a module hands to the app, not a provider: routes, migrations, job registrations, global layers and error mappers are all contributions. A module contributes with m.contribute(value), and anyone holding the AppContext collects every value of a type:

Rust

Each Contributed<C> carries the value (an Arc<C>) and the contributing module (id, name, global), in module dependency order. Unlike multi-bindings, nothing is constructed: the values exist as soon as the graph does.

Overriding providers in tests

override_value replaces the provider of a type wherever it's registered; the original factory never runs. If no module registers the type, the value is provided globally. Fix the time in a test:

Rust

Navree::app().override_value(..) does the same without the test kit. Overrides replace unnamed providers only: there is no override for a named provider or a multi-binding. Several modules ship fakes meant for this, such as HttpClient::fake() and the queue fake; see fakes.

Known limits

  • A singleton that stores an AppContext (from r.app()) creates a reference cycle, so the app is never freed. That's harmless in a long-running server, but it leaks in tests that boot many apps. Store ctx.downgrade() (a WeakAppContext) or a Lazy<T> instead.
  • The graph check sees declared dependencies only. A factory that resolves something it didn't declare in .deps(..) is checked when it runs, not before.

Popular pages

  • Installation
  • Your first app
  • Routing and controllers
  • Validation
  • Authentication
  • Benchmarks