Introduction to Dependency Injection in Rust
Dependency Injection (DI) is a design pattern where an object receives its dependencies from external sources rather than creating them itself. In the Rust ecosystem, this approach gains particular significance due to its strict type system and ownership model. DI makes code more modular, testable, and maintainable, which is especially important for large projects.
Unlike languages with dynamic typing or garbage collection, Rust requires a special approach to dependency injection. There is no reflection or runtime containers in the classical sense. Instead, developers use static mechanisms: trait objects, generics, and composition through structs.
In this article, we will explore three main approaches to implementing Dependency Injection in Rust, starting from the simplest manual injection to using libraries. Each approach will be illustrated with practical code examples.
1. Manual Dependency Injection (Manual DI)
The simplest and most transparent way is to pass dependencies through the struct constructor. This approach requires no additional libraries and is fully controlled by the Rust compiler.
Example: Logger and Service
// Define a trait for loggingtrait Logger { fn log(&self, message: &str);}
// Logger implementation for productionstruct ConsoleLogger;
impl Logger for ConsoleLogger { fn log(&self, message: &str) { println!("[LOG]: {}", message); }}
// Implementation for testsstruct MockLogger { pub messages: Vec<String>,}
impl Logger for MockLogger { fn log(&self, message: &str) { self.messages.push(message.to_string()); }}
// Service depending on Loggerstruct UserService<T: Logger> { logger: T,}
impl<T: Logger> UserService<T> { fn new(logger: T) -> Self { Self { logger } }
fn create_user(&self, name: &str) { self.logger.log(&format!("Created user: {}", name)); }}
fn main() { let logger = ConsoleLogger; let service = UserService::new(logger); service.create_user("Alexey");}Advantages:
- Complete type safety at compile time
- Maximum performance (zero-cost abstraction)
- Ease of understanding and debugging
Disadvantages:
- Code becomes cumbersome as the number of dependencies grows
- Difficult to change implementations at runtime
- Requires explicitly passing all dependencies through the call chain
2. Injection through trait objects (Dynamic Dispatch)
If you need the ability to change implementations at runtime or hide the concrete type, use trait objects (Box<dyn Trait>). This is a classic approach for DI, known from other languages.
Example: Abstract Repository
use std::sync::Mutex;
trait UserRepository { fn find_by_id(&self, id: u32) -> Option<String>; fn save(&self, user: String);}
struct InMemoryRepository { users: Mutex<Vec<String>>,}
impl UserRepository for InMemoryRepository { fn find_by_id(&self, id: u32) -> Option<String> { let users = self.users.lock().unwrap(); users.get(id as usize).cloned() }
fn save(&self, user: String) { let mut users = self.users.lock().unwrap(); users.push(user); }}
struct UserController { repository: Box<dyn UserRepository>,}
impl UserController { fn new(repository: Box<dyn UserRepository>) -> Self { Self { repository } }
fn get_user(&self, id: u32) -> Option<String>