A different dependency injection in Rust
I just blew my own mind, accidentally. This is a short little thingamabob about injecting dependencies. But not like that.
I come from a primarily Java background. I dabbled in Go and currently write a bunch in Python for backend and js/ts for frontend.
Yet I still carry with me the way Mr. Martin (a.k.a. Uncle Bob) taught us all, in his Clean Code books - to inject the dependencies, any means necessary. Either directly to the class, as a constructor parameter, or through setters, or some other shenanigan. Use Spring Boot for all we care, just inject the dependency.
Not saying that this isn't possible in Rust. In fact, it's easy. Let's say, for the sake of an example, you have an Action
, which has an execute
method. And you'd like to, well, execute the action when it's called. But what do you do when you need to use some service or client as part of that execute
method call?
Let's talk concretions:
Now, let's say that we need to send a http request as part of the MessageAction.execute()
call. How do we pass a certain client in?
We could do:
But what if not all actions need a client? Or some actions need a database connection?
We could add the client to the MessageAction
itself:
Which, works, yes, in a way. But we're now mixing up data (message
and user
) with functionality (client
). We could iterate on the idea a bit more - create a new struct, or an enum, which has the client and the action itself, but that's pretty much the same "mixing data with functionality", just with more steps.
We could also create a tuple. An unnamed tuple. Kind of. See for yourself:
Meaning.. we're implementing the trait on a tuple where the first element is the ChatMessageAction
, and the second is our http client, and then we destructure "self" to two variables - the action and the client.
But wait, you may be thinking to yourself, how is this better than having a struct like ActionWithClient
, like:
One word: boilerplate.
The less I need to type, the happier I am. Provided that the readability of the code does not suffer because of that. Because then I get grumpy.
Observe the difference:
At this point you may still be trying to figure out the benefit of all. In essence, we're still only taking reqwest::Client
as the second element in the tuple, why's that good for us?
Enter generics (and some boilerplate, but the useful kind):
After creating a custom trait
, and also implementing it for reqwest::Client
, we're ready for the last piece of the puzzle
There's some syntax to unpack, but generally speaking, it implements Action
for all two-element tuples, where the first element is a ChatMessageAction
, and the second is any type T
that implements HttpClient
. This includes reqwest::Client
(because we implemented HttpClient
for it manually), and anything else we may implement.. For example for testing purposes, we could have a SpyClient
which records the calls it gets to be later inspected. We could also implement that in a test module which does not get compiled when we ship our binary.
We could get even further with this, if we want to make it as generic as possible, by introducing another trait:
And then implement this for our different message types. Then, in the end:
impl<M, T> Action for (M, T)
where
M: Message,
T: HttpClient,
{
fn execute(&self) -> Result<(), Error> {
let (action, client) = self;
let message = action.message();
let user = action.user();
// use the client to do stuff with the action
}
}
And that is the ultimate form, right there. Implement Action
for tuples where the first type implements Message
, and the second one implements HttpClient
.
We could, of course, add other traits, like DatabaseClient
, or DevNullClient
, and implement Action
for those as well.
Of course, 99% of the power here is coming from generics themselves. But that 1%, that we can actually implement traits (or even just "normal" functions) on tuples directly, is mind blowing.