Dependency Injection in Rust With Traits and Generics
Rust needs no DI container. Define a trait for the dependency, take it in the constructor and wire the real types in main. Tests pass a fake instead.

Dependency injection in Rust needs no container, no annotations and no reflection. A struct that needs a clock, a mailer or a database takes it as a field, constructed by whoever builds the struct; tests pass in a fake, and production passes in the real one. The only decision is how the field is typed: a generic parameter bounded by a trait, a boxed trait object, or a reference. This post wires one service three ways, shows the compile-time and run-time trade-offs, writes the test that justifies the whole exercise, and ends with the place where the wiring should live. Everything compiled on Rust 1.99, edition 2024.
The problem: a function that reaches out
Here is a signup function that decides whether a trial has expired and sends a mail. It calls the system clock and prints to stdout directly, so there is no way to test the "expired" branch without waiting thirty days:
use std::time::{Duration, SystemTime};
struct Trial {
started: SystemTime,
email: String,
}
fn check_trial(t: &Trial) -> Option<String> {
let age = SystemTime::now().duration_since(t.started).unwrap_or_default();
if age > Duration::from_secs(30 * 24 * 3600) {
println!("sending mail to {}", t.email);
Some(format!("expired for {}", t.email))
} else {
None
}
}
fn main() {
let t = Trial { started: SystemTime::now(), email: "[email protected]".into() };
println!("{:?}", check_trial(&t));
}NoneTwo hidden dependencies, the clock and the mail output, are baked into the body. Injection means moving each one behind a trait and passing it in.
Step 1: name the dependencies as traits
Keep each trait small. A dependency with one method is easy to fake; a dependency with twelve is a mock library waiting to happen.
use std::time::SystemTime;
trait Clock {
fn now(&self) -> SystemTime;
}
trait Mailer {
fn send(&self, to: &str, body: &str);
}
struct SystemClock;
impl Clock for SystemClock {
fn now(&self) -> SystemTime {
SystemTime::now()
}
}
struct StdoutMailer;
impl Mailer for StdoutMailer {
fn send(&self, to: &str, body: &str) {
println!("mail to {to}: {body}");
}
}
fn main() {
let clock = SystemClock;
let mailer = StdoutMailer;
mailer.send("[email protected]", &format!("it is {:?}", clock.now().elapsed().is_ok()));
}mail to [email protected]: it is trueStep 2a: generics (static dispatch)
The default in Rust is a generic struct. The compiler monomorphizes it for each combination of types, so a call through self.clock.now() compiles to a direct call to SystemClock::now, with no vtable and often inlined.
use std::cell::RefCell;
use std::time::{Duration, SystemTime};
trait Clock {
fn now(&self) -> SystemTime;
}
trait Mailer {
fn send(&self, to: &str, body: &str);
}
struct Trial {
started: SystemTime,
email: String,
}
struct TrialService<C: Clock, M: Mailer> {
clock: C,
mailer: M,
limit: Duration,
}
impl<C: Clock, M: Mailer> TrialService<C, M> {
fn check(&self, t: &Trial) -> bool {
let age = self.clock.now().duration_since(t.started).unwrap_or_default();
let expired = age > self.limit;
if expired {
self.mailer.send(&t.email, "your trial has ended");
}
expired
}
}
struct FixedClock(SystemTime);
impl Clock for FixedClock {
fn now(&self) -> SystemTime {
self.0
}
}
#[derive(Default)]
struct RecordingMailer {
sent: RefCell<Vec<(String, String)>>,
}
impl Mailer for RecordingMailer {
fn send(&self, to: &str, body: &str) {
self.sent.borrow_mut().push((to.to_string(), body.to_string()));
}
}
fn main() {
let day = Duration::from_secs(24 * 3600);
let start = SystemTime::UNIX_EPOCH + 100 * day;
let svc = TrialService {
clock: FixedClock(start + 31 * day),
mailer: RecordingMailer::default(),
limit: 30 * day,
};
let trial = Trial { started: start, email: "[email protected]".into() };
println!("{}", svc.check(&trial));
println!("{:?}", svc.mailer.sent.borrow());
let early = TrialService { clock: FixedClock(start + 5 * day), mailer: RecordingMailer::default(), limit: 30 * day };
println!("{} {}", early.check(&trial), early.mailer.sent.borrow().len());
}true
[("[email protected]", "your trial has ended")]
false 0The test that was impossible before is now a handful of lines with no sleeping: a clock frozen thirty-one days after the trial started, and a mailer that records what it was asked to send. The fake mailer uses RefCell because the trait method takes &self; in async code or when sharing across threads it would be a Mutex. Production wires TrialService { clock: SystemClock, mailer: SmtpMailer::new(...), limit } and gets the real thing with no change to check.
Step 2b: boxed trait objects (dynamic dispatch)
Generic services spread their type parameters into every function that holds one. A struct with five dependencies has five parameters, and that gets heavy fast. If the cost of a virtual call is unimportant (a mailer sends over a network; one nanosecond is nothing), box the dependencies and the struct has no parameters:
use std::sync::Arc;
trait Mailer: Send + Sync {
fn send(&self, to: &str, body: &str);
}
trait Store: Send + Sync {
fn find_email(&self, user_id: u32) -> Option<String>;
}
struct Notifier {
mailer: Arc<dyn Mailer>,
store: Arc<dyn Store>,
}
impl Notifier {
fn notify(&self, user_id: u32, msg: &str) -> Result<(), String> {
let email = self.store.find_email(user_id).ok_or_else(|| format!("no user {user_id}"))?;
self.mailer.send(&email, msg);
Ok(())
}
}
struct Stdout;
impl Mailer for Stdout {
fn send(&self, to: &str, body: &str) {
println!("-> {to}: {body}");
}
}
struct Users;
impl Store for Users {
fn find_email(&self, user_id: u32) -> Option<String> {
(user_id == 7).then(|| "[email protected]".to_string())
}
}
fn main() {
let mailer: Arc<dyn Mailer> = Arc::new(Stdout);
let n = Notifier { mailer: mailer.clone(), store: Arc::new(Users) };
println!("{:?}", n.notify(7, "welcome"));
println!("{:?}", n.notify(8, "welcome"));
}-> [email protected]: welcome
Ok(())
Err("no user 8")Arc<dyn Trait> is the usual spelling for a dependency shared by many components or threads: cloning the Arc is cheap, and the Send + Sync supertraits make the whole service usable inside an async runtime or behind a web framework's shared state (the same trait-bound story as in Send and Sync). The compiler enforces that the trait is object-safe, rules in dyn compatibility: no generic methods, no Self returns.
The lightest injection: a closure
When a dependency is a single function, a trait is more ceremony than it is worth. The service can hold a closure instead, and the test passes one that returns a fixed value. This is the smallest seam there is, and it is how most Rust code takes the current time:
use std::time::{Duration, SystemTime};
struct RateLimiter<N: Fn() -> SystemTime> {
now: N,
window: Duration,
last: Option<SystemTime>,
}
impl<N: Fn() -> SystemTime> RateLimiter<N> {
fn new(now: N, window: Duration) -> Self {
RateLimiter { now, window, last: None }
}
fn allow(&mut self) -> bool {
let t = (self.now)();
match self.last {
Some(prev) if t.duration_since(prev).unwrap_or_default() < self.window => false,
_ => {
self.last = Some(t);
true
}
}
}
}
fn main() {
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
let offset = std::cell::Cell::new(0u64);
let mut rl = RateLimiter::new(|| base + Duration::from_secs(offset.get()), Duration::from_secs(10));
println!("{}", rl.allow());
offset.set(4);
println!("{}", rl.allow());
offset.set(11);
println!("{}", rl.allow());
}true
false
trueThe test moves a Cell instead of sleeping, so a ten-second window is tested in microseconds and the result is the same on a loaded CI machine. Production passes SystemTime::now as the closure, and a function item satisfies the bound without a wrapper. The closure form is a good start. When a second function joins it (a clock that can also sleep, say), promote it to a trait.
Injection in async code
The same ideas apply with two additions. Trait methods that return futures need either native async fn in traits, which is not dyn compatible, or boxed futures for trait objects; the choices are laid out in async traits then and now. And a shared dependency used from spawned tasks must be Send + Sync + 'static, which is why Arc<dyn Trait + Send + Sync> is the common field type in web services. In tokio tests, the paused clock (tokio::time::pause) removes the need to inject time at all for async code that uses tokio's own timers, which is one place the runtime provides the seam for you.
Where the wiring happens
Everything above leaves one question: who builds the real objects? The answer is a single function near main, often called the composition root, that creates the concrete types, connects them and hands the finished service to the rest of the program. It is plain code, so the compiler checks the wiring, and reading it tells you the whole dependency graph. A container library is almost never necessary. The structure of that function and why to keep construction out of the rest of the code is in the composition root.
Choosing between the two forms
Generic S<C: Clock> | Boxed Arc<dyn Clock> | |
|---|---|---|
| Dispatch | Static; can inline | Virtual call |
| Type parameters on the struct | One per dependency | None |
| Allocation | None required | One per dependency, at construction |
| Mixing implementations at run time | No | Yes |
| Compile time and binary size | More code per instantiation | One copy |
| Best for | Hot-path dependencies, libraries | Application services with many collaborators |
A rule that scales: libraries take generics (their callers decide), applications box their dependencies at the edges and keep inner code simple. The mixed approach also works. Inner hot code is generic over a cheap trait such as a hasher or a comparator, while the service layer holds Arc<dyn ...> for the big collaborators.
Fakes, mocks and how much of this you need
A fake with real logic (an in-memory store) is usually better than a mock that asserts call order, because it survives refactoring. If you want auto-generated mocks, mockall derives them for a trait, and the tradeoffs are compared in mockall versus fakes. The test doubles in this post were all hand-written in a dozen lines.
Not every dependency needs a trait. A pure function (a price calculation, a parser) has nothing to inject; call it. Standard-library items that are already deterministic, like Vec and HashMap, need no seam. The seams that earn a trait are the ones that touch the outside world or time: the clock, randomness, the filesystem, the network, the database. If a trait has exactly one production implementation and one test fake, and the fake is trivially simple, that is a good sign you drew the line in the right place. If you find yourself writing a trait for a struct with a single method that wraps a single other call, you are probably adding a layer for its own sake. The boundary design question is picked up in hexagonal architecture.


