Dry-AutoInject with Dry-Initializer, order matters


#1

I was working with Dry-Initializer and with Dry-AutoInject and noticed that my injected dependency wasn’t getting populated. The field was there but the value was not. After some digging I discovered that the order that the injected dependency is loaded matters:

require 'dry_auto_inject_issue/version'
require 'dry-auto_inject'
require 'dry-container'
require 'dry-initializer'

module DryAutoInjectIssue
  class Container
    extend Dry::Container::Mixin

    register 'test' do
      'some string'
    end
  end

  Import = Dry::AutoInject(Container)

  class BrokenlyInjected
    include Import['test']
    extend Dry::Initializer

    param :arg1
  end

  class Injected
    extend Dry::Initializer
    include Import['test']

    param :arg1
  end

  class Workaround
    extend Dry::Initializer

    param :arg1
    option :test, default: -> { Container.resolve('test') }
  end
end
$ bin/console
2.4.2 :001 > DryAutoInjectIssue::BrokenlyInjected.new('hello').test
 => nil
2.4.2 :002 > DryAutoInjectIssue::Injected.new('hello').test
 => "some string"
2.4.2 :003 > DryAutoInjectIssue::Workaround.new('hello').test
 => "some string"
2.4.2 :004 >

I’m not sure if this is something that is known or should be fixed. But it was an interesting and unexpected behavior when using the system.


#2

Why do you need both dry-initializer AND dry-auto_inject, both provide constructors, so I’d recommend using one or the other, not both. In applications we don’t use dry-initializer at all, for me it’s a low-level lib (of course I understand that it could be used differently, as it’s quite flexible).


#3

I ended up not using both as it wasn’t working as expected and modification could easily break it’s functionality (opting for the Workaround class above).

For this specific case, I was toying with how to inject a Logger into small classes without needing to pass it around via args. The logging level was being controlled via a CLI but it was cumbersome (and not really the correct interface) to pass the logger reference around. However, other aspects of the constructor were not able to be implicitly injected so I was using initializer.

I honestly really enjoy using initializer for just about every class I create that does more than just hold data. If it just holds data I use a Dry::Struct::Value but in much of my work I create Functions as an Object and using the initializer to define the signature gives me control/safety around how the caller uses it. It also provides a really nice declarative interface with typings, defaults, and coercions that a normal method signature does not.

As for my specific use-case, a singleton is all I really needed. I wanted to see if dependency injection would solve my case because it’s a littler more explicit in terms of declaring the requirements for a class. It also shortens the call signature to a method on the callee. This means when logging I use self.logger vs TopLevelModule.logger. Another solution might have been a mixin to inject the logger method include TopLevelModule::Logger but that adds to the ancestor tree of a my class making it a “logger” when it’s not really. I personally spend a lot of times thinking about what each of my classes “are” and this seemed to violate that.

Perhaps there’s another solution to my problem that I haven’t considered. However, the ordering issue here was still slightly unexpected.


#4

If you have objects that hold data, then using DI is rather weird. DI is useful in objects that act as “services”, as in, they do something with objects that hold data. Mixing constructors that set data and accept external dependencies is not something that I’d recommend.


#5

I think you misunderstood me since it feels like you repeated what I was saying. If an object holds data I use Dry::Struct::Value. What you describe as a “service” object I think of as a “Function as an Object”. When I said

does more than just hold data

I meant to imply that every object holds some amount of data but if it has verbs attached to it (such as perform or run or compute) then I switch to the more semantic Dry::Initializer.


#6

As for the need to have required constructor params, it seems likely that not everything about what a “service” object needs can be injected, somethings may have runtime requirements and need to be passed in. I suppose in these cases it might make sense to make them arguments to the function on the service object:

class Test
  include Imports['dependency']

  def perform(arg1)
    sub_action(arg1)
  end

  private

  def sub_action(arg1)
  end
end

This is currently missing a clean declarative dry format. So I prefer to have them as part of the initializer right now. As a bonus the initializer makes them available without needing to do argument passing. If arg1 is a function argument I need to pass it to all subsequent method calls. With a single argument, this isn’t too bad, but for 2 or 3 it gets a lot less dry. When it’s initialized then it’s available within the object scope and is a lot cleaner:

class Test
  extend Dry::Initializer
  include Imports['dependency']

  param :arg1

  def perform
    sub_action
  end

  private

  def sub_action
    # do something with arg1
  end
end

I also get the value of self documenting code if that param can be defaulted or has a type. This is useful for some changes in behavior that aren’t connected to external dependencies. Or are essentially arguments on data to read/transform. In my usecase, it was creating a service object that took a file path and parsed the yaml data. The path is checked for format as a Dry::Types (named Types::Path in this case).

class Loader
  extend Dry::Initializer

  param :filepath, Types::Path

  def load!
    # Do something with the filepath
  end
end

But since this was connected with a CLI I was trying to wire up a logger to expose debug steps during it’s flow.

I ended up not using auto inject in this case but I am using a container to expose a singleton logger that can be referenced from other places. This was a dependency that I wanted injected but the service object still had arguments that it needed at run time (ie, cannot be injected). I may switch the logger to be a simple singleton using Ruby but I found the method signature to be wonky: MyNamespace.logger.debug { 'log message' }. By using autoinject, i was hoping for a cleaner call: logger.debug { 'log message' }. I may stop using containers for this as well as a Ruby Singleton as the default to an initializer generally saves the same problem.