Hacker News new | ask | show | jobs
by procrastitron 1213 days ago
Dependency injection frameworks are a horrible idea.

They optimize for the wrong thing; making code easier to write but harder to read and edit.

Writing code is a one time cost whereas reading and editing it is an ongoing effort, so dependency injection frameworks are optimizing for the uncommon case at the expense of the common case.

Compile-time dependency injection frameworks are a big improvement over runtime dependency injection frameworks because you can at least read the generated code, but they are still a lot worse than manual dependency injection when it comes to editing code.

8 comments

This has not been my experience with dependency injection frameworks in C# at all. You know what happens in a large code base when you tell all of the developers to do manual dependency injection? They don’t do it and instantiate their dependencies in the constructor because it’s easier.
So deterministic instantiation then, which I as a (predominately C#, currently) developer actually prefer - I find it much easier to follow unfamiliar code. But whatever work for your team ...
I work in a large code base where we've told all of the developers to use dependency injection frameworks.

We are now using a mixture of 3 different dependency injection frameworks, manual dependency injection, and instantiating dependencies in the constructor.

How is that a problem with DI, instead of a problem with the engineering team not enforcing coding guidelines and libraries?
I'd prefer what you describe, because I would still be allowed to fix it (change it to manual DI).

I usually write manual DI when I want to iterate on the system and make sure each part does what it's supposed to.

Then once it works I convert it to Joe Armstrong's gorilla-holding-the-banana-and-the-whole-jungle (framework DI) so it gets past code review, and I hope to never touch it again.

I recognise that DI frameworks may have a place when working with large teams and large applications, but otherwise I discourage them for a few reasons.

The prerequisite knowledge problem:

"Manual" DI requires knowing the language. Framework DI requires knowing the language and the framework. This alone makes it harder for new people to approach the codebase if they have not familiarity with the framework, even if they have mastered the language. There are already several DI frameworks for golang, but which ones are your new hires familiar with?

The "how do I do X" problem:

If you want to do something with your dependency graph, it's usually straightforward to express with plain code, but it might not be immediately obvious how to accomplish the same with the framework. For example, having multiple dependencies of the same type. With "manual" DI this is totally unremarkable. With wire, it goes against its mechanism and requires a minor workaround.

The implicit graph problem:

A DI framework the frees you from having to think much about the dependency graph may cause you to accidentally create a messy, illogical graph. It's easy to end up cornered by dependency cycles, for example. With manual DI, a flawed graph is more obvious early on because it's code you have to read and write. Explicit, not implicit.

It's a pretty big assumption to say DI makes code harder to read.

Personally, a decade and a half ago I felt like it was pretty easy to understand what I was working with. I'd hope observability has improved since, that there's been further improvement.

There's a horrible reputation for DI, people with old scars, that seems like there's no escaping from. But my workplaces had a great time using a variety of DI tools, again and again. We got great monorepo code sharing across numerous projects & easy to understand parts. My experience has been great. I want to keep asking, what would make folks reconsider their negative attitude?

For me, it would take at least one positive and significant experience.

I would probably need to work on a team where at least some of my teammates had some mastery of the framework, including knowing how to use it effectively in the long term.

Di is great. Di frameworks are usually not.
I could understand if the environment would depend on network loading speeds, and having to lazy load and cache/inject dependencies only when they are actually used (like in the Browser).

But with go as build environment this kind of contradicts everything that go is good at, so even after reading I'm asking myself for a legit use case for dependency injection in go (other than malware development or hijacking a loaded DLL of another program).

Not sure if I agree they’re horrible, but I think you should put it off until you really need it.

I disagree that they optimize for write: a DI is quite easy to real, but hard to debug. Adding @Inject(databaseHandle) makes it pretty clear you’re getting a handle to your database. That’s easy to read. When the database handle doesn’t work, suddenly it’s hard to debug.

I also disagree with others that it’s harder to learn. You can barely know a language and understand that a library is auto-magically injecting that database into that variable. New users can pretty quic Write code that relies on existing injections or copies other examples and it’ll usually work without flaw.

The problem with DI is when you exit the happy path: it’s really hard to diagnose what is failing, and the implicit ordering make it hard to figure out quickly. This could be worth it to reduce a ton of boilerplate on a large and mature monolith, or to speed up an agile team, but it could just give you one more thing to endlessly tweak and change.

I despise "magic" but DI has worked incredibly well for me for years. It almost always "just works". Even my homebrew DI framework I coded up in a couple of days worked extremely well. They're not that complicated. If you only have one instance of each object type it works nicely. If you need multiple then you need a bit of magic to pick one but even that isn't bad.
Thank you for this. I'd even say that dependency injection frameworks eliminate the most important thing of dependency injection: explicit dependencies.
But they're not though?

    class Foo(@Inject x: SomeType) 
Is still a class you can't instantiate without a correct instance of the given type.

(If you're talking about field injection, then I agree, that is the devil and must be purged with fire. Constructor injection or none.)

And when there's multiple instances of a given type available, a good framework refuses to guess. And I really like compile time DI for surfacing these errors far quicker than the traditional runtime approaches.

My problem is that 100% of the time, the chain of construction is as brittle as if you'd not used DI as all.

It's not that clear with Foo(SomeType). What I usually deal with is:

    AuthController(AuthService(UserService(UserRepo(Hikari(Postgres(..))))).
Now I just want to test that AuthService accepts/rejects a user based on whether they're in the system or not. I want to do something like:

    users = new Map<UserId, User>()
    auth = new AuthService(users::get)
> And when there's multiple instances of a given type...

(There should always be (in the case that you're writing a test for something))

> a good framework refuses to guess

I do too! I want to manually wire the classes inside main() in the usual case, and manually wire (different combinations) of classes in the tests.

> They optimize for the wrong thing; making code easier to write but harder to read and edit.

No, they optimise for making code easier to test.

Perhaps this wasn’t clear enough, but I was making an explicit distinction between dependency injection and dependency injection frameworks.

Dependency injection makes code easier to test, and I’m a big fan of dependency injection.

What I’m opposed to is the frameworks that automate (and often hide) the construction of the dependencies.

As someone else said in this thread “explicit is better than implicit”.

DI frameworks always make me deal with way more of the system than I want to.

I want to just be able to 'new' any class in the system, and start playing with it under test.

When I try to test a Parser class under a framework DI, it ends up instantiating the most fiddly bits of unrelated crap. I get null pointers in the database config, which had nothing to do with what I wanted.

It can be controlled, by adding even more annotations and mocking frameworks. But I'd rather take out annotations rather than put them in.

But you don’t have to do it that way? You can still manually instantiate your Parser in your test and have DI
I disagree that they make code harder to read. It's the opposite in fact in my experience, especially with the prevalence of mature IDEs that allow you to navigate the graph and injection points. It's quite a nice experience.