rust-ioc icon indicating copy to clipboard operation
rust-ioc copied to clipboard

Simple DI

Open KodrAus opened this issue 8 years ago • 3 comments

I'm playing with some ideas for a simpler DI approach that doesn't need any extra framework. The idea is to be nothing special.

Right now it's based on a few things:

  • impl Trait to avoid having to commit to a concrete implementation in the factory signature
  • The fact that functions can implement traits to avoid some boilerplate with abstract dependencies and generics on structures

This pattern works nicely for things like commands and queries where there's naturally only a single method.

It's limiting in that sense though. It would also be problematic for something like Rocket where you need a concrete type to inject through state. In this approach, your actual dependencies are anonymous, which also affects debugging if you want to view the type signature. It's going to contain anonymous closures.

So I think it needs more work. In general:

  • What about dependencies with more than a single method?
  • Related: what about injecting a concrete type as state?

KodrAus avatar Jul 18 '17 21:07 KodrAus

The intent is to separate injection from calling, which is pretty easy to do. It's just not very ergonomic. With functions, we can make things a bit simpler to compose, without leaking implementation details about what a particular thing is:

fn get_product_from_db(db: DbConn) -> impl GetProductQuery {
    move |query: GetProduct| {
        db.query::<Product>(some_sql, &[query.id])
    }
}

fn get_product_from_api(http: HttpClient) -> impl GetProductQuery {
    move |query: GetProduct| {
        http.get("/api/products/{:id}", query)?.json::<Product>()
    }
}

fn get_product_details<TGetProduct, TGetReviews>(product_query: TGetProduct, reviews_query: TGetReviews) -> impl GetProductDetailsQuery
    where TGetProduct: GetProductQuery,
          TGetReviews: GetReviewsQuery
{
    move |query: GetProductDetails| {
        let product = product_query.get_product(GetProduct { id: query.id })?;
        let reviews = reviews_query.get_reviews(GetReviews { productId: query.id })?;

        Ok(ProductDetails {
            product: product,
            reviews: reviews
        })
    }
}

fn some_api_route(http: State<HttpClient>) {
    // Inject: this is what IoC would do for us
    let query = get_product_details(get_product_from_api(http), get_reviews_from_api(http));

    let details = query.get_product_details(GetProductDetails { id: 123 })?;

    Ok(Json(details))
}

So the main benefit of this approach is ergonomics. It still has the problem of leaking implementation details to the caller though when constructing the query. That could be worked around through some more indirection, but too much and it starts to become difficult to see how things hang together.

If you're in a place where you have something like Rocket's state manager injecting route arguments then anonymous dependencies isn't going to work. I'll think about some alternative approaches using structures and trait objects.

We may have some external integration that needs to be mocked for a little while, so there are options for stubbing it:

let query = get_product_details(
    move |query: GetProduct| {
        // Stub out the query
        Ok(Product { ... })
    }, 
    get_reviews_from_api(http));

KodrAus avatar Jul 18 '17 23:07 KodrAus

I think we've got two issues to look at here:

  1. Defining implementations with abstract dependencies
  2. Constructing implementations with abstract dependencies

Using functions we have a novel approach to 1, but not so much to 2. I'd like to expand on that a bit when you're in the context of something like Rocket with state management, and when you're not. Is there some simple boilerplate you could reasonably expect someone to put together in their apps for hiding those implementation details? I think so. Off the top of my head:

struct Resolver {
    inner: RocketState // or FooState, or some TypeMap, or whatever
}

impl Resolver {
    fn get_product(&self) -> impl GetProduct {
        let http = self.get_http();

        get_product(http)
    }
}

...

impl Resolver {
    fn get_product_details(&self) -> impl GetProductDetails {
        let get_product = self.get_product();
        let get_reviews = self.get_reviews();

        get_product_details(get_product, get_reviews)
    }
}

Which can be peppered around in modules that contain the command or query or whatever.

KodrAus avatar Jul 19 '17 00:07 KodrAus

This is sort of service locator-y, where we have this one concrete type that everyone needs to know about. I think the issues raised against service locators are less of a thing with this approach because we still have clear separation of dependency resolution and construction from app logic.

You do still have the potential problem of magic runtime binding, but that's no different than forgetting to register something with an IoC container.

So I'm pretty happy with this approach and will flesh it out a bit more in a sample app.

KodrAus avatar Jul 19 '17 05:07 KodrAus