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:
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:
// `impl` is syntactic sugar for trait-bound generics: `fn do_the_thing<F: FooDependency>(foo: F)`
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:
;
;
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:
So something like this:
Could become:
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:
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:
We could try fixing the bloat problem by using trait objects, but that means we are now locked into dynamic dispatching:
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:
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:
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:
pub use BetaDependencies as ServiceDependencies;
pub use ProdDependencies as ServiceDependencies;
# Cargo.toml
[]
= []
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 ServiceDependencies;
If you need to swap out a dependency, just change type concrete type in the dependencies container:
// NEW IMPLEMENTATION
Need to mock dependencies for unit tests? Don't use 3rd party crates like mockall, just create a new TestDependencies
container!
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:
- No dynamic dispatching / trait objects
- Dependencies are fully determined at compile time thanks to static dispatching
- We can configure different dependency containers for different environments - no code changes, no extraneous code in production binaries
- No generics-cluttered, overly-verbose function definitions
- 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].