This is my take on how to get the most out of dependency injection.
NOTE before we start - by referring to DI (dependency injection) I'm referring to the pattern - not to any specific implementation. It's possible to do DI entirely manually although most often a library or framework is used where a centralized DI container manages the dependencies' lifecycles.
First of all - what is it?
- Dependencies are injected from external context e.g. instead of creating instances of other classes they're passed in via the class constructor (or in some cases private attributes - more on that later)
- Class focuses only on knowing its own implementation details and lets other components handle the lifecycle management for the dependencies instead of trying to do it myself
What are the benefits and how to use it?
- Makes it easy (or at least possible with sensible effort) to unit test the class
- This can be contrasted to the situation in many legacy projects where calls to static methods are made that further execute file or DB operations etc. and break if tried to execute without the fully set up context (without doing some pretty fancy test mocking)
- Explicitly define the dependencies of the class (or other similar abstraction) making it potentially more readable
- Facilitates good design - when used correctly promotes SRP (single responsibility principle)
- One of advantages of DI is making the dependencies explicit and clear. There's no point in listing ILogger as dependency of nearly every class - it's just clutter
When is it relevant?
- When invoking dependencies which have side effects (which for most traditional software projects written in C# / Java / etc. is most dependencies)
- It's the side effects which need mocking in the unit tests and that also need to be much more explicitly managed
- Meaning also that for code that is purely functional (does not have any side effects) using DI adds complexity without any payoff
Common problems with DI containers
- Easily makes code less readable by making it unclear where the call actually will get routed
- Sometimes creates "interesting" hard-to-find memory leaks when an automatically managed object's lifecycle has been slightly misclassified
Anti-patterns - How NOT to use it
- Do NOT create extra superfluous interfaces just because DI pattern demands it
- All good unit testing frameworks nowadays enable classes to be treated in a similar manner for mocking obviating the need for the interface
- Superfluous interfaces just make the code less readable, harder to jump between points in the code and harder to understand the overall flow
- Using property injection - pattern used by some DI frameworks e.g. Spring. Basically mandatory when dealing with EJBs
- This means that private variables of the class get set by the DI meaning that the class can be in an inconsistent state
- Unit testing essentially requires using the container in question making them more cumbersome, less explicit and understandable etc.
- Contrasted to constructor injection where after constructor call finishing successfully you can be quite sure the class stays in a consistent state (exceptions being cases where a direct dependency gets deprecated causing need to re-create the consumer object as well - this can be addressed via better design)
- And the worst: makes it nigh impossible to use immutable classes
- Breaking the classes into too small pieces without a driving design reason for it - and registering all the little classes in the container
- Easily ends up exposing APIs and classes that should actually be internal implementation details within a larger class encompassing a coherent sub-domain
So how to get the best use out of it?
- KISS principle - keep it as simple as possible
- Make everything as explicit as possible (while balancing it with the amount of boilerplate)
- Stay away from aspect oriented programming - it might be somewhat decent with good enough IDE support but I have yet to see it (you'll just create headache for yourself by making the functionality of your program less explicit)
- Mentioned because DI containers are often a way by which AOP is implemented
- Constructor injection only - and assign to private final
- Works very well with immutable / functional style of programming: https://contemplative-architect-journey.blogspot.com/2019/12/declarative-programming-simplicity.html
- Avoid any complex rules in DI setup - and avoid too much implicit logic ("it's magic")
- Never create an interface unless there's an explicit need for it from other reasons (i.e. there actually are multiple implementations for it or it adds very concrete value in being more understandable that way)
- And along with this - when you do need to use interfaces, use the most specific (i.e. avoid overtly generic ones like IDisposable unless that's explicitly required)
- Don't DI purely functional calls (although this requires certainty on which calls are like this and which have even potential side effects)
- Always aim for code you can understand just by looking at it - meaning that DI shouldn't cause surprises in how it injects a dependency. This is related to keeping the injection rules as simple as possible. Avoid spooky action at distance
- Do not ban creating instances outside of the DI framework (there will be immutable value and other classes, data classes, etc. which don't fall under DI anyway)
Some random insightful notes
- "If code has no side effects, DI is just useless complexity"
- "Should we create superfluous interfaces to satisfy the demands of the pattern? Absolutely not"
- Note that previously (working with monoliths) it was an issue that a large software component's all dependencies might easily get registered in the same central DI container causing leakage between sub-domain boundaries etc. With the rise of microservice architectures this is much less of a problem since a microservice itself is supposed to implement a specific sub-domain and be of the scale where it's very natural for (at least major) dependencies to be visible to each other within the implementation thus obviating the need to start considering segregation strategies for the DI container.
No comments:
Post a Comment