Dependency Injection Containers for Rust: Managing Dependencies in Systems Programming
In the world of systems programming, Rust is renowned for its performance and memory safety. However, as a project grows, the challenge of managing complex dependency graphs arises. Unlike languages with automatic memory management (Java, C#, Python), Rust requires a special approach to Dependency Injection (DI). In this article, we will explore what tools and libraries exist for implementing DI containers in Rust, and how to use them correctly.
Why Do You Need a DI Container in Rust?
The Dependency Injection pattern allows separating object creation from their usage. In Rust, this is especially relevant due to the strict ownership and borrowing system. A DI container solves several tasks:
- Simplified testing — the ability to replace real implementations with mocks
- Reduced code coupling — components depend on abstractions, not concrete implementations
- Centralized lifecycle management — singletons, temporary objects, pools
- Automatic dependency resolution — the container builds the object graph itself
Main Libraries for DI in Rust
1. shaku — Simple and Efficient DI Container
One of the most popular libraries. Based on traits and macros. Allows registering components with different lifetimes.
// Cargo.toml// [dependencies]// shaku = "0.6"use shaku::{module, HasComponent, Interface};use std::sync::Arc;
// Define the interfacetrait Logger: Interface { fn log(&self, msg: &str);}
// Implementation#[derive(Clone)]struct ConsoleLogger;impl Logger for ConsoleLogger { fn log(&self, msg: &str) { println!("LOG: {}", msg); }}
// Registration modulemodule! { MyModule { components = [ConsoleLogger], providers = [] }}
// Usagefn main() { let module = MyModule::builder().build(); let logger: Arc<dyn Logger> = module.resolve().unwrap(); logger.log("Hello from DI!");}2. dyn-clone + Manual Container
If you prefer minimalism, you can create a simple container manually using Any and dyn-clone. This gives you full control over the process.
use std::any::Any;use std::collections::HashMap;
struct SimpleContainer { services: HashMap<String, Box<dyn Any>>,}
impl SimpleContainer { fn new() -> Self { SimpleContainer { services: HashMap::new() } }
fn register<T: 'static>(&mut self, service: T) { let type_name = std::any::type_name::<T>().to_string(); self.services.insert(type_name, Box::new(service)); }
fn resolve<T: 'static>(&self) -> Option<&T> { let type_name = std::any::type_name::<T>().to_string(); self.services.get(&type_name) .and_then(|s| s.downcast_ref::<T>()) }}3. once_cell and lazy_static — For Singletons
Often DI boils down to managing global states. These libraries help create lazily initialized singletons.
use once_cell::sync::Lazy;use std::sync::Mutex;
static CONFIG: Lazy<Mutex<Config>> = Lazy::new(|| { Mutex::new(Config::load())});
struct Config { db_url: String,}
impl Config { fn load() -> Self { Config { db_url: "postgres://localhost".into() } }}Architectural Patterns for DI in Rust
In Rust, Constructor Injection is often used — dependencies are passed through the struct constructor. This is the most idi