I know that many of you are familiar with this subject. But what I'm going to say isn't about the right description of this pattern; I'm just extrapolating the name for another usage. Don't get me wrong, they are essentially the same concept, but as a principle, it has too much value way above the function definition, actually.
If a function does too many things, it may need dependencies to do them. What you usually do, when it applies, and that's always an “it depends" engineering thing, is give the control to the dependency instead of keeping it in the parent. That often works. I wanted to look at an example here, and since I come from the Node.js world, I can say that Express.js is the most dead-simple example of it. (I had to bake first because honestly, I wasn't sure at first that that was what Express middleware is) But I must say I stumbled on this idea again while reviewing an HTTP middleware chain in my current Golang project. In a nutshell, your code doesn't control the app lifecycle; the Express library does that for you, but what Express does is give you control over what to do with every request. This is the textbook composition you need a dependency to run your code (an http lifecycle engine) but instead of importing it and using it to handle your logic you compact your logic within a function and pass it to the library: “do not call us; we call you” the Hollywood principle they say (I found that sticky phrase while I debated my ideas with an LLM today).
So here is the new problem/concept about IoC that I want to share today: it's not always trivially found in function composition and arguments, but sometimes in architecture as well. You may have a fat method or class that does too many things, with some of that work handled by its components. It doesn't mean this is necessarily wrong. In fact, I prefer most deep modules to shallow ones, per John Ousterhout (A Philosophy of Software Design). But then I realize that when I see the pattern start to tumble down because some of the previous invariants no longer hold, the usual way to solve is to look at things from the outside in. It may work for you as well as it has worked for me the last couple of years.
I started digging into this just recently, when I realized how often I used to reflect on things that seemed wrong or smelled bad in code. So you may want to try once or twice and see if it works.
A concrete example: I got a basic auth interceptor that handles parsing the auth credentials regardless of the input channel and validates their authenticity using the appropriate method. Let's say we have three of them: cookie-based authentication, Bearer tokens, and full mTLS. Each one of them needs a different workflow and auth validation. I know this isn't the usual way to see it, because you usually get each one separately. But not when you have a distributed policy engine that handles every policy application point across the org. So you not only validate each credential but also ensure that the authenticated actor has the right permissions over each resource it will touch, and manage this centrally. When you describe it like that, it sounds easy to see the right way to put it, but that's not often the case when you start with a single client and then evolve into a more complex consumer architecture. You build on the march under tight deadlines and with a fear of breaking things, so you look for the minimal, easy thing to do and build on what is already there. That's nobody's fault or everybody's responsibility. There is no single developer in charge of that piece of code, but a team effort to keep things clean and legible. Sometimes not aligned with the product, certainly, and probably it requires a little bit of culture and self-awareness to keep it just like that. There is no way to keep it simple or a house clean without energy waste. That's the second law of thermodynamics, also known as the law of entropy. Even if you don't move or change anything, things around you that you or your app can't control change.
So, looking for ways to simplify code is always on my mind. This is just one of them: invert control, which gives your code a dependency on its dependencies when it becomes too bloated or hard to maintain. Do the opposite if you have too many dependencies and want to simplify and consolidate logic in one place. This is always the first way I start. Consolidate first, then diversify; monolith first, then microservices; orchestrate first, then choreography. Start simple and explicit, then become abstract and decouple. But that's just my vision, and you may have a different strategy to approach this yourself.
Have you ever encountered a 'fat' interceptor that tried to do too much? How did you decouple it? I’d love to hear your strategies in the comments!
Top comments (0)