auto-di 0.5.0

Ergonomic async-aware automatic dependency injection for Rust
Documentation

auto-di

Async-aware automatic dependency injection for Rust. Providers register through inventory; constructor parameters form the dependency graph, and instances are initialized safely with Tokio OnceCell.

The public model is intentionally small:

  • #[singleton] registers a constructor-managed dependency.
  • #[provider] registers a factory function or factory method.

Installation

[dependencies]
auto-di = "0.5.0"

Quick start

use std::sync::Arc;
use auto_di::{application, resolve, singleton};

struct Database;

#[singleton]
async fn database() -> Database {
    Database
}

struct UserService {
    database: Arc<Database>,
}

#[singleton]
impl UserService {
    fn new(database: Arc<Database>) -> Self {
        Self { database }
    }
}

#[application]
async fn main() -> Result<(), auto_di::DiError> {
    let service = resolve::<UserService>().await?;
    Ok(())
}

No registration list or manual container wiring is required.

Field injection

#[injectable] generates singleton construction directly from struct fields. Fields are automatically resolved by type; use #[inject(...)] to override a field with a literal or transformation closure:

#[injectable]
struct Facade {
    database: Arc<Database>,

    #[inject(3)]
    retries: usize,

    #[inject(|client: Arc<HttpClient>| ClientHandle(client))]
    client: ClientHandle,
}

Arc<T>, optional, collection, trait, Provider<T>, and Lazy<T> fields are injected without cloning. An owned T field or owned closure input requires T: Clone; prefer Arc<T> for singleton identity and zero-copy injection.

Injected functions and methods

#[injected] automatically removes DI-shaped parameters from the caller signature and resolves them. Arc<T>, Option<Arc<T>>, Vec<Arc<T>>, Provider<T>, and Lazy<T> are detected without an attribute:

#[injected]
fn calculate(
    config: Arc<AppConfig>,
    #[inject(2)] offset: i32,
    multiplier: i32,
) -> i32 {
    (config.base + offset) * multiplier
}

let result = calculate(3).await?;

Every #[injected] function also gets a _with variant containing the full original signature. Use the short name for automatic injection, or _with when you want to pass every dependency manually:

calculate(3).await?;                         // config auto-resolved
calculate_with(config, 2, 3);                // everything supplied manually

Rust does not support function overloading, so one function name cannot accept both arities. The generated _with variant keeps both paths explicit and type-safe.

Use #[inject(expression)] for literal/default or transformation overrides. Use #[argument] when an Arc<T>-shaped value must remain caller-supplied.

The same syntax works on methods with a receiver. Generated wrappers are async and return Result<OriginalReturn, DiError>, because dependency resolution can fail. Detection is based on the parameter's Rust type shape rather than the runtime registry, keeping expansion deterministic across crates.

Providers

A provider can be a standalone sync, async, or fallible function:

#[provider]
fn config() -> AppConfig {
    AppConfig::default()
}

#[provider]
async fn database(config: Arc<AppConfig>) -> Result<Database, DatabaseError> {
    Database::connect(&config.database_url).await
}

A singleton can expose additional providers directly:

#[singleton]
impl Application {
    fn new(database: Arc<Database>) -> Self {
        Self { database }
    }

    #[provider]
    fn cache(&self) -> Cache {
        Cache::new()
    }
}

If a provider creates a dependency required by its owning singleton, make it an associated function without &self. Otherwise the graph would be circular:

#[singleton]
impl Application {
    fn new(config: Arc<AppConfig>) -> Self {
        Self { config }
    }

    #[provider]
    fn config() -> AppConfig {
        AppConfig::default()
    }
}

Direct calls such as self.cache() bypass the container and are rejected by the macro. Inject the provided type as a parameter instead.

Injection forms

Constructors and providers support:

Arc<T>                   // required dependency
Option<Arc<T>>           // optional dependency
Vec<Arc<T>>              // all implementations
Arc<dyn Trait>           // selected trait implementation
Vec<Arc<dyn Trait>>      // all trait implementations
Provider<T>              // resolve later
Lazy<T>                  // resolve once on first access

Provider<T> and Lazy<T> preserve the originating container, active profile, and request context.

Multiple implementations

trait Payments: Send + Sync {}

#[singleton(name = "stripe", primary)]
fn stripe() -> Arc<dyn Payments> {
    Arc::new(Stripe)
}

#[singleton(name = "razorpay")]
fn razorpay() -> Arc<dyn Payments> {
    Arc::new(Razorpay)
}

#[singleton]
impl Checkout {
    fn new(
        default: Arc<dyn Payments>,
        #[qualifier("razorpay")] indian: Arc<dyn Payments>,
    ) -> Self {
        Self { default, indian }
    }
}

Duplicate names, multiple primary implementations, and unresolved ambiguity are reported as DI errors.

Scopes

The default scope is singleton:

#[singleton]
fn shared_client() -> Client { Client::new() }

#[singleton(scope = "prototype")]
fn job() -> Job { Job::new() }

#[singleton(scope = "request")]
fn request_metadata() -> RequestMetadata { RequestMetadata::new() }

Resolve request-scoped dependencies through a request context:

let request = auto_di::global_container()?.request_context();
let metadata = request.resolve::<RequestMetadata>().await?;

A singleton is not allowed to capture a request-scoped dependency.

Constructor options

#[singleton] and #[provider] accept:

  • name = "..."
  • primary
  • scope = "singleton" | "prototype" | "request"
  • eager
  • blocking for expensive synchronous work
  • profile = "development"
  • condition = "ENV_KEY" or condition = "ENV_KEY=value"
  • post_construct = "async_method"
  • pre_destroy = "async_method"

Lifecycle methods may return () or Result<(), E>.

Profiles and conditions

Active profiles come from comma-separated APP_PROFILES:

#[singleton(profile = "development")]
fn development_database() -> Database { /* ... */ }

#[singleton(condition = "CACHE_ENABLED=true")]
fn cache() -> Cache { /* ... */ }

A local container can select profiles explicitly:

let container = auto_di::Container::with_profiles(["test"])?;

Configuration properties

#[configuration_properties("database")]
struct DatabaseProperties {
    url: String,       // DATABASE_URL
    pool_size: usize,  // DATABASE_POOL_SIZE
}

Validation and lifecycle

Container::validate() initializes and validates every active singleton. It surfaces missing dependencies, ambiguous providers, circular graphs, and scope violations during startup. #[application] runs validation automatically and runs shutdown hooks in dependency-safe topological order.

The resolver also detects cycles formed concurrently by different Tokio tasks, so they return an error instead of waiting on each other's OnceCell forever.

Renaming the dependency

Cargo aliases are supported:

[dependencies]
di = { package = "auto-di", version = "0.5.0" }
#[di::singleton]
fn dependency() -> Dependency { Dependency }

Example

Run the complete example from this repository:

cargo run -p auto-di --example showcase