Make auto-register return classes instead of `.new` objects?


#1

I’ve got dry-system wired up, and it appears that by default auto-registered objects injected by Import are created by calling new on the class name, with no arguments. However, that is not always what I want.

There are two cases, in particular, that I have questions about.

  1. The constructor of the injected class requires one or more arguments, and the arguments should be provided by the class into which it’s being injected. How is this case supposed to be handled? If we imagine a constructor with 2 arguments, the first of which is specified at runtime and contains the argument we need to pass to the constructor of the 2nd argument, then what we want is for the class itself to be injected as the 2nd argument, and then we would be able to new that class with the information from the first argument. There may be other solutions too…
  2. I am injecting a module of pure static functions. That is, I want the module itself to be injected – I don’t want to call new on it.

Both of these cases would be simple enough to solve if I were configuring the container by hand user register. But what is the correct way to solve them while also using auto-register?


#2

Hi, @jonahx.

Thanks for posting the question. I think an easy solution to this problem is to configure the auto_registeredobjects.

For the both scenario class requires one or more arguments, and the arguments should be provided by the class into which it’s being injected and I want the module itself to be injected – I don’t want to call new on it you could configure that all of the auto_registered would return the constant using the auto_register!(path) configure block:

class Application < Dry::System::Container
  configure do |config|
    config.root = Pathname(File.dirname(__FILE__)).parent
  end

  load_paths!('lib')
  auto_register!('lib') do |config|
    config.instance do |component|
      component.loader.constant
    end
  end
end

When passing a block to auto_register! method will yield a configuration object that responds to instance and exclude when calling any of the methods they will yield the component itself. The instance method is used in the register phase of the container

The exclude will be used for determining if a component should not be registered.

The loader part that you see it is used for loading the component


If all you component works this way, you could provide your custom loader to avoid having to use the auto_register configure block.

Hope this helps with your question, if you have more please do not hesitate to ask :smile:


#3

@GustavoCaso,

Thanks for the reply. That worked!

Couple followup questions:

  1. Where is this feature documented in the specs, if it is?

  2. Note that because auto_register! takes only one argument, I had to call it multiple times in a loop – is there a better approach?

Jonah

require 'dry/system/container'

class Application < Dry::System::Container
  configure do |config|
    config.root = Pathname(File.dirname(__FILE__)).parent
    config.auto_register = ['domain', 'lib', 'use_cases']
  end

  load_paths!('domain', 'lib', 'use_cases')
  ['domain', 'lib'].each do |dir|
    auto_register!(dir) do |config|
      config.instance do |component|
        component.loader.constant
      end
    end
  end
end

Application.finalize!

#4

Hi @jonahx

Great glad that it works.

  1. The specs for this are located here:
  1. Maybe we could make auto_register! to accept multiple dir like config.auto_register

BTW I think you do not have to config.auto_register and auto_register! twice the domain and lib folders in this case only in the auto_register! method because you need custom auto_register behavior.


#5

Returning constants defeats the purpose of having a container that resolves objects for you. The whole point of using objects returned by a container is to decouple your code from details like object instantiation.


#6

@solnic, Thanks. So what is the proper way to deal with the problems I raised in the OP? In particular, the question I raised in 1. in the OP?

EDIT:

To clarify my question above, I’m essentially confused by this:

  1. On the one hand, we want the container to create all our objects for us, as you said.
  2. The container’s resolve method takes only a key, so that it cannot instantiate objects whose constructors take arguments.
  3. On the other hand, clearly some objects require arguments to construct.

Obviously I’m missing something quite simple here or else there is a miscommunication. Thanks for any clarifications.


#7

If your objects are legitimately abstract “services” or things that need to become dependencies of others, I’d suggest redesigning (or wrapping) them so they don’t accept this runtime data via their initializers, and instead via instance methods.

If for some reason they must end up with the runtime data going into an object’s state, then you could follow the pattern of making it so the runtime options are optional arguments to the initializer, and then you could supply a #new instance method that can accept the runtime args, merges them with any existing state, and returns a new instance with the merged args passed to self.class.new.

As for your second example, the module of static methods, since this is indeed static, and not something you’d ever want to be an abstract, substitutable service or dependency, there’s no need to use the container at all. Just require the file and refer to the concrete module name.


#8

If your objects are legitimately abstract “services” or things that need to become dependencies of others, I’d suggest redesigning (or wrapping) them so they don’t accept this runtime data via their initializers, and instead via instance methods.

Yeah, that would be easy enough. I see that this is the pattern you’ve followed in the dry-transaction examples, and I’m doing something similar:

create_user = CreateUser.new
create_user.call("name" => "Jane", "email" => "jane@doe.com")

I think the way I was approaching this just wasn’t in harmony with the how the libraries work. I’m going to rework it and see if it improves.

As for your second example, the module of static methods, since this is indeed static, and not something you’d ever want to be an abstract, substitutable service or dependency, there’s no need to use the container at all. Just require the file and refer to the concrete module name.

This is a good point. And probably it means that it shouldn’t be static, even if they are library functions, because I might want to swap them out with some other implementation at some point.


#9

@timriley,

One quick followup question after sleeping on this:

Since based on the above conversation, the best practice with container-created objects is for them to have 0 argument constructors, and accept arguments through a call method (or something similar) if they need to, the way the CreateUser example does, where do objects like dry-struct objects fit in?

Clearly their constructors take an argument… are they not candidates for container construction? And if not, what is different about them? Doesn’t an object that returns a Dry::Struct, for example, still have a dependency on it?

To take a similar example from inside CreateUser here:

def process(input)
    Success(name: input["name"], email: input["email"])
end

Here we clearly have a dependency on Dry::Monad.Success, but our dependency is hardcoded.

I’m not saying any of this is wrong or even that it looks odd to me… I’m just trying to understand the principle that determines which kinds of objects are appropriate for container injection and which are not.

Thanks.


#10

I’m not saying any of this is wrong or even that it looks odd to me… I’m just trying to understand the principle that determines which kinds of objects are appropriate for container injection and which are not.

DI should be used for object composition exclusively. You will never compose objects from things like data structures or result objects. It’s good to think about these as types. Every specific data structure is a specific type, same with result objects, you have success and failure types. It’s also the reason why I suggested assigning data schemas from dry-v to constants, and treating them as types, in a similar way we have types from dry-types.

We gotta document this properly somewhere…


#11

@solnic,

Thanks for the explanation. That makes sense.

To be sure I have it right, would it be accurate to say:

  1. It’s ok to have “hardcoded” dependency to “types.” For example, inside a repo it’s okay to have code like return SomeDomainObject.new(blah, blah, blah) because SomeDomainObject here is essentially just a type. We don’t inject these objects.
  2. If you have an object that performs computation – whether that be db inserts, validation, or some complex mathematical calculation – then that object should be injected, if it’s being used by another object.

Let me know if I’m still mistaken in some way, or if you’d adjust the way I phrased that.


#12

This sounds correct :slight_smile: