de-centralized service locator pattern

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.

No comments:

Post a Comment