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.
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
- 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 ofArcand removeSend/Syncrequirements. - 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 withInjectorInjectTransient;provide::<T>()includes it in a chosen registry and scope. - Auto-registration.
froodi-autocan collect providers declared with macros. - Framework integrations.
axum,dptree,telers, andruststreamare supported out of the box.
- Install the crate.
[dependencies]
froodi = "1.0.0-beta.18"- Define your types.
In this example:
Configlives for the whole applicationGreetingServiceis created per requestWelcomeHandlerdepends 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));
}
}- 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 })
}),
],
})
}- 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();- 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");- 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();
}- (Optional) Add async support or framework integration.
- For async containers and factories, see async registration
- For
froodi-auto, see auto registration and async auto registration - For framework integration, see axum, dptree, telers, and ruststream
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.
A dependency is simply a value constructed by the container.
Factories can depend on other values, and froodi resolves those dependencies recursively.
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 fromAppcontainer.enter_build()usually goes fromApptoRequest
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();The container holds resolved scoped dependencies and is used to access them.
get::<T>()resolves a dependency using its scope and cache settingsget_transient::<T>()creates a fresh valueenter_build()creates the next child scopeclose()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.
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 syntaxinstance(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.
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();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 })),
],
};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)asyncaxumhttp2-axumdptreetelersruststream
Disable default features if you want to turn off thread_safe.
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.
- Registration. Basic synchronous container setup
- Construct registration. Derive field construction with
InjectandInjectTransient - Fragments. Split the greeting example into reusable registrations across modules
- Async registration. Sync and async factories in one registry
- Finalization. Scoped cleanup with synchronous finalizers
- Async finalization. Scoped cleanup with async finalizers
- Auto registration. Synchronous auto-registration with
froodi-auto - Async auto registration. Async auto-registration with
froodi-auto - Context. Supply request values through
Context - Axum integration. Request injection in
axum - Dptree integration. Endpoint injection in
dptree - Telers integration. Handler injection in
telers - RustStream integration. Handler injection in
ruststream
Browse the full examples directory.
- 🇺🇸 🇷🇺 @froodi_di
Contributions are welcome.