Dependency Injection in Rust
How to use traits to keep your code aloof, in plain english
Who is this for?
- Rust developers who have a some experience with the language and are curious about taking the next step in robustness
- Non-Rust developers who are curious about Rust patterns
What will be covered?
- Recap of traits and their use
- Using traits to isolate (and substitute) dependencies
Why should you care?
Dependency injection allows us to write code that treats its dependencies like black boxes, knowing nothing about the nitty gritty details. This quality allows us to easily test and future-proof our applications! For example, if we want to interact with a database we would prefer:
over:
They may look similar but there’s an important distinction between accepting a loose trait vs. a hard struct: substitution.
In the former example, do_stuff
will happily use any type that implements the Database
trait, and in the latter, do_stuff
will only accept the Database
struct.
Traits — A recap
Traits are a name given to a group of functions that a data structure can implement.
For example, one of the traits from the standard library, Default
, says “Anyone who implements me has a default value”
This says “In order for a type to implement Default, it needs to provide a function called default
that takes no arguments and returns an instance of itself” (The Self
type here is a special type that refers to whoever’s implementing Default
)
For example, the default value for bool
is false
:
And the default value can be used like so:
What if you want to write a function that only cares about the input implementing a trait?
To answer this question, we’ll rely on the Debug
trait that says “I can be formatted in a printable way for easy debugging”
Say we want to write a logger function that accepts anything Debug
-able and prints it to standard output — there are a few ways of accomplishing this:
logger_impl
and logger_generic
are almost identical, and for most applications they mean the exact same thing.
logger_impl
says “give me a t
of some type that implements Debug
”
logger_generic
says “for any type T
that implements Debug
, give me a t
of type T
”
They both allow the compiler to replace impl Debug
/ T
with the type you pass to it — for example if you call logger<bool>(true)
it’s as if you wrote a fn: fn logger(t: bool)
— there is no performance penalty.
logger_dyn
is a little more special, this says “give me a t
that points to some value on the heap that implements Debug
.”
The compiler will know nothing about the specific type being used, and has to add a little bloat so the code can figure out where to look in memory for the trait functions.
This comes with a minor performance penalty, but would allow you to write a version of logger that accepts a collection of different types that all implement Debug;
Cool, now we’re speaking the same language and can move on to dependency injection!
Isolating dependencies with Traits
Let’s say we’re writing an application that should:
- Get calendar events for the day from Outlook and Google Calendar
- Create a daily summary of events from all sources
- Send the summary to you via SMS, Slack, and Discord
Let’s start with getting calendar events by tying together Outlook and Google calendar under a common Calendar trait:
Now, the “Event Summary”:
Next, tie together the notification channels (SMS, Slack, Discord) under a common Notifier trait:
Now our main
function needs to create the hard implementing types, and call out to a separate function isolated from the implementations:
Not only is our code clean, handling all calendars and notification channels the same way, but our business logic is now completely isolated from the implementations!
Testing run
would be as simple as:
Feel free to ask any questions about using traits effectively, and follow me for a follow-up story on extending this pattern with the AppState pattern!