Dependency Injection via Generics
Dependency Injection via Generics
Level 18 — Rust Using generic type parameters and trait bounds to inject dependencies rather than hard-coding concrete types, improving testability.
1. Prerequisites
- Generics (
<T>) — Generic parameters. - Trait Bound — Trait bounds.
2. Term Category
Rust Architecture Pattern (trait-based dependency inversion): Dependency Injection via generics and trait bounds.
3. Explanation
(1) Design Motivation — "Why did we design this?"
Hardcoding concrete struct instantiations inside business logic components creates tight coupling, making unit testing and database mocking impossible.
Dependency Injection (DI) in Rust decouples component dependencies by accepting generic parameters bounded by traits (struct Service<D: Database> { db: D }). This enables zero-cost compile-time static dispatch and effortless test mocking.
(2) Reality Metaphor
A game console USB port: the console hardware connects to any compliant controller (steering wheel, gamepad, flight stick) through a standardized interface boundary.
(3) Rust Code Examples
Short Snippet
pub trait Logger { fn log(&self, msg: &str); }
pub struct Service<L: Logger> { pub logger: L }
Fuller Example
pub trait Repository {
fn find_user(&self, id: u64) -> Option<String>;
}
pub struct PostgresRepo;
impl Repository for PostgresRepo {
fn find_user(&self, id: u64) -> Option<String> {
Some(format!("PostgresUser_{id}"))
}
}
pub struct UserService<R: Repository> {
repo: R,
}
impl<R: Repository> UserService<R> {
pub fn new(repo: R) -> Self { Self { repo } }
pub fn get_name(&self, id: u64) -> String {
self.repo.find_user(id).unwrap_or_default()
}
}
fn main() {
let service = UserService::new(PostgresRepo);
assert_eq!(service.get_name(1), "PostgresUser_1");
}
4. Common Mistakes & Pitfalls
Mistake 1: Hardcoding Concrete Struct Types in Service Field Definitions
The mistake: Instantiating concrete structs directly inside service constructors.
Why it is wrong: Prevents replacing the dependency with mock implementations during unit tests.
Incorrect:
struct Service { db: PostgresDatabase }
Fix:
struct Service<D: Database> { db: D }
Mistake 2: Using Box<dyn Trait> for Monomorphic Single-Implementation Dependencies
The mistake: Using dynamic trait objects Box<dyn Trait> when only static compile-time dispatch is needed.
Why it is wrong: Introduces dynamic dispatch vtable overhead and extra heap allocations.
Incorrect:
struct Service { db: Box<dyn Database> }
Fix:
struct Service<D: Database> { db: D }
Mistake 3: Creating Circular Dependencies Between Services
The mistake: Designing Service A to depend on Service B while Service B depends on Service A.
Why it is wrong: Causes ownership and struct lifetime initialization deadlocks in Rust.
Incorrect:
struct A<B_Type> { b: B_Type } struct B<A_Type> { a: A_Type }
Fix:
Decouple shared state into a third shared repository or event channel!
5. Practice Exercises
Exercise 1: Mockable Payment Processor Service
Scenario: Build a payment processor service taking a generic PaymentGateway dependency that can be mocked in unit tests.
Requirements:
- Define
PaymentGatewaytrait withcharge(&self, amount: u64) -> bool. - Implement
MockGatewayfor unit testing. - Implement
OrderService<G: PaymentGateway>.
Answer
Implementation
pub trait PaymentGateway {
fn charge(&self, amount: u64) -> bool;
}
pub struct MockGateway {
pub should_succeed: bool,
}
impl PaymentGateway for MockGateway {
fn charge(&self, _amount: u64) -> bool {
self.should_succeed
}
}
pub struct OrderService<G: PaymentGateway> {
gateway: G,
}
impl<G: PaymentGateway> OrderService<G> {
pub fn new(gateway: G) -> Self { Self { gateway } }
pub fn process_order(&self, amount: u64) -> Result<(), &'static str> {
if self.gateway.charge(amount) {
Ok(())
} else {
Err("Payment declined")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_order_success() {
let service = OrderService::new(MockGateway { should_succeed: true });
assert!(service.process_order(100).is_ok());
}
#[test]
fn test_order_decline() {
let service = OrderService::new(MockGateway { should_succeed: false });
assert!(service.process_order(100).is_err());
}
}
Technical Explanation
OrderServiceaccepts any generic typeGbounded byPaymentGateway.MockGatewaytests success and failure paths deterministically without network calls.
Exercise 2: Injectable Configuration Provider
Scenario: Build a service using dependency injection for configuration reading.
Requirements:
- Define
ConfigStoretrait. - Inject mock config.
Answer
Implementation
pub trait ConfigStore {
fn get(&self, key: &str) -> Option<String>;
}
pub struct MemoryConfig {
pub val: String,
}
impl ConfigStore for MemoryConfig {
fn get(&self, _key: &str) -> Option<String> {
Some(self.val.clone())
}
}
pub struct AppEnv<C: ConfigStore> {
config: C,
}
impl<C: ConfigStore> AppEnv<C> {
pub fn new(config: C) -> Self { Self { config } }
pub fn port(&self) -> u16 {
self.config.get("PORT").and_then(|s| s.parse().ok()).unwrap_or(8080)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_injection() {
let app = AppEnv::new(MemoryConfig { val: "9000".into() });
assert_eq!(app.port(), 9000);
}
}
Technical Explanation
- Decouples environment variable lookup from application logic.
- Supports memory test fixtures.
Exercise 3: Generic Email Notification Service
Scenario: Implement a notification service injecting a Mailer trait.
Requirements:
- Define
Mailertrait. - Inject mailer.
Answer
Implementation
pub trait Mailer {
fn send(&self, to: &str, msg: &str) -> bool;
}
pub struct TestMailer;
impl Mailer for TestMailer {
fn send(&self, _to: &str, _msg: &str) -> bool { true }
}
pub struct NotificationManager<M: Mailer> {
mailer: M,
}
impl<M: Mailer> NotificationManager<M> {
pub fn new(mailer: M) -> Self { Self { mailer } }
pub fn notify(&self, user: &str) -> bool { self.mailer.send(user, "Welcome!") }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_notification() {
let mgr = NotificationManager::new(TestMailer);
assert!(mgr.notify("alice@example.com"));
}
}
Technical Explanation
- Achieves zero-cost compile-time dependency injection.
- Enables seamless unit testing.
6. Related Terms
- Trait Bound — Trait bound constraints.
7. Key Takeaways
- Decouples component dependencies via traits.
- Enables unit testing via mock implementation injection.
- Supports zero-cost compile-time static dispatch (
<D: Trait>). - Avoids dynamic dispatch vtable overhead.