The Many Levels of Dependency Injection

The moral of this story is not about software design at all.

David Barral
Trabe
3 min readJun 20, 2022

--

Photo by Volodymyr Hryshchenko on Unsplash

Some months ago I had a friendly debate with a friend about dependency injection and testing in Javascript. We started from different points but end agreeing in the middle ground. This debate reminded me, once again, that when trying to solve problems no solution is perfect. There are always pros and cons, and, most of the time, is in that middle ground where the best solution may lie.

The purist approach

We were talking about functions that implemented some use cases and needed to access data repositories. His design was extremely purist. He chose to explicitly inject the dependencies as an additional parameter (bear in mind that the following code snippets are simplified for the sake of brevity).

The use case function:

The client code:

And, the tests:

Although we did not intend to use different repositories in our client code, we could do so with ease. Also, passing explicit dependencies decoupled the code and made the tests straightforward. This is truly a very purist approach to dependency injection.

The pragmatic version

I favored a less verbose approach, using hard require dependencies.

My use case function:

The client code, which is simpler and does not need to know that the use case function requires a repository.

And finally, the test. It assumes that the require mechanism is a de facto dependency injector, that can be either be used conditionally: require(cond ? “some-module” : “another-module”), or tinkered with using the test runner infrastructure (like this example using Jest mock support).

Not a beauty but it does the job.

My friend disliked a lot overriding the require mechanism, and I cannot blame him. However, I’d rather have ugly tests and simpler code.

Pros and cons

Regardless of my personal preferences, I truly think that there’s no best approach here. Each one has its own pros and cons. I’d happily work with either of these designs.

The purist approach allows changing the repository in the client code easily. It’s pure dependency injection. Also, it makes the tests isolated and explicit: test phases are clearer.

The pragmatic approach simplifies the client code and reduces the API surface (by using require instead of parameters). The problem is the coupling between the use case and the repository, which makes the tests harder to write. Also we are tied to the test runner support for mocking, and we have to deal with shared mocks from test case to test case (restoreAllMocks before each test).

Refactor wise, both options also have different pros and cons, with no clear winner IMHO.

Given these two options, most of the time, I would end up choosing by pure personal preference. And I won’t be wrong.

The middle ground

There’s a third option though: take the best of each version. Just pass all dependencies, but use default values.

Now, the client code:

And the test:

This third approach has both the simple client code and the straightforward tests.

Both parties agreed that this solution was acceptable. No hard feelings 😄.

Summing up

I guess most experienced developers have had this kind of debate at least once in their career. Being a purist vs being pragmatic. Sometimes their choice or conviction needed to change overtime (even several times back and forth). Finding the middle ground seems the best approach, in the end 😄.

The moral of this story is not design related. It’s not about right or wrong design choices. It’s about having friendly debates with fellow developers, and learning from them. That’s what’s important.

--

--

David Barral
Trabe

Co-founder @Trabe. Developer drowning in a sea of pointless code.