Skip to content
rusthacks.dev
Architecture

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.

3 min read
Summarize in AI
Flat illustration for the post “Dependency Inversion With Rust Traits Without Overdoing It”, composed around generics.

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

rust
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

rust
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> }
Dispatchstaticone indirect call
Type visible in signaturesyes, spreads to callersno
Choose implementationat compile timeat run time, from config
Requirementsnonetrait 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

rust
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.

Also inGenerics
Taggeddi
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.