rust-dix is the core crate of the rust-dix framework — a Rust dependency injection
container inspired by Microsoft.Extensions.DependencyInjection (MEDI).
[]
= "0.10"
MSRV: Rust 1.95. Both crates are
#![forbid(unsafe_code)]; CI runs a test matrix plus RustSeccargo audit. SeeSECURITY.mdat the repository root.
When should you use this?
1. You want to decouple service creation from usage
Instead of MyService::new(dep1, dep2, dep3) scattered across your codebase,
you declare once how services are constructed and let the container wire them
up automatically.
// Without DI: every call site must know how to build everything
let svc = new;
// With DI: declare once, resolve anywhere.
// `MyService` is annotated with `#[rust_dix::inject(transient)]`, so it is
// registered automatically (its definition is omitted here).
let provider = from_injected
.singleton
.singleton
.build
.unwrap;
let svc: = provider.get.unwrap;
2. You need to swap implementations for testing
Change one registration line — no need to touch the code that consumes the service.
3. You have objects with different lifetimes
Some services should be shared globally (database pool), some per-request (HTTP context), some created fresh each time (value objects). rust-dix gives you three lifetimes to model this naturally.
| Lifetime | Behavior | Typical use |
|---|---|---|
| Singleton | Created once, shared everywhere | Database pool, config, event bus |
| Scoped | Created once per scope, shared within scope | HTTP request context, unit of work, transaction |
| Transient | Created every time you resolve it | Value objects, DTOs, lightweight stateless services |
let provider = new
.singleton // one pool for the app
.scoped // one per request
.transient // new each time
.build
.unwrap;
4. You want multiple instances of the same type
Keyed services let you register several implementations of the same trait,
each distinguished by a key — a string or a typed ServiceKey value. All three
lifetimes are supported:
// Keyed with different lifetimes
let provider = new
// Singleton keyed - shared globally
.
// Scoped keyed - shared within a scope
.
// Transient keyed - new instance each time
.
// AnyKey fallback: used when no exact key is registered (cached per (registration, requested key))
.
.build
.unwrap;
let fast: = provider.get_keyed.unwrap;
let session: = provider.scope.get_keyed.unwrap;
let fresh1: = provider.get_keyed.unwrap;
let fresh2: = provider.get_keyed.unwrap; // different instance
// Typed keys work too: provider.get_keyed(ServiceKey::new(StrategyKey::Fast))
5. You are building a plugin / modular system
- Layered containers: child-first, root-fallback resolution for plugin isolation. The plugin sees its own services first; host services are visible as fallback.
- Named services: register services by string name — critical for cdylib
plugins where Rust's
TypeIddiffers across compilation units. IProvider: pass a unified DI interface (resolution + named registry) to external modules that don't depend on rust-dix directly.
Quick Reference · API
Registration (ServiceCollection)
| Method | Lifetime | Use when |
|---|---|---|
.from_injected() |
mixed | Collect all #[rust_dix::inject] annotations (see below) |
.singleton(f) |
Singleton | You need exactly one instance shared globally |
.scoped(f) |
Scoped | You need one instance per scope (request/transaction) |
.transient(f) |
Transient | You need a new instance every time |
.keyed_singleton(k, f) |
Singleton | Multiple keyed instances (strategy pattern); k: impl Into<ServiceKey> |
.keyed_scoped(k, f) |
Scoped | Keyed instances scoped per request |
.keyed_transient(k, f) |
Transient | Keyed instances created fresh each time |
.keyed_any_singleton(f) |
Singleton | AnyKey fallback (MEDI parity); f: Fn(&dyn IServiceResolver, &ServiceKey) -> Arc<T>, cached per (registration, requested key) |
.keyed_any_scoped(f) |
Scoped | AnyKey fallback, one instance per scope per (registration, requested key) |
.keyed_any_transient(f) |
Transient | AnyKey fallback, fresh per resolution |
.instance(arc) |
Singleton | You already have a built Arc<T> |
.try_add(f) |
Singleton | Only register if not already present (MEDI TryAdd) |
.try_add_scoped(f) |
Scoped | Only register if not already present |
.try_add_transient(f) |
Transient | Only register if not already present |
.singleton_value(v) |
Singleton | Register a plain value (wraps in Arc) |
.add(lt, f) |
any | Specify lifetime explicitly |
.build() |
— | Build the provider with default (strict) scope validation |
.build_with(options) |
— | Build with explicit ServiceProviderOptions (ServiceProviderOptions::new() / Default = strict; with_validate_scopes(bool); lenient() restores 0.7.x root-scope behavior) |
.build_async() / .build_async_with(options) |
— | Async build; required when any async_* registration exists |
Typed keys (ServiceKey) and AnyKey fallback
Keyed APIs accept impl Into<ServiceKey>: strings (&str / String) keep working, and
ServiceKey::new(value) supports enum/integer/custom keys. Key equality includes the
value's type — ServiceKey::new(1u32) != ServiceKey::from("1"). ServiceKey::any_key() /
KeyedService::any_key() is the fallback sentinel and ServiceKey::is_any() detects it;
ServiceKey::as_str() returns Some for string-like keys (String / &'static str,
including ServiceKey::new("literal")) and None for enum/integer/custom typed keys and
the AnyKey sentinel. The default typed-key resolver methods fall back to the legacy string
path for Some, so a typed &'static str key is treated as a String key there (not
equal in fully typed containers) — typed consumers should use the *_key methods.
String-key footgun: ServiceKey::new("x") is a &str-typed key and is not equal to
the String key stored by keyed_singleton("x", …); use "x".into() /
ServiceKey::from("x") for string keys and ServiceKey::new only for typed
(&'static str / enum / integer) keys.
use *;
use Arc;
let provider = new
.
.
.build
.unwrap;
let read: = provider.get_keyed.unwrap;
AnyKey semantics (full details:
current (0.10) semantics):
- exact-key registrations take precedence; the fallback is used only when no exact key exists;
- singleton/scoped fallback instances are cached per (registration/type, requested key)
(different
AnyKeyregistrations stay independent; scoped: per scope per (registration, key)); a singleton consumed during build validation is prefilled into that cache and reused at runtime (single construction, identity preserved); get_all_keyedexcludes fallbacks; theany_key()sentinel is invalid for single-service resolution (is_registered_keyed(any_key()) == false); registrations are sync-only and not eagerly initialized/validated at build;get_keyed_owned/try_get_keyed_ownedfor a scoped/transient fallback is allowed at the strict root (caller-owned exception); nested resolution of a different requested key is legal; the factory gets&ServiceKey(Debug/Display/Eq/Hash, no typed downcast accessor).
The factory closure f receives &dyn IServiceResolver. Generic get::<T>()
requires Self: Sized, so it cannot be called on that trait object: let the
inject macros generate the constructor (internal __rdi_construct_*,
#[doc(hidden)]), or resolve with get_any + downcast:
new
.singleton
// `Repo` is `#[derive(Inject)]` with `#[inject] pool: Arc<Pool>`; the
// generated constructor resolves its fields during registration.
.transient
.build
.unwrap;
Type-erased IServiceResolver helpers used by the macros and by custom factories
(get_any / get_keyed_any panic on unresolvable, the *_result variants return Err):
| Method | Use |
|---|---|
get_any(type_name) / get_by_type_id(TypeId) |
Type-erased single lookup (String / TypeId path) |
get_keyed_any(type_name, variant) / get_keyed_by_type_id(TypeId, key) |
Type-erased keyed lookup |
get_all_any_result(type_name) / get_all_any(type_name) |
Type-erased get_all (*_result returns Err for uninitialized async) |
get_keyed_any_key(type_name, &ServiceKey) / try_get_keyed_any_result_key(...) |
Typed-keyed lookup (0.10); default falls back to string path only for ServiceKey::as_str() == Some |
get_keyed_owned_any_key(type_name, &ServiceKey) / try_get_keyed_owned_any_key(...) |
Typed-keyed owned lookup (0.10); same string-only default |
try_get_any_result(type_name) / try_get_keyed_any_result(type_name, variant) |
Infallible probing (no panic) |
The four
*_keymethods have default implementations that only support string-like keys (ServiceKey::as_str());ServiceProvider/Scope/ServiceProviderWrapperand the build-time validation resolver override them with full typed-key support (AnyKey fallback, owned, captive checks). CustomIServiceResolverimplementations must override them to support non-string keys. Extension contract: extension points.
#[rust_dix::inject] — Attribute-based auto-registration (recommended)
The preferred way to register services is to annotate structs (or trait impl blocks) directly:
use *;
// Annotate a struct → registered as its concrete type.
// Lifetime defaults to `singleton` when omitted.
// Annotate a trait impl → registered as `dyn Trait`.
// The trait type is auto-detected from `impl Trait for Type`,
// so no `as = dyn Trait` boilerplate is needed.
// For trait-oriented services, use #[derive(Inject)] on the struct (generates
// a constructor but does NOT register) and #[inject] on the impl block only.
// Putting #[inject] on BOTH struct and impl causes double registration.
// on the impl: trait registration (singleton by default)
// Then build from all annotations in one call:
let provider = from_injected.build.unwrap;
The #[rust_dix::inject] attribute automatically generates constructor code
(like #[derive(Inject)]) and registers the service via inventory
(on Linux, macOS, and Windows).
Supports both named-field structs and unit structs (zero fields):
// Unit struct — zero fields, works out of the box
;
Two placement sites, one consistent syntax:
| Placement | What it registers | Reuses constructor? |
|---|---|---|
#[inject] on struct |
Concrete type T |
Generates its own |
#[inject] on impl Trait for T |
dyn Trait |
Reuses the generated constructor (internal __rdi_construct_T, #[doc(hidden)]) |
#[derive(Inject)] on struct |
(nothing — constructor only) | — |
Choose one site, don't double-register: #[inject] generally goes on
either the struct or the impl block, not both. Putting #[inject] on both
registers the same struct twice (concrete type + dyn Trait), which is rarely
intended — consumers can then bypass the trait via get::<ConcreteType>().
For trait-oriented services (handlers, services, repositories), prefer
#[derive(Inject)] on the struct (constructor only, no registration) plus
#[inject] on the impl block. This replaces the old as = dyn Trait /
as = [dyn A, dyn B] syntax.
Resolution (ServiceProvider / Scope)
| Method | Returns | Behavior |
|---|---|---|
.get::<T>() |
Result<Arc<T>, RdiError> |
Returns Err if not registered; last registration wins |
.get_optional::<T>() |
Option<Arc<T>> |
Returns None if not registered |
.get_keyed::<T>(key) |
Result<Arc<T>, RdiError> |
key: impl Into<ServiceKey>; exact key wins over an AnyKey fallback; any_key() is not a valid single-service key |
.try_get_keyed::<T>(key) |
Option<Arc<T>> |
Returns None if key not found/unresolvable |
.get_all::<T>() |
Vec<Arc<T>> |
All non-keyed registrations, in registration order (keyed excluded) |
.get_all_keyed::<T>() |
Vec<Arc<T>> |
All explicitly keyed registrations of T, in registration order (AnyKey fallbacks excluded) |
.get_named::<T>(name) |
Option<Arc<T>> |
Named resolution (cross-DLL), returns None if missing |
.get_named_any::<T>(name) |
Option<Arc<T>> |
Named, returns None if missing |
.get_owned::<T>() |
Result<T, RdiError> |
Owned instance; Singleton returns Err; Scoped is allowed from the root (caller-owned, never cached — its Scoped dependencies still obey scope validation) |
.try_get_owned::<T>() |
Option<T> |
None if unregistered or Singleton |
.get_keyed_owned::<T>(key) |
Result<T, RdiError> |
Owned keyed instance; Singleton returns OwnedNotSupported |
.try_get_keyed_owned::<T>(key) |
Option<T> |
None if unregistered / Singleton (infallible probing) |
.is_registered::<T>() |
bool |
Whether T has a non-keyed registration (does not instantiate) |
.is_registered_keyed::<T>(key) |
bool |
Whether T has an exact keyed registration or an AnyKey fallback (any_key() → false) |
.scope() |
Scope |
New scope for scoped services |
.async_context() |
Option<AsyncFactoryContext> |
Capture the current async chain for detached tasks (cross-task cycle detection) |
.dispose() |
() |
Idempotent; releases instances in reverse creation order, later resolution returns Err(ProviderDisposed) (0.8.0 起,0.10.x 同样适用) |
0.10.x semantics (summary): last registration wins;
get_allis unkeyed-only (get_all_keyedlists explicit keys, excludingAnyKeyfallbacks); keys may be typed; scope validation is strict by default (root Scoped →ScopedFromRootProvider, withget_owned/keyed owned as the caller-owned exception). Full semantics: docs/rust-dix/09-reference/semantics.md.
Enumeration and async:
get_all/get_all_keyed/#[inject] Vec<Arc<T>>include already-initialized async singletons; uninitialized async services yieldAsyncServiceRequiresGetAsync(enumeration panics/Erras applicable). Async scoped/transient services should be resolved item-by-item withget_async; async scoped enumerated at the root is omitted by scope validation (panics only inside aScope).
Diagnostics
| Error | When |
|---|---|
RdiError::SyncSingletonDependsOnAsyncService { singleton, async_service } |
a sync singleton depends on an async service (build time); make the dependent async or resolve lazily |
RdiError::AsyncServiceRequiresGetAsync { service } |
synchronous get of an async service (get_optional returns None), or synchronous enumeration of an uninitialized async service; use get_async |
RdiError::AsyncServiceRequiresBuildAsync { service } |
build() used on a collection containing async_* registrations; use build_async() |
RdiError::is_circular() |
heuristic helper: true for a direct CircularDependency or a FactoryPanic payload containing CircularDependency (custom payloads may false-positive; prefer matching the typed variant) |
T can be a concrete type or dyn Trait:
let svc: = provider.get.unwrap;
let plugin: = provider.get.unwrap;
let all: = provider.get_all;
Container injection (ServiceProvider / ScopeFactory)
The container is itself resolvable as a built-in service (no registration
required) through every resolver path — provider.get(), #[inject] fields and
build-time validation alike:
| Field | Notes |
|---|---|
#[inject(provider)] provider: Arc<ServiceProvider> |
On-demand resolution; also available in eagerly built singletons. Warning: a singleton holding this creates a strong container → singleton → container cycle; prefer ScopeFactory |
#[inject] provider: Arc<ServiceProvider> |
Same built-in, plain form (MEDI IServiceProvider parity); same strong-reference caveat |
#[inject] scope_factory: Arc<dyn ScopeFactory> |
Recommended for singleton workers: one scope per task. Weak-backed singleton, so holding it does not create an Arc cycle |
#[inject(key = <expr>)] field: Arc<T> |
Keyed injection; expr: impl Into<ServiceKey> (string literal, ServiceKey::new(...), or downstream key via From) |
#[inject(owned)] field: T |
Owned (callback-owned) instance; T must be Transient/Scoped (Singleton → error) |
#[inject(owned, key = <expr>)] field: T |
Owned + keyed injection; same lifetime rules as #[inject(owned)] |
Explicit singleton::<dyn ScopeFactory>(...) registrations still take precedence
over the built-in.
Resolving services through an injected provider inside a singleton constructor is rejected at build time with a typed
FactoryPanic: it would bypass cycle/captive validation and make the result order-dependent. Store the handle and resolve lazily in methods.
Owned service resolution (&mut self without interior mutability)
get::<T>() returns Arc<T>, giving you only &T — you cannot call
&mut self methods. For services that need mutable access (e.g. a
DbContext with save_changes(&mut self)), use get_owned::<T>() to
obtain an owned T instead of resorting to RwLock + unsafe:
let provider = new
.transient
.build
.unwrap;
let mut ctx: DbContext = provider.get_owned.unwrap;
ctx.add; // ✓ &mut self — no RwLock, no unsafe
assert_eq!;
| Lifetime | get_owned behavior |
|---|---|
| Transient | New instance each call ✓ |
| Scoped | Bypasses cache, new instance each call ✓ (callable from the root too: the owned instance is not subject to root scope validation, but its Scoped dependencies are) |
| Singleton | Err(OwnedNotSupported) (shared instance cannot be owned); use try_get_owned for None |
With #[derive(Inject)]: mark bare T fields with #[inject(owned)]
and Arc<T> fields with #[inject]. Option<T> → try_get_owned,
Option<Arc<T>> → try_get:
// ...
let mut h: Handler = provider.get_owned.unwrap;
h.ctx.add; // ✓ owned field is mutable
Limitations:
- Singleton cannot be owned —
get_ownedreturnsErr,try_get_ownedreturnsNone. - Scoped
get_ownedbypasses the scope cache (each call is fresh). This is intentional: owned&mut selfis incompatible with scope-shared semantics. Owned resolution is the exception to strict scope validation:get_owned/try_get_ownedmay resolve a Scoped service from the root (the instance is caller-owned, never cached or tracked) — but Scoped dependencies resolved inside its constructor still returnErr(ScopedFromRootProvider)at the root. - Trait bound remains
T: Send + Sync + 'static(reuses the existing factory, no separate owned-factory path).
Async support (build_async)
For services that need async initialization (DB connections, network I/O, config
loading), use build_async() and the async_* registration methods.
Registration
| Method | Lifetime | Use when |
|---|---|---|
.async_singleton(f) |
Singleton | Async init, shared globally (DB pool, Redis) |
.async_scoped(f) |
Scoped | Async init, per scope (request-scoped resources) |
.async_transient(f) |
Transient | Async init, fresh each time |
.async_keyed_singleton(k, f) |
Singleton keyed | Async init, keyed instance (k: impl Into<ServiceKey>) |
.async_keyed_scoped(k, f) |
Scoped keyed | Async init, per-scope keyed instance |
.async_keyed_transient(k, f) |
Transient keyed | Async init, fresh keyed instance |
The async factory receives Arc<ServiceProvider> and returns a pinned future:
let provider = new
.async_singleton
.
.build_async
.await
.unwrap;
Resolution
Use get_async() / get_keyed_async() when resolving async-registered services
(both accept string or typed ServiceKey keys and return impl Future; .await usage is
unchanged):
let pool: = provider.get_async.await.unwrap;
let gateway: = provider.get_keyed_async.await.unwrap;
For sync-registered services you can use either get() or get_async(): get_async()
falls back to the synchronous resolution path for services registered with the sync
scoped / transient builders. Async registration (async_*) is only needed
for services that require asynchronous initialization. Synchronous get of an
async service reports RdiError::AsyncServiceRequiresGetAsync; AnyKey fallbacks are
sync-only and are also resolved through the synchronous path.
Async singleton initialization is dependency-first and registration-order independent:
a factory may await any async singleton, and build_async() recursively
initializes dependencies first (deterministic dependency-first topological order, eager and
fail-fast). A sync singleton depending on an async service is rejected at build time with
RdiError::SyncSingletonDependsOnAsyncService — make the dependent async or resolve
lazily.
Cross-task cycle detection (AsyncFactoryContext)
Async cycle detection is chain-based: each resolution future captures the current chain
when constructed, so self/cross recursion returns RdiError::CircularDependency. For
detached tasks, capture sp.async_context() inside the factory and resolve through the
cloned context; without the context a fully detached task may still dead-lock:
let provider = new
.async_singleton
.build_async
.await
.unwrap;
AsyncFactoryContext exposes get_async, get_keyed_async and provider(), is Clone,
and can also be obtained from a Scope (scope.async_context()).
Mixing sync and async
You can mix sync and async registrations in the same collection. Sync singletons
are validated and initialized alongside async ones during build_async():
let provider = new
.async_singleton
.singleton // sync OK
// `UserRepository` is `#[derive(Inject)]`; its constructor resolves DbPool.
.scoped // sync OK
.build_async
.await
.unwrap;
Key rule: Once you use any async_* registration, call build_async()
instead of build(). Mixing is supported — sync singletons are validated and
initialized alongside async ones.
Per-request scope with async
let provider = new
.async_singleton
.scoped
.build_async.await.unwrap;
// Each request gets its own scope
Flexible Application Patterns
🔹 Three-layered architecture (Controller → Service → Repository)
// `UserRepo` / `UserService` / `UserController` are `#[derive(Inject)]`; the
// generated constructors wire the dependencies for each layer.
let provider = new
.singleton
.transient
.transient
.transient
.build
.unwrap;
🔹 Strategy pattern with keyed services
let provider = new
.
.
.
.build
.unwrap;
🔹 Scoped per-request (web server)
🔹 Plugin isolation with ServiceProviderWrapper
let host_provider = new
.singleton
.build.unwrap;
let plugin_provider = new
.singleton
.build.unwrap;
let wrapper = new;
// PluginService resolved from plugin container
// HostService falls back to host container
🔹 Cross-DLL plugin with named services
// Host process
let provider = new
.singleton
.build.unwrap;
provider.register_named;
// Plugin loaded from cdylib (separate compilation unit)
let bus = host.;
🔹 External system integration via IProvider
let provider: = provider as ;
// Pass to third-party code or FFI boundary
Architecture
┌──────────────────────────────────────────────┐
│ ServiceCollection │
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │singleton │ │ scoped │ │ transient │ │
│ │.keyed_singleton│ │.keyed_*()│ │ .instance() │ │
│ └──────────┘ └──────────┘ └──────────────┘ │
│ │ .build() │
│ ▼ │
│ ServiceProvider │
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ .get() │ │.get_keyed│ │ .scope() │ │
│ │ .get_all │ │.get_named│ │ │ │
│ └──────────┘ └──────────┘ └──────┬───────┘ │
│ │ │
└────────────────────────────────────┼─────────┘
│
▼
┌──────────┐
│ Scope │
│ .get() │
│ scoped │
│ cached │
└──────────┘
Relationship with rust-dix-macros
rust-dix works with or without rust-dix-macros. The macros
provide three compile-time conveniences:
#[rust_dix::inject(...)](recommended) — Attribute macro that combines constructor generation + auto-registration. One annotation on a struct (or trait impl) is all you need —ServiceCollection::from_injected()collects everything.#[derive(Inject)]— Generate the factory function automatically from struct fields. Used internally by#[rust_dix::inject].#[rust_dix::module]— Collectrust_dix::register!()declarations at compile time and generate a complete provider builder. Useful for external types, conditional compilation, and centralized management.
See macros/README.md for details.
License
MIT.