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:
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.
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
usefibre_ioc::{global, resolve};structAppConfig{app_name: String,max_name_len:usize,}fnmain(){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 -qTally accepts names up to 8 characters
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:
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
fnmain(){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!(traitUserRepo); 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 -qTally accepts names up to 8 charactersusers stored: 1
SignupService holds an Arc<AppConfig> and an Arc<dyn UserRepo>. It never names MemoryUserRepo. Add it below the repository:
src/main.rs
structSignupService{config:Arc<AppConfig>,repo:Arc<dyn UserRepo>,}implSignupService{fnsign_up(&self, name:&str)->Result<u64, String>{if name.len()>self.config.max_name_len {returnErr(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
fnregister_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!(traitUserRepo),});}fnmain(){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!(traitUserRepo); 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 -qTally signupssigned up ada as user 1signed up grace as user 2rejected: "margaret hamilton" is longer than 8 charactersusers 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.
Give the service an Option<Arc<dyn Notifier>> and call it when it is there:
src/main.rs
structSignupService{config:Arc<AppConfig>,repo:Arc<dyn UserRepo>,notifier:Option<Arc<dyn Notifier>>,}implSignupService{fnsign_up(&self, name:&str)->Result<u64, String>{if name.len()>self.config.max_name_len {returnErr(format!("{name:?} is longer than {} characters",self.config.max_name_len));}let id =self.repo.insert(name);ifletSome(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:
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
fnmain(){register_services();ifstd::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!(traitUserRepo); 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 -- --notifyTally signups[notify] welcome to Tally, adasigned up ada as user 1[notify] welcome to Tally, gracesigned up grace as user 2rejected: "margaret hamilton" is longer than 8 charactersusers stored: 2
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:
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.