Simple DI
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 Traitto 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?
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));
I think we've got two issues to look at here:
- Defining implementations with abstract dependencies
- 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.
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.