Hi Ian, great to hear from you! Been a long time.
It’d be good to make this nicer with legacy objects where #initialize
is used to set state with user-provided values. Our approach to support existing args (the one @flash-gordon shared above) is definitely unwieldy and it’s not nice to have to override #initialize
.
So I had a play
How does something like this seem to you?
require "dry-container"
require "dry-auto_inject"
# Set up the strategy
################################################################################
class AttrWriters < Module
ClassMethods = Class.new(Module)
InstanceMethods = Class.new(Module)
attr_reader :container
attr_reader :dependency_map
attr_reader :instance_mod
attr_reader :class_mod
def initialize(container, *dependency_names)
@container = container
@dependency_map = Dry::AutoInject::DependencyMap.new(*dependency_names)
@instance_mod = InstanceMethods.new
@class_mod = ClassMethods.new
end
def included(klass)
define_new
define_accessors
define_injector
klass.send(:include, instance_mod)
klass.extend(class_mod)
super
end
private
def define_accessors
instance_mod.class_eval <<-RUBY, __FILE__, __LINE__ + 1
attr_accessor #{dependency_map.names.map { |name| ":#{name}" }.join(', ')}
RUBY
self
end
def define_new
class_mod.class_exec(container, dependency_map) do |container, dependency_map|
define_method :new do |*args|
super(*args).tap do |obj|
obj.inject
end
end
end
end
def define_injector
instance_mod.class_exec(container, dependency_map) do |container, dependency_map|
# TODO: make name configurable
define_method :inject do |**args|
deps = dependency_map.to_h.each_with_object({}) { |(name, identifier), obj|
obj[name] = args[name] || container[identifier]
}.merge(args)
deps.each do |name, dep|
send :"#{name}=", dep
end
self
end
end
end
end
class Strategies < Dry::AutoInject::Strategies
register :attr_writers, AttrWriters
end
# Now set up some objects in our container
################################################################################
class MyOperation
end
class AnotherOperation
end
class MyContainer
extend Dry::Container::Mixin
register "my_op" do
MyOperation.new
end
register "another_op" do
AnotherOperation.new
end
end
Import = Dry::AutoInject(MyContainer, strategies: Strategies)
# Time to test things!
################################################################################
class Legacy
include Import.attr_writers[
first_op: "my_op",
second_op: "another_op",
]
def initialize(value)
@value = value
end
end
# Create an object with the default dependencies:
legacy = Legacy.new("hi")
p legacy
#<Legacy:0x007f82e39d51a8 @value="hi", @first_op=#<MyOperation:0x007f82e39d4fc8>, @second_op=#<AnotherOperation:0x007f82e39d4f78>>
# Now let's use the helper method to replace just one of the dependencies (you can see the first one is preserved!):
class ManualOp
end
legacy.inject(second_op: ManualOp.new)
p legacy
#<Legacy:0x007f82e39d51a8 @value="hi", @first_op=#<MyOperation:0x007f82e39d4a28>, @second_op=#<ManualOp:0x007f82e39d4b90>>
As you can see here, all the injections can be done via attr_writers (or in bulk via an #inject
method that takes a hash, much like we do by default with in our kwargs
strategy). This means that as user of this injection strategy doesn’t need to adjust their class’ #initialize
at all, and you still maintain the ability to replace dependencies manually if you need (like in the final line in that example). The only downside is that replacing the dependencies mutates the object’s state, which we don’t see as “clean”, but hey, like you say, this is an “escape valve” for legacy classes that we don’t have as much control over.
How does that seem to you?
It feels compelling enough to me that we could almost distribute it directly in the gem!