A different dependency injection in Rust

Iron chain with rust with the sun peeking through one of the links
Photo by Miltiadis Fragkidis / Unsplash

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:

// Action is the trait in question
trait Action {
    fn execute(&self) -> Result<(), anyhow::Error>;
}

// MessageAction represents a message to be sent to a specific user
struct MessageAction {
    message: String,
    user: String
}

// Implementing Action for MessageAction
impl Action for MessageAction {
    fn execute(&self) -> Result<(), anyhow::Error> {
        // do the things
        // then return
    }
}
A small setup to explore

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:

trait Action {
    //                v---------------------v This is the new part
    fn execute(&self, client: reqwest::Client) -> Result<(), anyhow::Error>;
}
Same trait, but now client passed in as a parameter

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:

struct MessageAction {
    // v The newly added field
    client: reqwest::Client,
    message: String,
    user: String
}
MessageAction struct, now with 50% more fields

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:

// Note the parentheses around the type
//         here v                         and here v
impl Action for (ChatMessageAction, reqwest::Client) {
    fn execute(&self) -> Result<(), Error> {
        let (action, client) = self;
        // use the client to do stuff with the action
        todo!()
    }
}
Crazy to see parentheses in places we don't expect them to be

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:

struct ActionWithClient {
    client: reqwest::Client,
    action: ChatMessageAction
}
<3 reqwest

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:

fn test() -> Result<(), Error> {
    let message = ChatMessageAction {
        message: "hello there".to_string(),
        user: "general kenobi".to_string(),
    };
    let client = reqwest::Client::new();

    // First create the struct
    let struct_version = ActionWithClient { client, message };

    // Then call the execute method
    struct_version.execute()?;

    // redeclaring because Rust.
    let message = ChatMessageAction {
        message: "hello there".to_string(),
        user: "general kenobi".to_string(),
    };
    let client = reqwest::Client::new();

    // Pass in `client` as the second element, and call the execute method
    (message, client).execute()
}
Does Rust allow &nbsp; as a function name? Thankfully no.

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):

// HttpClient is a trait that represents a client that can make HTTP requests
trait HttpClient {
    fn get(&self, url: &str) -> Result<(), Error>;
    fn post(&self, url: &str, body: &str) -> Result<(), Error>;
}

// Implement HttpClient for reqwest::Client
impl HttpClient for reqwest::Client {
    fn get(&self, url: &str) -> Result<(), Error> {
        todo!()
    }

    fn post(&self, url: &str, body: &str) -> Result<(), Error> {
        todo!()
    }
}
Ignore the specifics of this trait. It's there as an example, not as a production grade abstraction of a http client

After creating a custom trait, and also implementing it for reqwest::Client, we're ready for the last piece of the puzzle

impl<T> Action for (ChatMessageAction, T)
where
    T: HttpClient,
{
    fn execute(&self) -> Result<(), Error> {
        let (action, client) = self;
        // use the client to do stuff with the action
        todo!()
    }
}
<3 todo! macro

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:

trait Message {
    fn message(&self) -> String;
    fn user(&self) -> String;
}
Message is a message and a user. You can tell I'm running out of useful examples here

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.