All
hexagonal-architecture diagrams I've seen tend to look similar to this one. The external-adaptors live in the outer-ring and the application code
you're focused on lives inside. Conceptually, they are neatly separated.
One of the things I don't like about
Dependency Injection
is how it blurs this separation.
The external-adaptors living in the outer-ring have to be injected into the
application objects living in the inside. So are the external-adaptor objects still on the outside or not?
The words we're using suggest we just
injected them so now they are
in the
inside!
For this and other reasons I tend to avoid Dependency Injection.
But of course, I still need the objects on the inside to be able to communicate with the objects on the outside.
So cyber-dojo uses the
Service Locator pattern.
But it does
not use a central registry.
I prefer the connections they communicate over to be obvious and explicit.
And I prefer those connections to actually be connections that connect the outside and the inside together.
I feel the need for a Ruby example...
Suppose I have an inside Runner class that needs access to an outside Shell service
class Runner
def run(...)
...
stdout,_ = shell.assert_exec("docker run #{args} #{image_name} sh")
...
end
end
The Runner object locates the shell object using the nearest_ancestors mix-in:
require_relative 'nearest_ancestors'
class Runner
def initialize(parent, ...)
@parent = parent
...
end
attr_reader :parent
private
include NearestAncestors
def shell
nearest_ancestors(:shell)
end
end
module NearestAncestors
def nearest_ancestors(symbol)
who = self
loop {
unless who.respond_to? :parent
fail "#{who.class.name} does not have a parent"
end
who = who.parent
if who.respond_to? symbol
return who.send(symbol)
end
}
end
end
All objects know their parent object and nearest_ancestors chains back parent to parent to parent
until it finds an object with the required symbol or runs out of parents.
I can create a root object that simply holds the external-adaptors (eg shell).
Conceptually, this root object lives at the boundary between the outside and the inside.
require 'sinatra/base'
require_relative 'shell'
require_relative 'runner'
class MicroService < Sinatra::Base
post '/run' do
runner.run(...)
end
def shell
@shell ||= Shell.new(self)
end
def runner
@runner ||= Runner.new(self, ...)
end
end
All the internal objects can access all the external-adaptors.
I
love how trivial moving a piece of code from one class to another class becomes.
Another thing I love about this pattern is the effect it has on my tests.
require_relative 'shell_stubber'
...
class RunnerTest < MiniTest::Test
def test_runner_run_with_stubbed_shell
@shell = ShellStubber.new(self)
...
runner.run(...)
...
end
attr_reader :shell
def runner
@runner ||= Runner.new(self, ...)
end
end
-
In the MicroService class self refers to the MicroService object
which becomes the parent used in nearest_ancestors. Thus in runner.run,
shell resolves to shell inside the MicroService object.
-
In the RunnerTest class self refers to the RunnerTest object
which becomes the parent used in nearest_ancestors. Thus in runner.run,
shell resolves to shell inside the RunnerTest object.
The RunnerTest class effectively doubles as the top-level MicroService class and I create, and hold, my test doubles
locally. I find it greatly improves locality of reference and
habitability in general.