The Problem With Dependency Injection Frameworks
January 6, 2023
Over on his “Shade Tree Developer” blog, Jeremy Miller threw some shade1 my way after praising my recent update to the Testing Without Mocks: A Pattern Language article.
1See what I did there? Eh? Eh? …Yeah.
Of course, I’m going to ignore the nice things he said and focus on the criticism. He wrote:
Some time over the holidays Jim Shore released an updated version of his excellent paper Testing Without Mocks: A Pattern Language. He also posted this truly massive thread with some provocative opinions about test automation strategies:
I think it’s a great thread over all, and the paper is chock full of provocative thoughts about designing for testability. Moreover, some of the older content in that paper is influencing the direction of my own work with Wolverine. I’ve also made it recommended reading for the developers in my own company.
All that being said, I strongly disagree with approach the approach he describes for integration testing with “nullable infrastructure” and eschewing DI/IoC for composition in favor of just willy nilly hard coding things because “DI [i]s scary” or whatever.
—Jeremy Miller, “Automating Integration Tests using the ‘Critter Stack’”
“DI is scary or whatever?” DI IS SCARY? Oh, it’s on.
I Ain’t Afraid of No Ghosts
Let me be clear: I don’t have a problem with dependency injection. One of my most popular articles is “Dependency Injection Demystified”, and if you look at the Testing Without Mocks examples, you’ll see that dependency injection is used nearly everywhere.
No, my problem isn’t with dependency injection. My problem is with dependency injection frameworks.
The Cost of Third-Party Code
People think of “build vs. buy” as a simple decision: either I pay to build and maintain something myself, or I pay someone else to build and maintain it for me. Easy! Now I just need to worry about whether the dependency is part of my core competency or not. If it is, I build, because I need to be able to make changes easily. For everything else, I buy, so I can focus my time where it matters.
This view of build vs. buy is clean, pleasant, and 100% wrong.
The common view of build vs. buy is clean, pleasant, and 100% wrong.
You’re not comparing the cost of building and maintaining it yourself to the cost of buying it from someone else. You’re comparing it to the cost of buying it, learning it, working around not-quite-right behavior, keeping up with updates, and dealing with incompatibilities.
Every line of code in your system adds to your maintenance burden, and third-party code adds more to your maintenance burden than well-designed and tested2 code your company builds itself.
2Ay, there’s the rub. I’m assuming competence. That includes having more than one person who understands the code. (If your company isn’t competent, well, you know what you need to do.)
Your code is designed to handle the specific narrow case you care about.3 Third-party code is generic, making it harder to understand and use. Often, much harder.
Your code is updated on your schedule, and is built to integrate with the rest of your code. Third-party code is updated on others’ whims, and can break your existing code. Security-critical upgrades can result in “dependency hell:” cascading incompatibilities requiring you to drop everything as you scramble to make everything work together again.
There’s usually someone you can ask about how your code works. Third-party maintainers may not be interested in answering support requests, and you’ll be lucky if the documentation covers every obscure edge case.
Your code is maintained by full-time professionals. It’s as high-quality and secure as your company cares to invest.4 Third-party code is often maintained by part-time volunteers and students. It’s subject to catastrophic security holes, fits of pique, and supply-chain attacks.
3If it’s not, you’re doing it wrong. Build exactly what you need, and no more. Then evolve it as your needs change.
4There’s that rub again. But if your company isn’t willing to pay for security of your code, there’s no way they’re going to pay to properly secure third-party code.
Sometimes these costs are worth it! But it’s not the clean, simple, “only build core competencies” people would have you believe. Third-party code is expensive. Even when it’s free.
Magic Frameworks Are Bad
Frameworks have the problems of third-party code in spades. They tend to be these massive kitchen-sink things that have loads of edge cases and undocumented behavior. Because they’re so big, they have lots of security risks, and their reliance on plug-ins means that you’re sure to run face-first into dependency hell sooner or later. Not to mention the mutual finger-pointing that occurs when a set of plug-ins won’t play nice together.
But magic… magic makes it worse. “Magic” is when a framework does something automatically, in a cool way. It uses esoteric language features like monkey-patching and reflection to make you say, “wow!” It’s fun to use and makes for a great demo.
And then you do something wrong and your code doesn’t work and now the deadline is here and it still isn’t working and you have no idea Why. It. Won’t. Just. Work!
That’s magic. Sorta cool when it works. Absolute hell when it doesn’t.
That’s magic. Sorta cool when it works. Absolute hell when it doesn’t.
I need to know everything that’s going on in my code. I need simple, straightforward function calls. Nothing else! I want to be able to start at main()
and trace through the code. I want to look at callers and find where every parameter came from. I want to get a stack trace and see the line where I made a mistake. Reading code is hard enough already. Magic frameworks make it harder.
But again, sometimes these costs are worth it. Building magical frameworks is a favored pastime of senior developers. Chances are good that the “best” way to solve a common problem in your language involves a framework. Just… for the love of Bob… only use a magic framework if you don’t have another choice.
Dependency Injection Frameworks Encourage Bad Design
Now we get to dependency injection frameworks. Third-party code, check. Magic framework, check. And for what? To move object instantiation from one part of the code to another? From the place where it’s used to a distant, opaque blob? A blob that might not even be real code?
No, that’s uncharitable. Hard-coded dependencies are bad. Microsoft says:
Hard-coded dependencies, such as in the previous example [of a
Worker
class that depends on aMessageWriter
instance], are problematic and should be avoided for the following reasons:
To replace
MessageWriter
with a different implementation, theWorker
class must be modified.If
MessageWriter
has dependencies, they must also be configured by theWorker
class. In a large project with multiple classes depending onMessageWriter
, the configuration code becomes scattered across the app.This implementation is difficult to unit test. The app should use a mock or stub
MessageWriter
class, which isn't possible with this approach.—Microsoft, “Dependency injection in .NET”
Yep, that sure is terrible! Except it’s all a crock. Let’s dismiss the first and last points quickly:
“The
Worker
class must be modified.” As compared to what, changing some other file? Yeah, I’m fine with that. It’s called cohesion. Put the code where it’s used, not in some far off mess. (Seriously, have you seen the example code? Search forCreateHostBuilder
. Who reads this and thinks, “Oh yeah, this is so much better than callingnew
?”)“This implementation is difficult to unit test.” Horsepucky. You can still have dependency injection without a framework. Make a constructor that takes the dependency as an optional parameter. Done. Applause. Early lunch.
The big one is the second one. “If MessageWriter
has dependencies… the configuration code becomes scattered across the app.” You can’t just say new Worker()
. You have to say new Worker(new MessageWriter(myConfig))
. The deeper your dependency chain, the worse it gets.
Or does it?
When your code is hard to work with like this, it’s telling you something. It’s telling you that your design is bad and needs to be improved. The problems dependency injection frameworks solve are problems of bad design.
The problems dependency injection frameworks solve are problems of bad design.
If you have to construct massive dependency chains, you have poor encapsulation. Each class should unpack the configuration its given and pass just what is needed to the next level down. Callers should only worry about their direct dependencies.
If you have to pass the same dependency to every method, you have poor class responsibility design or poor application architecture. You shouldn’t need logging everywhere, for example. Your logic code should be side-effect free, and most of the rest of your code should be throwing detailed exceptions or returning errors rather than writing to the log and returning
null
.If you have huge lists of dependencies to pass in to a method, you have a data clump, and possibly the other problems too. Identify which dependencies belong together and combine them into a single class. Figure out which dependencies you don’t need and improve your design so you don’t have to pass them around.
Furthermore, dependency injection frameworks encourage you to think in terms of globals. That’s what they inject! A globally-configured instance of a class. Think about it. If you one day want two different instances of an injected variable, you’ll need an impact driver to express just how screwed you are.5 This has all kinds of knock-on effects in terms of reducing encapsulation and separating state from behavior.
5Yeah, yeah, yeah. You have a fancy DI framework. You can make named instances or whatever. But at what complexity cost? And how is that better than just passing a parameter?
Sure, Go Ahead
But that’s just like, my opinion, man.6
I get it. Good design is hard, and sometimes we’re stuck with code that’s, um, Not So Great. Engineering is tradeoffs, and there’s no one right answer when it comes to design. DI frameworks solve design problems at the cost of increased maintenance costs and lower-quality design. Manual dependency injection solves those problems, but at the cost of spending more time passing around variables and thinking about how to structure your dependencies.
Look, I’m a professional. I get the job done. Pretty much every client I work with uses a DI framework. I’m not going to do that passive-aggressive programmer thing where I sniff dismissively and refuse to work on any code that involves a DI framework. I know how to work with DI frameworks, I do it, and I’ll even admit they make life easier. The Testing Without Mocks patterns are designed to let you not have a DI framework, but they work just as well if you do.
But I also think they’re a crutch. Next time you start a new codebase from scratch, try it without a DI framework. You’ll have to spend a lot more time thinking about good design, and that’s a good thing. You might be surprised where it takes you.
As Brian Marick likes to say, “An Example Would Be Handy Right About Now.” You can find an example here.