Ferrunix

A simple, idiomatic, and lightweight dependency injection framework for Rust.

Build Status Crates.io API reference MSRV License

[dependencies]
ferrunix = "0.3"

Compiler support: requires rustc 1.67.1+

Check out the User Guide.

Documentation

Due to how the various features affect the public API of the library, the documentation is provided for each major feature separately.

Feature FlagsLink to Documentation
nonelink to docs
multithreadlink to docs
tokiolink to docs

Features

  • Can register and inject any type (incl. generics, types must be Send if the multithread feature is enabled, and Send + Sync if tokio is enabled).
  • Simple and elegant Rust API; making the derive macro purely optional.
  • Different dependency lifetimes:
    • Singleton: Only a single instance of the object is created.
    • Transient: A new instance is created for every request.
  • Dependency resolution happens at run time, making it possible to dynamically register types.
  • Injection of concrete value types (T), Box<T>, Rc<T>, and Arc<T>.
  • Derive macro (#[derive(Inject)]) to simplify registration.
  • Automatic registration of types, thanks to inventory.
  • One global registry; with support for multiple sub-registries.

Cargo Feature Flags

Ferrunix has the following features to enable further functionality. Features enabled by default are marked with *.

  • multithread: Enables support for accessing the registry from multiple threads. This adds a bound that all registered types must be Send.
  • derive (*): Enables support for the #[derive(Inject)] macro.
  • tokio: Enables support for async constructors. Bumps the MSRV up to 1.75.0 because some of the internal traits require RPITIT.
  • tracing: Enables support for tracing and annotates all public functions with tracing::instrument.

License

Licensed under either of Apache License, Version 2.0 or MIT license at your option.
By contributing to this project (for example, through submitting a pull request) you agree with the individual contributor license agreement. Make sure to read and understand it.

First Steps

Follow the steps of creating your first application with ferrunix.

Installation

Install ferrunix by adding the following to your Cargo.toml.

[dependencies]
ferrunix = "0.3"

or by running cargo add:

cargo add ferrunix

Once installed, the next step is understanding the core concepts.

Core Concepts

Fundamentally, ferrunix is a hash table, with the registered type as the key, and the objects constructor (for transients) or value (for singletons) as the value.

In Rust, this could be, very crudely, represented as a HashMap:

enum Provider {
    Transient(fn() -> Box<dyn Any>),
    Singleton(OnceCell<dyn Any>),
}

type Registry = HashMap<TypeId, Provider>;

Of course, reality is a bit more complicated.

The core concepts necessary to understand for efficiently using ferrunix are:

All of which will be tacked in the next few pages.

Registry

The Registry is used for registering new objects, and retrieving previously registered objects.

Each object has a specific lifetime set during registration, currently, the following two lifetimes are supported:

  • transient: when retrieved, a new object is created.
  • singleton: when retrieved, a single instance is lazily created, and a reference is returned.

All functions of the registry are outlined in the Registry reference guide or described in detail in the documentation.

The next step is to more thoroughly understand lifetimes.

Lifetimes

Transient

Transients are constructed every time when requested. For transients, the following container types are supported:

  • T: No container.
  • Box<T>: A boxed type, where T can be a dyn T.

Singleton

Singletons are lazily constructed on the first access, and only a single instance can be created over the programs lifetime. When requested, a reference to the object is returned. The following container types are supported:

  • Rc<T>: When multithread and tokio feature are disabled.
  • Arc<T>: When the multithread or tokio is enabled.

The library offers a Ref type alias, which is aliasing the correct container, based on the enabled features.

The next step is to understand registration.

Registration

To register a new object, we need to tell the Registry which type we want to use as a key, what dependencies it has, and how to construct it.

For the registration, we have four functions on the Registry that are of interest:

Without dependencies

The most straightforward object to register is a type without dependencies:

#![allow(unused)]
extern crate ferrunix;
use ferrunix::Registry;

pub struct CurrentCurrency(&'static str);

fn main() {
    // Create a new empty registry.
    let registry = Registry::empty();

    // Register `CurrentCurrency` as a singleton.
    registry.singleton(|| CurrentCurrency("USD"));

    // Retrieve `CurrentCurrency` from the registry.
    let currency = registry.get_singleton::<CurrentCurrency>().unwrap();

    // Assert that our retrieved object is actually what we expect.
    assert_eq!(currency.0, "USD");
}

Of course, this example is rather simple, but should highlight the pattern of registering a new object:

  1. The constructor is registered with registry.singleton or registry.transient.
  2. A new object is retrieved using either registry.get_singleton or registry.get_transient.
  3. The retrieved object is used.

The CurrentCurrency object in the example above is registered with a singleton lifetime. As a result, it needs to be retrieved with get_singleton. Trying to retrieve it with get_transient will return None.

Remember!
Retrieval lifetime must match registration lifetime!

With dependencies

Types that have dependencies need to state the dependencies they have, so that the Registry can fulfill them before passing them to the constructor of the type to be registered.

#![allow(unused)]
extern crate ferrunix;
use ferrunix::{Transient, Registry};

pub struct CurrentCurrency(&'static str);

pub struct Config {
    currency: CurrentCurrency,
}

fn main() {
    let registry = Registry::empty();
    // Register a type without dependencies.
    registry.transient(|| CurrentCurrency("USD"));

    // Register our `Config` types with a dependency.
    registry
        .with_deps::<_, (Transient<CurrentCurrency>,)>() // Trailing comma required!
        .transient(|(currency,)| {
            Config {
                currency: currency.get(),
            }
        });

    // Construct a new config.
    let config = registry.get_transient::<Config>().unwrap();

    // Assert that our retrieved object is actually what we expect.
    assert_eq!(config.currency.0, "USD");
}

This follows a very similar pattern as our previous example; however, the registration of the type with dependencies is slightly different.

Let's examine this in a bit more detail:

#![allow(unused)]
extern crate ferrunix;
use ferrunix::{Transient, Registry};
pub struct CurrentCurrency(&'static str);
pub struct Config {
    currency: CurrentCurrency,
}
fn main() {
let registry = Registry::empty();
registry
  .with_deps::<_, (Transient<CurrentCurrency>,)>() // <-- (1)
  .transient(|(currency,)| { // <-- (2)
      Config {
          currency: currency.get(), // <-- (3)
      }
  });
}

At (1), the .with_deps function has the following (simplified) call signature: fn with_deps<Ret, Deps> with Ret being the type that's to be registered, in our case Config and Deps a tuple type of our dependencies, in our case (CurrentCurrency,).

To indicate, that this is a transient dependency, the Transient<T> marker type needs to be used. A similar marker also exists for singletons, it's called Singleton<T>.

At (2), the constructor for the transient object Config is registered. It takes a single argument, a tuple of dependencies, which we immediately destructure into it's parts.

Careful!
The trailing comma for the tuple at (1) and (2) is necessary to indicate a single element tuple (and not a literal that's in parentheses). This is only necessary for types with one dependency.

At (3), we'll use the Transient<T>'s get() function to consume it and get the inner CurrentCurrency to construct the Config, which is returned from the constructor function. The inner CurrentCurrency is constructed with the previously registered constructor.

With the registration done, the last part to is retrieval of constructed objects.

Retrieval

Each registered object needs to be retrieved with the same lifetime that it was registered with.

For retrieval, the following functions are available on the [Registry]:

The generic type parameter T is necessary to be specified, either on the function call or the assignee. It's necessary to find which object to construct.

This is the example from the previous section:

#![allow(unused)]
extern crate ferrunix;
use ferrunix::{Transient, Registry};

pub struct CurrentCurrency(&'static str);
pub struct Config { currency: CurrentCurrency }

fn main() {
    let registry = Registry::empty();
    registry.transient(|| CurrentCurrency("USD"));
    registry
        .with_deps::<_, (Transient<CurrentCurrency>,)>() // Trailing comma required!
        .transient(|(currency,)| {
            Config {
                currency: currency.get(),
            }
        });

    // Construct a new config and currency.
    let config = registry.get_transient::<Config>().unwrap(); // <-- (1)
    let currency: CurrentCurrency = registry.get_transient().unwrap(); // <-- (2)

    // Assert that our retrieved object is actually what we expect.
    assert_eq!(config.currency.0, "USD");
    assert_eq!(currency.0, "USD");
}

At (1) the Registry::get_transient::<T>() function is used to construct a new Config. The generic type parameter T is directly specified at the function call.

However, at (2), the Rust compiler can infer the type T from the annotation on the left, and therefore, it's not necessary to explicitly specify it.

Registry

Creation

Registration

Retrieval

Validation

Derive Macro

The Inject derive macro supports the two following attributes:

  • #[provides]: Customizing the object registration.
  • #[inject]: Customizing how an injected member is created.
#[derive(Inject)]
#[provides(PROPERTY...)]
struct Transient {
    #[inject(PROPERTY...)]
    field: UserType,
}

provides Properties

  • transient [= "<TYPE-SIGNATURE>"]
    • The object is provided as a transient registered with <TYPE-SIGNATURE> as key. If the signature is omitted, the concrete type is used as a key.
  • singleton [= "<TYPE-SIGNATURE>"]
    • The object is provided as a singleton registered with <TYPE-SIGNATURE> as key. If the signature is omitted, the concrete type is used as a key.
  • ctor = <IDENTIFIER>
    • The object isn't constructed using member-wise construction, but it's constructed using a custom constructor (e.g., new). The constructor will be passed the members in order of declaration as parameters.
  • no_registration
    • The type isn't registered automatically and the generated Self::register(&ferrunix::Registry) function needs to be called manually to register the type.

inject Properties

  • default
    • Construct the field using the Default implementation.
  • ctor = "<RUST-CODE>"
    • Construct the field using the provided Rust code.
  • transient [= true]
    • Construct the field as a transient by retrieving it from the Registry.
  • singleton [= true]
    • Construct the field as a singleton by retrieving it from the Registry.

Full Example

#![allow(unused)]
extern crate ferrunix;
use ferrunix::Inject;

pub trait Logger {}

#[derive(Inject)]
#[provides(transient = "dyn Logger", no_registration)]
//                   ^^^^^^^^^^^^^^
// The explicit type can be omitted, if it matches the concrete type.
pub struct MyLogger {}

impl Logger for MyLogger {}

#[derive(Inject)]
#[provides(singleton, no_registration)]
pub struct MyConfig {
    #[inject(default)]
    // Use the `Default::default` impl for construction.
    counter: u32,

    #[inject(ctor = r#""log-prefix: ".to_owned()"#)]
    //              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    // The constructor must be valid Rust code. For strings, two sets of quotes
    // are required.
    prefix: String,

    #[inject(transient /* = true */)]
    //                 ^^^^^^^^^^^^
    // The long form with `= true` is optional.
    logger: Box<dyn Logger>,
}

fn main() {
    let registry = ferrunix::Registry::empty();
    MyLogger::register(&registry);
    MyConfig::register(&registry);
}

Feature Flags

Features enabled by default are marked with *.

  • multithread: Enables support for accessing the registry from multiple threads. This adds a bound that all registered types must be Send.
  • derive (*): Enables support for the #[derive(Inject)] macro.
  • tokio: Enables support for async constructors. Bumps the MSRV up to 1.75.0 because some of the internal traits require RPITIT.
  • tracing: Enables support for tracing and annotates all public functions with tracing::instrument.