(Optional) Dependency Injection/Inversion with Axum

As of now we added our state directly into our web project. For greater projects it usually makes sense to abstract details away. In the end it should not matter, how your state is stored. It might be an SQL like database, a file, or something else.

In the end you are only interested in "storing" your state. But how - is a matter of detail, that we are now going to abstract away.

Currently, we have this dependency tree (left) - but we want to invert the dependency (right):

img.png

Here you do not have to code anything - we just wanted you to show, how a production style rust webserver could look like. The code for it is in snippets/injection. You can run the unit tests with cargo test, or run the webserver by cargo run.

Turning around dependencies

Our goal would be to turn around the dependency, such that our business logic does not depend on the implementation of the storage. Rather it should depend on an interface. The implementation then should depend on that same interface as well. This way, we can decouple our business logic from the implementation of our storage.

In Rust you usually achieve this behaviour with Traits.

First we define our trait, that's our interface layer between the storage implementation and anything else (e.g. our business logic):

#![allow(unused)]
fn main() {
#[async_trait]
pub trait Repository {
    async fn add_user(&mut self, user: &UserToCreate) -> User;
    async fn remove_user(&mut self, id: i64) -> Option<User>;
    async fn get_users(&self) -> Vec<User>;
}
}

Next, we can then implement a struct, that wraps our details and implements the above trait. We implement an SqliteAdapter - wrapping an SqlitePool and implementing above interface.

#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct SqliteAdapter {
    pool: SqlitePool,
}
}
#![allow(unused)]
fn main() {
#[async_trait]
impl Repository for SqliteAdapter {
    async fn add_user(&mut self, user: &UserToCreate) -> User {..}
    async fn remove_user(&mut self, id: i64) -> Option<User> {..}
    async fn get_users(&self) -> Vec<User> {..}
}
}

How do you now use this trait in axum handlers?

To make use of our new trait, we have to make them known to our handlers (where we usually have our business logic). Instead of using a struct inside our State extractor, we can use the trait with static dispatch (generics):

#![allow(unused)]
fn main() {
pub async fn get_users<T: Repository>(State(repo): State<T>) -> impl IntoResponse {
    Json(repo.get_users().await)
}
}

Now our inner logic (turning things into a Json), does not depend on the implementation, but rather on the interface, that promises to return a list of users.

As axum heavily depends on generics, it's a bit harder to tell the compiler, that we want to make use of this trait. Structs, that implement our trait, also must implement more traits to be used in a "generic" router.

To be correct they must implement: Clone + Send + Sync + 'static. For example our Arc<Mutex<HashMap<_,_>>> would implement all of these above. For our SqliteAdapter Send + Sync is implement be the wrapped SqlitePool. This is why have to declare our SqliteAdapter to by Clone. Our structures are automatically 'static, as we will create them in the beginning of our program (see chapters below) - and we guarantee, they outlive all places where they are used.

TL;DR: Our router (that defines the routes, and uses our handlers) needs more guarantees to be generic We have to make more guarantees, so we can use the interface we defined. A generic router looks like this now:

#![allow(unused)]
fn main() {
pub fn routes<T: Repository + Clone + Send + Sync + 'static>() -> Router<T> {
    Router::new()
        .route("/users", get(get_users::<T>).post(add_user::<T>))
        .route("/users/:id", delete(remove_user::<T>))
}
}

How (and where) do you dependency inject the implementation?

After we have a trait layer between our storage implemention, the next question is, where do we now inject our SqliteAdapter into our program?

The answer is rather simple. We just give our with_state(...) method in the router builder our SqliteAdapter. The compiler is happy, as SqliteAdapter meets all guarantees, we promised our compiler.

We inject like this:

#![allow(unused)]
fn main() {
let pool = Sqlitepool:new()...;// create an sqlitepool from sqlx
let adapter = SqliteAdapter::new(pool).await;
let router = Router::new().merge(user::routes()).with_state(adapter);
}