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.
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…
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?
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
Where is this feature documented in the specs, if it is?
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!
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.
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.
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.
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:
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.
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 CreateUserhere:
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.
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.
To be sure I have it right, would it be accurate to say:
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.
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.