Dependency injection (DI) is a design pattern where a component receives its dependencies from an external source rather than creating them itself.^1 This decouples components, making code easier to test, swap, and maintain. In Rust, DI is achieved idiomatically through traits and generics — without needing a framework — leveraging the compiler’s type system to enforce correctness at compile time.^2


Core Tool: Traits

In Rust, traits act as the contract (interface) that dependencies must fulfill.^1 Define the behaviour, not the implementation:

pub trait Logger {
    fn log(&self, message: &str);
}
 
pub struct ConsoleLogger;
 
impl Logger for ConsoleLogger {
    fn log(&self, message: &str) {
        println!("[LOG]: {}", message);
    }
}

Approach 1: Generics (Static Dispatch)

The preferred Rust approach — the concrete type is resolved at compile time, producing zero-cost abstractions.^1

pub struct Application<L: Logger> {
    logger: L,
}
 
impl<L: Logger> Application<L> {
    pub fn new(logger: L) -> Self {
        Self { logger }
    }
 
    pub fn run(&self) {
        self.logger.log("Application is running!");
    }
}
 
fn main() {
    let app = Application::new(ConsoleLogger);
    app.run(); // prints: [LOG]: Application is running!
}

Application only requires that L implements Logger — swap in any logger without touching Application itself.^3


Approach 2: Trait Objects (Dynamic Dispatch)

When you need runtime flexibility or to store mixed implementations in a collection, use Box<dyn Trait>.^3

pub trait MessageSender {
    fn send(&self, msg: &str);
}
 
pub struct NotificationService {
    sender: Box<dyn MessageSender>,
}
 
impl NotificationService {
    pub fn new(sender: Box<dyn MessageSender>) -> Self {
        Self { sender }
    }
 
    pub fn notify(&self, msg: &str) {
        self.sender.send(msg);
    }
}

This introduces a small runtime overhead via vtable lookup, so prefer generics unless dynamic dispatch is genuinely needed.^1


Approach 3: Enums (Closed Set)

If you have a fixed, known set of implementations, an enum avoids both generics complexity and boxing overhead.^3

pub enum LoggerKind {
    Console,
    File(String),
}
 
impl Logger for LoggerKind {
    fn log(&self, message: &str) {
        match self {
            LoggerKind::Console => println!("[Console]: {}", message),
            LoggerKind::File(path) => println!("[File({})] {}", path, message),
        }
    }
}

Why DI Shines in Testing

The real payoff is mockability — swap the real implementation with a mock during tests.^1

pub struct MockLogger {
    pub messages: std::cell::RefCell<Vec<String>>,
}
 
impl Logger for MockLogger {
    fn log(&self, message: &str) {
        self.messages.borrow_mut().push(message.to_string());
    }
}
 
#[test]
fn test_app_logs_on_run() {
    let mock = MockLogger { messages: Default::default() };
    let app = Application::new(&mock);
    app.run();
    assert!(mock.messages.borrow().contains(&"Application is running!".to_string()));
}

No real I/O, no external systems — pure, fast unit tests.^1


Choosing the Right Approach

GenericsBox<dyn Trait>Enum
DispatchCompile time (static)Runtime (dynamic)Compile time (static)
OverheadZeroVtable lookupZero
ImplementationsAny (open set)Any (open set)Fixed (closed set)
Heterogeneous collections
Best forMost casesPlugin-like flexibilityKnown, finite variants

For complex dependency graphs, consider a DI container crate such as rustyinject.^4


Key Pitfall: Visibility Creep

Any type referenced in a public trait’s function signature must also be public.^2 If your trait is public but references an internal struct, you’ll be forced to expose that struct too — keep internal traits pub(crate) where possible.^2