Dependency Inversion With Rust Traits Without Overdoing It
Depend on a trait that you own, not on a concrete client. Learn generics versus Arc dyn for wiring, when a trait is not worth it and how to keep it small.

Every codebase has a service that sends email, and every test of it either sends email or does not run. Dependency inversion is the fix: the service depends on trait Mailer, production passes an SMTP client, tests pass a fake. In Rust that takes ten lines. The harder part is not overdoing it. This post gives a minimal example, the injection choices, and the signs that a trait is not needed. It supports hexagonal architecture in organizing Rust projects.
Rust 1.90, edition 2024.
A trait the caller owns
pub trait Mailer {
fn send(&self, to: &str, subject: &str) -> Result<(), String>;
}
pub struct Welcome<M: Mailer> {
mailer: M,
}
impl<M: Mailer> Welcome<M> {
pub fn new(mailer: M) -> Self { Welcome { mailer } }
pub fn greet(&self, email: &str) -> Result<(), String> {
self.mailer.send(email, "Welcome")
}
}Mailer has one method, only what Welcome needs. An SMTP client with forty methods implements it with a small adapter. The trait is defined next to its user, which is what "inversion" means: the high-level module owns the interface.
A fake for tests
use std::cell::RefCell;
#[derive(Default)]
struct FakeMailer { sent: RefCell<Vec<String>> }
impl Mailer for FakeMailer {
fn send(&self, to: &str, subject: &str) -> Result<(), String> {
self.sent.borrow_mut().push(format!("{to}: {subject}"));
Ok(())
}
}
#[test]
fn sends_a_welcome() {
let w = Welcome::new(FakeMailer::default());
w.greet("[email protected]").unwrap();
assert_eq!(w.mailer.sent.borrow()[0], "[email protected]: Welcome");
}No network, no macro, and the assertion reads like the requirement. The tradeoffs against mock libraries are in Mockall versus fakes.
Generic or dyn
Welcome<M: Mailer> | Welcome { mailer: Arc<dyn Mailer> } | |
|---|---|---|
| Dispatch | static | one indirect call |
| Type visible in signatures | yes, spreads to callers | no |
| Choose implementation | at compile time | at run time, from config |
| Requirements | none | trait must be dyn compatible |
Generics are the default. The generic parameter spreads to every struct that holds a Welcome, which gets tiresome at the top of an application; there, Arc<dyn Trait> stops the spread. The decision is the same as in static versus dynamic dispatch.
Async
pub trait Mailer {
async fn send(&self, to: &str, subject: &str) -> Result<(), String>;
}This is legal since Rust 1.75 and works with generics. It cannot be used as dyn Mailer directly. If you need trait objects, return a boxed future or use the async-trait crate, as covered in the async hub async Rust and Tokio.
When to skip the trait
- One implementation, tested through the real thing (an in-memory SQLite, a local test server)
- A pure function that takes data and returns data, which needs no injection
- A trait that mirrors a concrete type method for method
Extract one when the second implementation appears. Changing a function to take &impl Mailer later is a small edit.
Limits
Traits cannot hide differences in semantics: a fake that always succeeds will not catch retry bugs. Keep a few tests against the real dependency. The wider design guidance is in clean architecture in Rust, and the Rust book covers trait objects in detail.


