Skip to content
DesidersPublic

About

An ergonomic Rust IoC container

Topics

Resources

Stars

23 stars

Watchers

1 watching

Forks

Latest commit

 

History

383 Commits

Folders and files

Repository files navigation

froodi

Crates.io Docs.rs License Telegram

Froodi is a lightweight, ergonomic Inversion of Control (IoC) container for Rust that helps manage dependencies with clear scoping and lifecycle management in a simple manner.

📚 Documentation

Purpose

froodi is for applications where object wiring has become repetitive enough that you want a container, but you still want lifetimes to stay explicit.

It focuses on a small set of DI problems:

  • how to register factories
  • how to express object lifetime using scopes
  • how to reuse scoped dependencies safely
  • how to create transient values when you need a fresh one
  • how to clean up resolved dependencies with finalizers
  • how to plug the container into framework request handling

Key features

  • Scopes. Built-in scopes let dependencies live for the whole application, a request, or even shorter units.
  • Thread safety. Thread safety is enabled by default. You can disable it to use Rc-based internals instead of Arc and remove Send / Sync requirements.
  • Finalizers. Dependencies can register cleanup logic that runs when a scope is closed.
  • Sync and async support. The crate supports both sync and async factories and containers.
  • Modular registries. Registries can be split and extended instead of building one large registration block.
  • Registration checks. Froodi catches missing or duplicate providers during compilation and can check cycles and static scopes before the app runs.
  • Explicit construction. #[derive(froodi::Construct)] describes field construction with Inject or InjectTransient; provide::<T>() includes it in a chosen registry and scope.
  • Auto-registration. froodi-auto can collect providers declared with macros.
  • Framework integrations. axum, dptree, telers, and ruststream are supported out of the box.

Quickstart

  1. Install the crate.
[dependencies]
froodi = "1.0.0-beta.18"
  1. Define your types.

In this example:

  • Config lives for the whole application
  • GreetingService is created per request
  • WelcomeHandler depends on the greeter and is resolved as a transient value
use std::sync::Arc;

trait Greeter: Send + Sync {
    fn greet(&self, name: &str) -> String;
}

#[derive(Clone)]
struct Config {
    greeting: String,
}

struct GreetingService {
    greeting: String,
}

impl Greeter for GreetingService {
    fn greet(&self, name: &str) -> String {
        format!("{}, {name}!", self.greeting)
    }
}

struct WelcomeHandler {
    greeter: Arc<Box<dyn Greeter>>,
}

impl WelcomeHandler {
    fn handle(&self, name: &str) {
        println!("{}", self.greeter.greet(name));
    }
}
  1. Register factories in a registry.
use froodi::{
    Container,
    DefaultScope::{App, Request},
    Inject, boxed, instance, registry,
};

fn build_container(cfg: Config) -> Container {
    Container::new(registry! {
        provide(App, instance(cfg)),
        scope(Request) [
            provide(|Inject(cfg): Inject<Config>| {
                Ok(boxed!(GreetingService { greeting: cfg.greeting.clone() }; Greeter))
            }),
            provide(|Inject(greeter)| {
                Ok(WelcomeHandler { greeter })
            }),
        ],
    })
}
  1. Create a container and enter the next scope.

Container::new(...) starts at the first non-optional default scope, which is usually App. enter_build() moves to the next non-optional child scope, which is usually Request.

let app_container = build_container(Config {
    greeting: "Hello".to_owned(),
});

let request_container = app_container.clone().enter_build().unwrap();
  1. Resolve dependencies.

Use get::<T>() to resolve a dependency using its scope and cache settings, and get_transient::<T>() to request a fresh instance.

let handler = request_container.get_transient::<WelcomeHandler>().unwrap();
handler.handle("froodi");

let config = request_container.get::<Config>().unwrap();
assert_eq!(config.greeting, "Hello");
  1. Close containers when done.
request_container.close();
app_container.close();
Full example
use froodi::{
    Container,
    DefaultScope::{App, Request},
    Inject, boxed, instance, registry,
};
use std::sync::Arc;

trait Greeter: Send + Sync {
    fn greet(&self, name: &str) -> String;
}

#[derive(Clone)]
struct Config {
    greeting: String,
}

struct GreetingService {
    greeting: String,
}

impl Greeter for GreetingService {
    fn greet(&self, name: &str) -> String {
        format!("{}, {name}!", self.greeting)
    }
}

struct WelcomeHandler {
    greeter: Arc<Box<dyn Greeter>>,
}

impl WelcomeHandler {
    fn handle(&self, name: &str) {
        println!("{}", self.greeter.greet(name));
    }
}

fn build_container(cfg: Config) -> Container {
    Container::new(registry! {
        provide(App, instance(cfg)),
        scope(Request) [
            provide(|Inject(cfg): Inject<Config>| {
                Ok(boxed!(GreetingService { greeting: cfg.greeting.clone() }; Greeter))
            }),
            provide(|Inject(greeter)| {
                Ok(WelcomeHandler { greeter })
            }),
        ],
    })
}

fn main() {
    let app_container = build_container(Config {
        greeting: "Hello".to_owned(),
    });

    let request_container = app_container.clone().enter_build().unwrap();

    let handler = request_container.get_transient::<WelcomeHandler>().unwrap();
    handler.handle("froodi");

    let config = request_container.get::<Config>().unwrap();
    assert_eq!(config.greeting, "Hello");

    request_container.close();
    app_container.close();
}
  1. (Optional) Add async support or framework integration.

Alternative for simple constructors

The WelcomeHandler above can use Froodi's Inject resolution directly. You can derive Construct on that struct:

#[derive(froodi::Construct)]
struct WelcomeHandler {
    greeter: Arc<Box<dyn Greeter>>,
}

Then replace its handwritten provide registration in scope(Request) with:

provide::<WelcomeHandler>(),

The derive describes construction; the registry chooses whether to include the type and in which scope. Fields use ordinary Inject<T> semantics by default, and #[di(inject)] selects the same mode explicitly. For a field that needs a fresh value, use #[di(inject_transient)] on its ordinary value type. Scope, config, and finalizer stay on the registry entry. Use provide(...) for fallible, async, or custom construction.

See Construct registration for a runnable example using both field modes.

Concepts

Dependency

A dependency is simply a value constructed by the container. Factories can depend on other values, and froodi resolves those dependencies recursively.

Scope

A scope describes how long a dependency lives.

The built-in default scope chain is:

Runtime -> App -> Session -> Request -> Action -> Step

Runtime and Session are optional by default. That means:

  • Container::new(...) usually starts from App
  • container.enter_build() usually goes from App to Request

If you want one of the optional scopes explicitly:

use froodi::{Container, DefaultScope::{Request, Runtime, Session}, registry};

let runtime_container = Container::new_with_start_scope(registry! {
    scope(Runtime) [
        provide(|| Ok(())),
    ],
    scope(Session) [
        provide(|| Ok(((), ()))),
    ],
    scope(Request) [
        provide(|| Ok(((), (), ()))),
    ],
}, Runtime);

let session_container = runtime_container.clone().enter().with_scope(Session).build().unwrap();

Container

The container holds resolved scoped dependencies and is used to access them.

  • get::<T>() resolves a dependency using its scope and cache settings
  • get_transient::<T>() creates a fresh value
  • enter_build() creates the next child scope
  • close() runs finalizers for resolved dependencies in that scope

If a child container was created by skipping optional parent scopes, closing the child also closes those skipped parents. For example, a request container created from an app container also closes the skipped Session scope.

Registry

The registry defines how dependencies are constructed.

The main registration forms are:

  • provide(scope, factory)
  • provide::<T>() for a type with #[derive(froodi::Construct)]
  • scope(ScopeName) [ provide(factory), provide::<T>(), ... ]
  • extend(other_registry)
  • extend_fragment(infrastructure!(config), services!()) for reusable syntax
  • instance(value) for values created outside the container

Use #[froodi::fragment(name)] at module scope to save a registry template. Parameters accept expressions with inferred types. Dependencies are checked together with the surrounding registry.

use froodi::{instance, registry, DefaultScope::App};

#[froodi::fragment(configuration(message))]
registry! {
    provide(App, instance(message)),
}

let registry = registry! {
    extend_fragment(configuration!("Hello".to_owned())),
};

extend_fragment includes reusable syntax; extend accepts registry values. Use .into_registry() on a final composition when you need an erased Registry.

Finalizer

A finalizer is cleanup logic attached to a registered dependency. It is executed when the owning scope is closed.

use froodi::{
    Container,
    DefaultScope::App,
    instance, registry,
};

#[derive(Clone)]
struct AppState;

let container = Container::new_with_start_scope(
    registry! {
        provide(App, instance(AppState), finalizer = |_dep| println!("AppState finalized")),
    },
    App,
);

let _state = container.get::<AppState>().unwrap();
container.close();

Trait objects

Use boxed! when the provided type should be exposed as a trait object.

use froodi::{Inject, boxed, registry};
use froodi::DefaultScope::Request;

trait Greeter {
    fn greet(&self) -> &'static str;
}

struct GreetingService;

impl Greeter for GreetingService {
    fn greet(&self) -> &'static str {
        "hello"
    }
}

struct Handler {
    greeter: std::sync::Arc<Box<dyn Greeter>>,
}

let registry = registry! {
    scope(Request) [
        provide(|| Ok(boxed!(GreetingService; Greeter))),
        provide(|Inject(greeter)| Ok(Handler { greeter })),
    ],
};

Features

Common feature combinations:

[dependencies]
froodi = { version = "1.0.0-beta.18", features = ["async", "axum"] } # choose the flags you need
froodi-auto = { version = "1", features = ["async"] }

Important feature flags:

  • thread_safe (enabled by default)
  • async
  • axum
  • http2-axum
  • dptree
  • telers
  • ruststream

Disable default features if you want to turn off thread_safe.

Registration checks

registry! catches missing or duplicate providers while compiling. It also catches dependency cycles when you build an application with a closed registry. Use Inject<T> or InjectTransient<T> for ordinary dependencies.

Values supplied later through Context or another registry can be declared with declare::<T>(). Use InjectCustom<T> for a custom resolver. Froodi checks these runtime boundaries when the container runs. Custom StaticScope types can enable earlier scope checks; ordinary scope values keep runtime checks.

Use TypedContainer when you also want unknown get::<T>() requests to fail at compile time. For more detail, see the API documentation for registry!.

With the async feature, the same registry! accepts sync and async factories together. async_impl::Container resolves either kind; Container resolves only sync providers, even when unrelated async providers are registered. Sync factories can depend only on sync providers; async factories can use either.

Use .into_registry() to return a composition as Registry. The same Registry can hold sync, async or mixed registrations and be extended into another registry.

Examples

Browse the full examples directory.

Community

Contributing

Contributions are welcome.

License

Apache License, Version 2.0

About

An ergonomic Rust IoC container

Topics

Resources

Stars

23 stars

Watchers

1 watching

Forks

Releases

Used by

Contributors

Languages