Wire a signup service through an IoC container

For Rust developers who want dependency injection without a framework

You build a small signup program whose service gets its config, its user repository and an optional notifier from a fibre_ioc container, then you test it by swapping the real repository for a test double.

Before you start

You need Rust with Cargo. fibre_ioc comes from crates.io:

fibre_ioc = "0"

Set up the project#

Start a new binary crate and add fibre_ioc.

cargo new signup
cd signup
cargo add fibre_ioc@0

Your Cargo.toml should now have this dependency block:

Cargo.toml
[dependencies]
fibre_ioc = "0"

The program has four pieces: an AppConfig with a couple of settings, a UserRepo trait with an in-memory implementation, a SignupService that uses both and an optional Notifier. Each one goes into the container and the service asks the container for the others.

Register a config instance#

The simplest registration is an object you already have. add_instance stores it as a singleton. resolve! gets it back as an Arc. Replace src/main.rs with this:

src/main.rs
use fibre_ioc::{global, resolve};

struct AppConfig {
    app_name: String,
    max_name_len: usize,
}

fn main() {
    global().add_instance(AppConfig {
        app_name: "Tally".to_string(),
        max_name_len: 8,
    });

    let config = resolve!(AppConfig);
    println!("{} accepts names up to {} characters", config.app_name, config.max_name_len);
}

global() returns the one application-wide Container. It is created the first time you touch it. resolve!(AppConfig) looks the service up by its type and returns Arc<AppConfig>.

$ cargo run -q
Tally accepts names up to 8 characters

Resolve a repository by trait#

The service should depend on what a repository does, so define a trait for it. The trait needs Send + Sync because the thread-safe container only stores types that are safe to share between threads. Add the trait and an in-memory implementation below AppConfig. Also change the use line to bring in Arc and Mutex:

src/main.rs
use fibre_ioc::{global, resolve};
use std::sync::{Arc, Mutex};

trait UserRepo: Send + Sync {
    fn insert(&self, name: &str) -> u64;
    fn count(&self) -> usize;
}

#[derive(Default)]
struct MemoryUserRepo {
    names: Mutex<Vec<String>>,
}

impl UserRepo for MemoryUserRepo {
    fn insert(&self, name: &str) -> u64 {
        let mut names = self.names.lock().unwrap();
        names.push(name.to_string());
        names.len() as u64
    }

    fn count(&self) -> usize {
        self.names.lock().unwrap().len()
    }
}

add_singleton_trait registers a factory for a trait object. You name the trait with a turbofish and the factory returns an Arc of the concrete type. The factory runs once, on the first resolution. Every later resolution gets the same Arc. To resolve it, put trait in front of the name. Update main:

src/main.rs
fn main() {
    global().add_instance(AppConfig {
        app_name: "Tally".to_string(),
        max_name_len: 8,
    });
    global().add_singleton_trait::<dyn UserRepo>(|| Arc::new(MemoryUserRepo::default()));

    let config = resolve!(AppConfig);
    println!("{} accepts names up to {} characters", config.app_name, config.max_name_len);

    let repo = resolve!(trait UserRepo);
    repo.insert("ada");

    let same_repo = resolve!(trait UserRepo);
    println!("users stored: {}", same_repo.count());
}

The second resolve! returns the same repository the first one wrote to, so the count is 1.

$ cargo run -q
Tally accepts names up to 8 characters
users stored: 1

Build a service that depends on both#

SignupService holds an Arc<AppConfig> and an Arc<dyn UserRepo>. It never names MemoryUserRepo. Add it below the repository:

src/main.rs
struct SignupService {
    config: Arc<AppConfig>,
    repo: Arc<dyn UserRepo>,
}

impl SignupService {
    fn sign_up(&self, name: &str) -> Result<u64, String> {
        if name.len() > self.config.max_name_len {
            return Err(format!(
                "{name:?} is longer than {} characters",
                self.config.max_name_len
            ));
        }
        Ok(self.repo.insert(name))
    }
}

The service is registered with add_singleton. Its factory resolves its own dependencies with resolve!, which reads the global container. Put all the registrations in one function so main stays short:

src/main.rs
fn register_services() {
    global().add_instance(AppConfig {
        app_name: "Tally".to_string(),
        max_name_len: 8,
    });
    global().add_singleton_trait::<dyn UserRepo>(|| Arc::new(MemoryUserRepo::default()));
    global().add_singleton(|| SignupService {
        config: resolve!(AppConfig),
        repo: resolve!(trait UserRepo),
    });
}

fn main() {
    register_services();

    let signup = resolve!(SignupService);
    println!("{} signups", signup.config.app_name);
    for name in ["ada", "grace", "margaret hamilton"] {
        match signup.sign_up(name) {
            Ok(id) => println!("signed up {name} as user {id}"),
            Err(e) => println!("rejected: {e}"),
        }
    }

    let repo = resolve!(trait UserRepo);
    println!("users stored: {}", repo.count());
}

When main resolves SignupService, the container runs its factory, which resolves the config and the repository. The repository main resolves at the end is the same singleton the service wrote to.

$ cargo run -q
Tally signups
signed up ada as user 1
signed up grace as user 2
rejected: "margaret hamilton" is longer than 8 characters
users stored: 2

If you resolve a service nobody registered with resolve! or resolve_from!, the program panics with a message naming the missing service. That is what you want for dependencies the program cannot run without.

Add an optional notifier#

Some dependencies are nice to have. Add a Notifier trait with a console implementation:

src/main.rs
trait Notifier: Send + Sync {
    fn notify(&self, message: &str);
}

struct ConsoleNotifier;

impl Notifier for ConsoleNotifier {
    fn notify(&self, message: &str) {
        println!("[notify] {message}");
    }
}

Give the service an Option<Arc<dyn Notifier>> and call it when it is there:

src/main.rs
struct SignupService {
    config: Arc<AppConfig>,
    repo: Arc<dyn UserRepo>,
    notifier: Option<Arc<dyn Notifier>>,
}

impl SignupService {
    fn sign_up(&self, name: &str) -> Result<u64, String> {
        if name.len() > self.config.max_name_len {
            return Err(format!(
                "{name:?} is longer than {} characters",
                self.config.max_name_len
            ));
        }
        let id = self.repo.insert(name);
        if let Some(notifier) = &self.notifier {
            notifier.notify(&format!("welcome to {}, {name}", self.config.app_name));
        }
        Ok(id)
    }
}

In the factory, maybe_resolve! returns None for a missing service instead of panicking. Add it to the use line and to register_services:

src/main.rs
use fibre_ioc::{global, maybe_resolve, resolve};

fn register_services() {
    global().add_instance(AppConfig {
        app_name: "Tally".to_string(),
        max_name_len: 8,
    });
    global().add_singleton_trait::<dyn UserRepo>(|| Arc::new(MemoryUserRepo::default()));
    global().add_singleton(|| SignupService {
        config: resolve!(AppConfig),
        repo: resolve!(trait UserRepo),
        notifier: maybe_resolve!(trait Notifier),
    });
}

Now register the notifier in main only when the program is started with --notify. Registration is dynamic, so it can come after register_services: the service's factory has not run yet and sees whatever is registered when SignupService is first resolved.

src/main.rs
fn main() {
    register_services();
    if std::env::args().any(|arg| arg == "--notify") {
        global().add_singleton_trait::<dyn Notifier>(|| Arc::new(ConsoleNotifier));
    }

    let signup = resolve!(SignupService);
    println!("{} signups", signup.config.app_name);
    for name in ["ada", "grace", "margaret hamilton"] {
        match signup.sign_up(name) {
            Ok(id) => println!("signed up {name} as user {id}"),
            Err(e) => println!("rejected: {e}"),
        }
    }

    let repo = resolve!(trait UserRepo);
    println!("users stored: {}", repo.count());
}

Run it without the flag and the output is the same as before. Run it with the flag and the notifier speaks up:

$ cargo run -q -- --notify
Tally signups
[notify] welcome to Tally, ada
signed up ada as user 1
[notify] welcome to Tally, grace
signed up grace as user 2
rejected: "margaret hamilton" is longer than 8 characters
users stored: 2

Swap in a test double#

Tests should not share the global container, because every test would see the others' registrations. Each test creates its own Container, registers what the test needs on it and resolves from it with resolve_from!. When the container goes out of scope at the end of the test, everything in it is dropped.

Here the test registers a config and a RecordingRepo for dyn UserRepo, then builds the SignupService from what that container holds. The test keeps its own Arc to the double so it can check what was written. Add this at the bottom of src/main.rs:

src/main.rs
#[cfg(test)]
mod tests {
    use super::*;
    use fibre_ioc::{Container, resolve_from};

    #[derive(Default)]
    struct RecordingRepo {
        inserted: Mutex<Vec<String>>,
    }

    impl UserRepo for RecordingRepo {
        fn insert(&self, name: &str) -> u64 {
            self.inserted.lock().unwrap().push(name.to_string());
            42
        }

        fn count(&self) -> usize {
            self.inserted.lock().unwrap().len()
        }
    }

    fn signup_with(repo: Arc<RecordingRepo>) -> SignupService {
        let container = Container::new();
        container.add_instance(AppConfig {
            app_name: "Test".to_string(),
            max_name_len: 8,
        });
        container.add_singleton_trait::<dyn UserRepo>(move || repo.clone());

        SignupService {
            config: resolve_from!(&container, AppConfig),
            repo: resolve_from!(&container, trait UserRepo),
            notifier: None,
        }
    }

    #[test]
    fn stores_valid_names() {
        let repo = Arc::new(RecordingRepo::default());
        let signup = signup_with(Arc::clone(&repo));

        assert_eq!(signup.sign_up("ada"), Ok(42));
        assert_eq!(*repo.inserted.lock().unwrap(), vec!["ada"]);
    }

    #[test]
    fn rejects_long_names_before_the_repo() {
        let repo = Arc::new(RecordingRepo::default());
        let signup = signup_with(Arc::clone(&repo));

        assert!(signup.sign_up("margaret hamilton").is_err());
        assert_eq!(repo.count(), 0);
    }
}

The id 42 in the first test can only come from RecordingRepo, so a passing test also shows the double is the one in use.

$ cargo test -q
running 2 tests
..
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Where to go next#

This tutorial used instances, singletons and trait singletons. The container also has transient factories that build a fresh value on every resolution (add_transient), named registrations for several providers of the same type (add_singleton_with_name and resolve!(AppConfig, "test_config")) and a LocalContainer behind the local feature for types that are not Send or Sync. It also panics with a clear message when two services depend on each other in a cycle.

The usage guide on GitHub covers each of these with examples: https://github.com/excsn/fibre/blob/main/ioc/README.USAGE.md

The full API is on docs.rs: https://docs.rs/fibre_ioc