Comparing Dependency Injection Libraries (`shaku`, `nject`, ...)

Hi,
I'm looking for dependency injection libraries to organize the code for a backend application into separate "module" (crates) to benefit from parallel compilation.
During the process I stumbled across shaku and nject. I tried both of them in a small axum webserver and although I was able to achieve dependency injection with both of them I'm confused about two things:

  • Why is nject claiming to be "zero cost"? How does it compare to shaku in regards to that?
  • I was able to achieve a similar dependency injection like I got with nject but without using nject at all (the only difference being that I had to access the dependency on the provider by its field name and not by simply calling .provide()). So what's the benefit of using nject?

Did anyone use these libraries before and can share some experiences with them?
Thanks a lot in advance :grinning:

1 Like

I've seen this problem most often supported by type-keyed maps (often called State<T>), it seems like shaku is making that a bit easier to initialize if you have complicated interdependence.

On the other hand nject seems to instead be "just" codegen for build methods: possibly useful if you have big fat singleton mutable state.

Unlike shaku, nject is by default entirely compile time, which means no overriding for tests, etc, but also it doesn't need to go through a boxed dyn Trait, making allocation and virtual dispatch respectively. The optimizer might be able to devirtualize and remove the allocation if it can see through the initialization code, but I wouldn't expect it. It shouldn't matter though unless you're really hammering those dependencies, though, eg getting up to millions of times a second.

3 Likes

Thank you @simonbuchan very much for the detailed answer. I really appreciate it :+1:
As I want to use traits (in a separate crate) as service interfaces to prevent direct dependencies between services, I need to use either &'a dyn Trait or Box<dyn Trait> anyways. Therefore I'd say that shaku using Box<dyn Trait> is not really a problem or do you see a benefit in using &'a dyn Trait over Box<dyn Trait>?

If all you want is to avoid source dependencies, not to make them runtime dependencies, you don't need any of these. You can use trait bounds in generics or the equivalent argument position impl Trait to avoid referencing them directly:

struct Foo<TBar: Bar> { bar: TBar }

...

foo.bar.use_bar()

This is just as flexible as either of those crates, shaku requires you to have Bar depend on the crate with the trait Foo, and nject doesn't allow cross module, let alone crate, dependencies, so your main crate would have to wrap all your crate types independently.

For maximal parallelism, you should look at using a "vocabulary" common crate, which has only the necessary types and traits for your other crates to talk to each other that they can all depend on.

Before doing any of this dependency wrangling, though, double check that you actually can get anything out of doing this with cargo --timings once you've done the simplest breakup into crates you can: cargo doesn't have to wait for a dependency to completely build before starting dependencies.

4 Likes

Thanks again for you quick reply @simonbuchan. I guess this...

...is what I experienced with nject and what I am confused about.
To better understand what I mean I implemented an example and published it on GitHub.
Here's a link to the repo and online VSCode. There's also a depgraph.png in the repo visualizing the dependency graph of the crates in the workspace.

When using nject in this workspace the only benefit I had was that in users/lib.rs I was able to use the .provide() method which nject implements for its providers (see the comment in the code). Other than that I was able to simply remove nject and everything just worked fine. This is what confuses me and made me reconsider if I really need nject.


When I try to use generics as you suggested I'd change the Provider in interfaces/lib.rs to

pub struct Provider<TUserSrv, TTenantSrv>
where
    TUserSrv: IUserService,
    TTenantSrv: ITenantService,
{
    pub user_service: TUserSrv,
    pub tenant_service: TTenantSrv,
}

But then I'd also need to change the signature of the router() function in users/lib.rs from

pub fn router() -> axum::Router<std::sync::Arc<Provider<'static>>> { ... }

to

pub fn router() -> axum::Router<std::sync::Arc<Provider2<UserService, TenantService>>> { ... }

which I cannot do because for this the users/lib.rs needs to import TenantService from the tenants crate which introduces an inter-service source dependency.


I really tried out a lot of things already (parallel front-end, mold linker, ...) and even split up some parts of the code into separate crates which helped a bit but the timings still show that there are three big crates in the workspace that depend on each other and cannot be compiled in parallel. For this reason I'm now trying to find the "perfect" project structure to minimize compile times especially reducing incremental build times because only parts of the code need to be re-compiled when everything is in a separate crate.