This technique his heavilly inspired by Tom Dalling's Dependency Injection Containers vs Hard-coded Constants
article. In particular, it proposes an alternative implementation of his "Hybrid Approach" using a build
class method.
- It introduces a new way of instantiating objects via the
.build
method. This new API requires other developers to be aware that they must use.build
if they want an instance of the object with default dependencies. - Following from the point above, in cases where we want to use cascading default dependencies, we must remember to use the dependencies
.build
method to benefit from their default behaviour.
This approach overrides the class' .new
method to provide the same behaviour with a less surprising API.
class UserRegistrator
def self.new(**deps)
deps[:validator] ||= UserValidator.new
deps[:repo] ||= UserRepository.new
super(deps)
end
attr_reader :validator, :repo, :other_arg_with_no_default
def initialize(validator:, repo:, other_arg_with_no_default:)
@validator = validator
@repo = repo
@other_arg_with_no_default = other_arg_with_no_default
end
def call(params)
if validator.validate(params)
repo.save(params)
else
false
end
end
end
# Usage with default dependencies
UserRegistrator.new(other_arg_with_no_default: :foo).call(name: 'Foo')
# Usage with injected dependencies
ru = UserRegistrator.new(validator: OtherUserValidator, repo: OtherUserRepository, other_arg_with_no_default: foo)
ru.call(name: 'Foo')
# Testing
let(:validator) { instance_double(UserValidator, validate: true) }
let(:repo) { instance_double(UserRepository, save: true) }
subject { UserRegistrator.new(validator: validator, repo: repo, other_arg_with_no_default: :foo) }
This approach works like this:
- We override
.new
to use the injected dependencies or set the defaults if they were not provided. super
calls Ruby's actual.new
method, which internally calls the instance's#initialize
.
For reference, roughly speaking, .new
works like this in Ruby:
class Class
def new(*args, &block)
obj = allocate
obj.initialize(*args, &block)
obj
end
end
Dependencies can also use dependency injection with defaults. The classes that use these dependencies don't need to be aware of an alternate method for initializing them with defaults.
class UserValidator
def self.new(**deps)
# A dummy default dependency just to show the point of nested dependency construction
deps[:subvalidator] ||= OpenStruct.new(validate: true)
super(deps)
end
attr_reader :subvalidator
def initialize(subvalidator:)
@subvalidator = subvalidator
end
def validate(params)
subvalidator
# ...
true
end
end
class UserRepository
def self.new(**deps)
# A dummy default dependency just to show the point of nested dependency construction
deps[:user_relation] ||= OpenStruct.new(insert: true)
super(deps)
end
attr_reader :user_relation
def initialize(user_relation:)
@user_relation = user_relation
end
def save(params)
# Do something
user_relation.insert
true
end
end
- It is a bit more challenging to understand what is going on (But the API just works as expected).
- We can no longer read the
default arguments
as we are used to reading them in Ruby since they are not part of theinitialize
method's signature or body. - It opens the possibility for having 2 places for
default arguments
:.new
and#initialize
.
This came out of some great discussions with Phillip Waldorf and Tim Riley.