SKIP TO CONTENT

How To Do Dependency Injection In Rust With Static Dispatching

Dependency injection (DI) is a common design pattern that tech companies use to increase the flexibility, maintainability, and testability of their software. Long-standing, industry-standard languages such as Java have a number of frameworks solely for this purpose, such as Spring, Guice, and Dagger.

However, in Rust, dependency injection is not an easy pattern to implement, at least not in a "Rust-like" way. Most, if not all, libraries and examples achieve DI through dynamic dispatching (trait objects). They also typically do not offer a solution for, in my opinion, one of the best features of DI frameworks - the ability to configure environment-specific dependencies at compile time (i.e. production dependencies for prod environments, beta dependencies for beta environments).

I'm sure many people have attempted to create their own statically-dispatched DI solutions by using traits and generics, only to abandon the excursion after realizing how much verbosity is demanded by Rust's strict type system:

struct Dependencies<A, B, C, D, E, F, G> {
    a: A,
    b: B,
    c: C,
    d: D,
    e: E,
    f: F,
    g: G,
}

fn program_entry_point<
    A: DependencyA,
    B: DependencyB,
    C: DependencyC,
    D: DependencyD,
    E: DependencyE,
    F: DependencyF,
    G: DependencyG,
>(
    dependencies: Dependencies<A, B, C, D, E, F, G>,
) {
    do_the_thing(dependencies);
}

I was close to giving up myself. However, just as I was about to compromise and use trait objects, I thought of a last-minute idea that actually worked and solved the usability issues I was facing with vanilla traits and generics. This idea (more precisely, a combination of ideas) is what I will be sharing in this post.

Learn By Example

If you are someone who learns best by reading code rather than reading about concepts, take a look at the example I posted on GitHub of how to do dependency injection in Rust natively without 3rd party crates and without dynamic dispatching.

Starting With The Naive Approach

Anyone who has tried to tackle this problem likely started with the same idea - use traits to create interfaces for dependencies, and use trait-bound generics to pass dependencies to functions:

trait FooDependency {
    fn bar();
}

// `impl` is syntactic sugar for trait-bound generics: `fn do_the_thing<F: FooDependency>(foo: F)`
fn do_the_thing(foo: impl FooDependency) {
    foo.bar()
}

However, this approach has some glaring problems.

Problem 1

Swapping out a dependency requires a code change. This approach does not allow us to define different dependencies for different environments:

trait FooDependency {}

struct ProdFoo;
impl FooDependency for ProdFoo {}

struct BetaFoo;
impl FooDependency for BetaFoo {}

fn main() {
    let prod_foo = ProdFoo;
    let beta_foo = BetaFoo;

    do_the_thing(prod_foo); // How do I change this to `beta_foo` for beta environments?
}

Problem 2

Depending on how you try to solve problem 1, using functions with trait-bound generics can lead to longer compile times and a larger binary file. Rust will statically resolve these generic functions through monomorphization:

"This means that compiler stamps out a different copy of the code of a generic function for each concrete type needed."

So something like this:

fn do_the_thing(foo: impl FooDependency) {}

Could become:

fn do_the_thing_prod_foo(foo: ProdFoo) {}

fn do_the_thing_beta_foo(foo: BetaFoo) {}

Now imagine you have 20 dependencies, each dependency has 3 different implementations, and most of your functions require all 20 dependencies. The amount of extra-generated code can get out of hand quickly.

Problem 3

Passing dependencies as function parameters can lead to bloated function definitions:

fn program_entry_point(
    a: impl DependencyA,
    b: impl DependencyB,
    c: impl DependencyC,
    d: impl DependencyD,
    e: impl DependencyE,
    f: impl DependencyF,
    g: impl DependencyG,
) {
    do_the_thing(a, b, c, d, e, f, g);
}

Naturally, we can attempt to fix this issue by creating a container struct to hold all the dependencies. But if we try using generics, it will not solve the bloat problem:

struct Dependencies<A, B, C, D, E, F, G> {
    a: A,
    b: B,
    c: C,
    d: D,
    e: E,
    f: F,
    g: G,
}

fn program_entry_point<
    A: DependencyA,
    B: DependencyB,
    C: DependencyC,
    D: DependencyD,
    E: DependencyE,
    F: DependencyF,
    G: DependencyG,
>(
    dependencies: Dependencies<A, B, C, D, E, F, G>,
) {
    do_the_thing(dependencies);
}

We could try fixing the bloat problem by using trait objects, but that means we are now locked into dynamic dispatching:

struct Dependencies {
    a: Box<dyn DependencyA>,
    b: Box<dyn DependencyB>,
    c: Box<dyn DependencyC>,
    d: Box<dyn DependencyD>,
    e: Box<dyn DependencyE>,
    f: Box<dyn DependencyF>,
    g: Box<dyn DependencyG>,
}

fn program_entry_point(dependencies: Dependencies) {
    do_the_thing(dependencies);
}

The Solution

The naive approach is actually quite good. In fact, it's likely how you'd achieve basic dependency injection in other languages where everything is dynamically dispatched. But when using Rust, do as the Rustaceans do.

We can achieve statically dispatched, compile-time dependency injection by clever use of Rust's associated types, feature flags, and export aliasing.

First, let's create a new trait that will act as the container that holds all of our dependencies:

pub trait Dependencies {
    // Note: The type name can be the same as the trait it is bounded to.
    //       They are named differently here to minimize any confusion.
    type DepA: DependencyA;
    type DepB: DependencyB;

    fn new() -> Self;

    fn dependency_a(&self) -> &Self::DepA;
    fn dependency_b(&self) -> &Self::DepB;
}

This is a trait rather than a struct so that we can create different containers for different environments. The associated types are how we avoid sprinkling generics syntax everywhere:

impl DependencyA for ProdDependencyA {}

impl DependencyB for ProdDependencyB {}

pub struct ProdDependencies {
    prod_dependency_a: ProdDependencyA,
    prod_dependency_b: ProdDependencyB,
}

impl Dependencies for ProdDependencies {

    // Look! We can assign concrete types, no trait objects!
    type DepA = ProdDependencyA;
    type DepB = ProdDependencyB;

    fn new() -> Self {
        // initialize dependencies..
    }

    fn dependency_a(&self) -> &Self::DepA {
        &self.prod_dependency_a
    }

    fn dependency_b(&self) -> &Self::DepB {
        &self.prod_dependency_b
    }
}
impl DependencyA for BetaDependencyA {}

impl DependencyB for BetaDependencyB {}

pub struct BetaDependencies {
    beta_dependency_a: BetaDependencyA,
    beta_dependency_b: BetaDependencyB,
}

impl Dependencies for BetaDependencies {

    // Look! We can assign concrete types, no trait objects!
    type DepA = BetaDependencyA;
    type DepB = BetaDependencyB;

    fn new() -> Self {
        // initialize dependencies..
    }

    fn dependency_a(&self) -> &Self::DepA {
        &self.beta_dependency_a
    }

    fn dependency_b(&self) -> &Self::DepB {
        &self.beta_dependency_b
    }
}

Now, we can use Rust's feature flags and export aliasing to make it so that there is only ever 1 dependencies container available to the program:

#[cfg(feature = "beta")]
mod beta_dependencies;

mod prod_dependencies;

#[cfg(feature = "beta")]
pub use beta_dependencies::BetaDependencies as ServiceDependencies;

#[cfg(not(feature = "beta"))]
pub use prod_dependencies::ProdDependencies as ServiceDependencies;
# Cargo.toml

[features]
beta = []

There are some minor gotchas with feature flags - see the notes in the example on GitHub for more details.

The cool thing about feature flags is that Rust will intelligently remove feature-specific code if that feature flag is not set. This means that no beta dependencies will ever be present in a production binary.

And that's it! Let's see what the usage looks like:

use crate::dependencies::ServiceDependencies;

fn main() {
    let service_dependencies = ServiceDependencies::new();

    execute(service_dependencies);
}

fn execute(dependencies: impl Dependencies) {
    let dependency_a = dependencies.dependency_a();
    let dependency_b = dependencies.dependency_b();
    // ...
}
cargo run
cargo run --features beta

If you need to swap out a dependency, just change type concrete type in the dependencies container:

impl DependencyB for ANewProdDependencyB {} // NEW IMPLEMENTATION

pub struct ProdDependencies {
    prod_dependency_a: ProdDependencyA,
    prod_dependency_b: ANewProdDependencyB, // UPDATED
}

impl Dependencies for ProdDependencies {
    type DepA = ProdDependencyA;
    type DepB = ANewProdDependencyB; // UPDATED

    // ...
}

Need to mock dependencies for unit tests? Don't use 3rd party crates like mockall, just create a new TestDependencies container!

#[cfg(test)]
mod tests {
    struct TestDependencies {
        test_dependency_a: TestDependencyA,
        test_dependency_b: TestDependencyB,
    }

    impl Dependencies for TestDependencies {
        type DepA = TestDependencyA;
        type DepB = TestDependencyB;

        // ...
    }

    #[test]
    fn test_execute() {
        execute(TestDependencies::new());
        // ...
    }
}

Recap

This solution is by no means a fully fleshed out dependency injection framework. But it does provide what I consider the 20% that accounts for 80% of the value of the DI design pattern. And we're able to do it in a more Rust-like fashion:

  1. No dynamic dispatching / trait objects
  2. Dependencies are fully determined at compile time thanks to static dispatching
  3. We can configure different dependency containers for different environments - no code changes, no extraneous code in production binaries
  4. No generics-cluttered, overly-verbose function definitions
  5. No 3rd party libraries

Thanks for reading! I hope you found this post useful and / or insightful.

If you have any questions, comments, or suggestions, feel free to drop a message below, or on the GitHub Discussions thread directly.

If you have a topic that you'd like me to write about, leave a comment on the topics thread, or message me at [email protected].