Injected Dependencies best practices

This seems to come up often because of how dry-container encourages DI so seamlessly, but I’m frequently running into this pattern, and I’m wondering if there might be a tool to make it simpler. First a slightly contrived code example of what I’m talking about:

class MyClient
  include MyContainer::Import[:http]

  def initialize(credentials:, **deps)
    super(**deps)
    @auth_http = http.basic_auth(credentials.user, credentials.password)
  end
end

class MyAdapter
  include MyContainer::Import[:client]

  def initialize(model: model, **deps)
    super(**deps)
    @client = client.new(credentials: model.credentials)
  end
end

class MyCommand
  include MyContainer::Import[:adapter]

  def call(a, b, c)
    model = Model.find_by_a(a)
    adapter = adapter.new(model: model)

    adapter.do_something(b)
    adapter.do_something_else(c)
  end
end

I like this structure of code, because the Client only has to care about “credentials”, the Adapter works on getting the credentials from the Model, and the Commands just pass the Model to Adapter.

However, the tension lies in testing this mess. I want to “pyramid/integration-test” the whole thing, to ensure changes to the Adapter doesn’t interfere with the Commands, and its easier to just have shared “fake client” between all the tests. However, because what I’m injecting is classes instead of instances, what I struggle with is how to inject the right thing at the right time. I end up having the mock both the class and the instance:

let(:client_class) { class_double(MyClient, new: client) }
let(:client) { instance_double(MyClient, mocks...) }

before { MyContainer.stub(client: client_class) }

That client_class double feels like a smell, but I’m not quite sure what to do about it. Times like this, I really wish ruby had partial function application, so I could do something like this:

client = MyClient.inject(http: fake_http)  # Returns a "partial class" with the
adapter = MyAdapter.inject(client: client) # dependency injected but not initialized

command = MyCommand.new(adapter: adapter)
command.call(1, 2, 3) # This finally calls `.new` on all those "partial classes"

This seems like something up the Dry.rb team’s alley, so I thought this would be as good a place as any to ask. Has anyone come across anything like this, or tools/patterns that might help?

I think dry-auto_inject and dry-system both attempt to solve this problem. I think they call .new by default on the things you Import , so you wouldn’t have to worry about the class_double part in your specs. You can create instance_doubles as normal, then in your tests manually call new on the object under test.

1 Like

I don’t think that’s true, unless maybe you’re talking about some new unreleased version?

class MyClass
  def foo
    "hello"
  end
end

module MyContainer
  extend Dry::Container::Mixin
  Import = Dry::AutoInject(self)
  register(:my_class) { MyClass }
end

class MyOtherClass
  include MyContainer::Import[:my_class]
  def call
    my_class.foo
  end
end
pry(main)> MyOtherClass.new.call
NoMethodError: undefined method `foo' for MyClass:Class
Did you mean?  for
from (pry):14:in `call'

Ah, my apologies, that’s obviously incorrect for dry-auto_inject.
dry-system's Auto Register feature must be what does it.

Given that you have a dependency that in order to be instantiated needs data from the database, this is really not a good fit for auto-inject. I wouldn’t inject classes and couple objects to how their deps (an adapter in this case) are instantiated. You could have something that hides adapter instantiation and use that in your objects. I suppose we could have a feature in auto-inject that would make this easier (or in dry-system).

@solnic Noodling on this over the weekend, I was inspired by a custom addition we’d made to Dry::Transaction:

def self.call(*args)
  self.new.call(*args)
end

This allows us to inject Transactions into other Transaction both as a class or as an instance:

class TxnB
  include Import[:txn_a]

  step :do_b 

  def do_b(a:, b:)
    txn_a.call(a: a).bind(do_b(b))
  end
end

# Invoke normally:
TxnB.call(a: a, b: b)

# In a test:
TxnB.new(txn_a: -> (**) { Success() })

# or
import_container.stub(:txn_a, TxnA.new(http: FakeHTTP))

Similar to this, I think I’m going to monkeypatch AutoInject to create a .setup method, which would return an anonymous class with the default dependencies changed to those specified in .setup:

adapter = MyAdapter.setup(client: fake_client)

MyCommand.new(adapter: adapter).call(...)

Since MyAdapter and MyAdapter.setup(...) both return something that can be .new-d, in the tests we can provide either as a dependency and the code will be content.

I plan to add this to our app this week, and we’ll see how it feels to use.

1 Like

The implementation turns out to be pretty simple.

    def self.setup(**deps)
      mod = Module.new
      mod.module_eval do |mod|
        mod.define_method :initialize do |*args, **kwargs|
          super(*args, **kwargs.merge(deps))
        end
      end
      Class.new(self) do
        prepend mod
      end
    end