Inversion of Control Patterns
2024-07-10Managing complexity is one of the hardest problems in developing software systems. Luckily, with Inversion of Control we have a powerful tool at our disposal. However, I find that it’s not generally well understood and people tend to shy away from it. Let’s talk about where you’d even use it: Architectural boundaries.
An architectural boundary lets you cut a software system into two parts where one doesn’t know about the other and is thus not going to be affected by changes to it. Boundaries are the only thing that can decouple local complexity from global complexity. If you want to make large software systems easy to work with, you’ll want to have them.
A visual way to describe boundaries is as lines you draw in an architecture diagram that are only crossed by dependencies in one direction.
This restriction of unidirectionality can not generally be imposed on control flow though. The control flow in the same application may look like this:
We need a way to have dependencies that oppose the direction of control flow. That’s where Inversion of Control comes in.
Patterns
Note that the kind of dependency we care about here is a static reference.
No Inversion
# a.rb
require 'b'
def do_something()
# ...
.do_something_else()
b
end
- explicit relationship between A and B
- traceable statically from A to B
➡️ Great default
Full Decoupling
# a.rb
require 'c'
def do_something()
# ...
.thing_was_done()
c
end
# b.rb
require 'c'
def initialize()
.register(self)
c
end
# c.rb
def thing_was_done(payload)
.each do |listener|
listeners
.thing_was_done(payload)
listener
end
end
- implicit relationship between A and B
- traceable statically, indirectly, inconveniently
- similar to a generalized event bus
➡️ Use only when you need to achieve very low coupling
Plugin Mechanism
# a.rb
def do_something()
# ...
thing_was_done()
end
def thing_was_done()
.each do |listener|
listeners
.thing_was_done()
listener
end
end
# b.rb
require 'a'
def initialize()
.register(self)
a
end
def thing_was_done()
# ...
end
- explicit relationship between A and B
- traceable statically from B to A
- if in the same repository, all plugins can be easily found by grepping
➡️ The cheapest way to establish a boundary with bidirectional control flow
Caveats
Inversion of control does obscure the control flow to a degree. While the call stack is preserved in exceptions, traces and logs since everything still happens synchronously in-process, “who calls who” is a lot less obvious from reading the code.
Therefore, we shouldn’t aim to resolve all boundary violations through Inversion of Control; often there are more straightforward solutions. But Inversion of Control is a useful tool that should be applied in some cases, and it serves well to illustrate the advantages of unidirectional boundaries.