Using Dry::Container for Dependency Injection

  • Ruby
  • DryRb

The point of this post isn't to convince you of the usefulness of Dependency Injection there's been plenty of pixels spilled about it already. Instead, I want to talk about using Dry::Container to alleviate some of the pain points that DI introduces.

The first problem is when one object calls another, and both are using DI. For example, say you have a Command object that calls and Adapter object that finally calls the Client object. You end up with long chains of DI objects being injected into objects at a higher level, and it quickly becomes unwieldy, particularly for testing. Imagine we have something like this:

class MyCommand
  attr_reader :adapter

  def initialize(adapter: MyAdapter.new)
    @adapter = adapter
  end

  def call(args)
    # do stuff
    adapter.do_something(params)
  end
end

class MyAdapter
  attr_reader :client

  def initialize(client: MyClient.new)
    @client = client
  end

  def do_something(params)
    # do stuff
    client.make_request(url, json)
  end
end

class MyClient
  attr_reader :http

  def initialize(http: HttpClient.new(timeout: 5))
    @http = http
  end

  def make_request(url, json)
    http.auth(user, pass).post(url, json: json)
  end
end

In an integration test, you want to set up a mock client for the Client object to use, so it doesn't make any real requests. A typical solution is involves complicated test setup:

# my_command_spec.rb

RSpec.describe MyCommand do
  let(:http)    { instance_spy(HttpClient) }

  let(:client)  { MyClient.new(http: http) }
  let(:adapter) { MyAdapter.new(client: client) }
  let(:command) { described_class.new(adapter: adapter) }

  it "should make an http call" do
    command.call(:send_message)
    expect(http).to have_received(:post).with("http://myapp.example/send_message",
                                              json: { "text": "Hello!" })
  end
end

In our integration test for MyCommand, we have to set up a whole lot of other intermediate objects, just so we can inject the spy in at the lowest level. It seems strange that the test for this high-level business object needs to care about the low-level details about how the client is calling our HttpClient. Additionally, we probably have different things using the Adapter or MyCommand themselves, and the tests for those will need the same setup. Then, if we do any refactorings around how the Command -> Adapter -> Client pattern is set up, we'll have to come fix the setup for all these tests, which becomes tedious and error-prone.

Another alternative would be to set up all the intermediate objects to allow http to be injected, and pass it all the way through to the thing that cares about it.

class MyCommand
  attr_reader :adapter

  def initialize(adapter: MyAdapter, client: MyClient, http: HttpClient.new(timeout: 5))
    @adapter = adapter.new(client: client, http: http)
  end
end

class MyAdapter
  attr_reader :client

  def initialize(client: MyClient, http: HttpClient.new(timeout: 5))
    @client = client.new(http: http)
  end
end

class MyClient
  attr_reader :http

  def initialize(http: HttpClient.new(timeout: 5))
    @http = http
  end
end

This isn't great either, because now all the outer objects have to pass through a thing they don't care about at all. Its also easy to loose track of them, which object needs which dependency. Also, if MyCommand's job is to decide which of 5 Adapters it needs to send the message, it has to have 5 different clients injected.

The other issue I have with Dependency Injection is that once you start using it, it makes sense to use it for everything. The problem with that, however, is that you quickly run into very long and noisy #initialize methods:

class MyCommand
  attr_reader :http, :logger, :instrumenter, :error_handler

  def initialize(http: HttpClient.new(timeout: 5),
                logger: Rails.logger,
                instrumenter: ActiveSupport::Notifications,
                error_handler: Honeybadger)
    @http = http
    @logger, @instrumenter, @error_handler = logger, instrumenter, error_handler
  end
end

I found that nearly every object I had was injecting that triplet of [:logger, :instrumenter, :error_handler], which got fairly tedious. While this maybe could be resolved with a small object like what's used for Primitive Obsession, I don't have a good name for that object.

These two problems are particularly exacerbated when you need to pass through a mock logger or instrumenter, and test the calls made to that. Now you need to inject a whole lot of unrelated things, some of which the object doesn't care about, and it gets messy quickly.

Dry::Container

First, lets take a look at what a Dry::Container looks like:

module MyApp
  module Container
    extend Dry::Container::Mixin

    register(:error_handler) { Honeybadger }
    register(:instrumenter)  { ActiveSupport::Notifications }
    register(:logger)        { Rails.logger }

    namespace(:clients) do
      register(:http)   { HttpClient.new(timeout: 5) }
      register(:github) { Octokit::Client.new(login: config.github_user, password: config.github_password) }
      register(:heroku) { PlatformAPI.new(token: config.heroku_token) }
    end
  end
end

To use the values within a Container, its fairly simple, you can treat the container like a Hash:

http = MyApp::Container["clients.http"]
http.get("https://myapp.example/")

We can inject them into our objects be referencing them through the container, rather than directly:

class MyClient
  attr_reader :http

  def initialize(http: MyApp::Container["clients.http"])
    @http = http
  end

  def make_request(url, json)
    http.auth(user, pass).post(url, json: json)
  end
end

This, when coupled with dry-container's stub feature, lets us avoid complex test setup or deep injection:

# in spec_helper.rb or something:
require 'dry/container/stub'
MyApp::Container.enable_stubs!

# In your test:
RSpec.describe MyCommand do
  let(:http) { instance_spy(HttpClient) }

  around do |example|
    MyApp::Container.stub("clients.http") { http }
    example.run
    MyApp::Container.unstub("clients.http")
  end

  it "should make an http call" do
    command.call(:send_message)
    expect(http).to have_received(:post).with("http://myapp.example/send_message",
                                              json: { "text": "Hello!" })
  end
end

Aside: In our app, we have that wrapped in a helper: stub_container(MyContainer, http: fake_client) { ... }. Inside the block its stubbed, then un-stubbed when the block ends.

Auto-inject

A separate gem, Dry::AutoInject can work with our containers to help eliminate the boilerplate when injecting many dependencies into a class. You can set it up in your container:

module MyApp
  Import = Dry::AutoInject(Container)
end

Then, in your objects:

class MyCommand
  include MyApp::Import[:logger, :instrumenter, :error_handler]
  include MyApp::Import["clients.http"]

  def call(args)
    instrumenter.instrument("MyCommand.call") do
      logger.info("doing stuff")
      http.post("https://myapp.example/")
    end
  end
end

It looks a bit strange, but essentially include MyApp::Import[:foo] is a macro that generates code like:

attr_reader :foo

def initialize(foo: MyApp::Container[:foo])
  @foo = foo
end

When you have a lot of dependencies to inject, it really cuts down on the boilerplate. One thing to make a note of, however, if you have non-DI args passed to your #initialize method, you have to remember to call super.

class MyCommand
  include MyApp::Import[:logger, :instrumenter, :error_handler]

  def initialize(user:, **deps)
    @user = user
    super(**deps)
  end
end

Hopefully you find this helpful, we certainly have. Dependency Injection is a powerful tool that makes organizing and testing code a much cleaner experience, and dry-container and dry-auto_inject are a nice bit of polish over some of the tedious or boilerplate parts that come with it.