Dependency Injection frameworks have existed for a long time, but Rubyists have traditionally avoided doing this, likely because it is perceived as being unnecessary complexity.
However, the "simplicity" afforded by not doing this has negative effects, as this document attempts to demonstrate. Ruby's flexibility allows us to approach DI in a much more friendly way than e.g. Java.
Frequently, parameters to an object are passed as a combination of initialization params and method arguments with no clear difference.
Here's a representative bit of imaginary code that follows this pattern:
class UserRegistration
def initialize(email, password)
@email = email
@password = password
end
def register
validate_email
hash_password
save_to_database
send_confirmation_email
end
private
def validate_email
validator = EmailValidator.new
raise "Invalid email" unless validator.valid?(@email)
end
def hash_password
@hashed_password = BCrypt::Password.create(@password)
end
def save_to_database
user_repo = UserRepository.new
user_repo.create(email: @email, password: @hashed_password)
end
def send_confirmation_email
mailer = ConfirmationMailer.new
mailer.send(to: @email, subject: "Confirm Email")
end
end@email and @password are initialized as instance state for this class. How often are we to assume these will change? Is it reasonable for these values to persist across multiple calls?
What if we look at the usage of this class in the controller and find:
def register_user
UserRegistration.new(params[:email], params[:password]).register
endWe can see from the invocation of this class that @email and @password comes from user-input, therefore this requires initializing the new value for every signup. The instance of UserRegistration is immediately discarded once the computation is finished.
Why should we set up persistent state in an object that is immediately discarded? This could be moved to a method argument for register with no logical change. @email and @password are arguments, not a dependency.
This class uses a couple external interfaces: EmailValidator, BCrypt::Password, UserRepository, and ConfirmationMailer. These are constants, and so do not change between calls. They are dependencies.
The constants are referenced directly in implementation code, and can't easily be replaced under test. So in order to write a test for this, we have to stub global API based on knowledge of the internal details of the object.
What if EmailValidator changes its rules in a way that breaks our test email? Suddenly our tests might fail for reasons totally unrelated to the behavior we're testing. We need a better way to replace these with test doubles.
Note
Dependencies are references that could be used multiple times during the lifetime of an object.
Arguments are one-time parameters that pertain to an individual request.
Ruby is object-oriented in design; every thing you interact with is an object. Classes exist to define a repeatable pattern for building objects of a particular type, but those class instances are treated as separate things.
When one object interacts with another, this creates a dependency relationship between them. We will call these coordinating objects. Managing complexity in a system is primarily about limiting these dependency relationships.
If coordinating objects may be referenced at any point within an object, visually determining the totality of its dependencies is more difficult.
Declaring all coordinating objects as explicit dependencies at the top makes visually determining a class's dependencies very easy.
class UserRegistration
def initialize(
confirmation: ConfirmationMailer.new,
digest: BCrypt::Password,
email_validator: EmailValidator.new,
user_repo: UserRepository.new
)
@confirmation = confirmation
@digest = digest
@email_validator = email_validator
@user_repo = user_repo
end
def register(email, password)
raise "Invalid email" unless @email_validator.valid?(email)
password_digest = @digest.create(password)
@user_repo.create(email:, password: password_digest)
@confirmation.send(to: email, subject: "Confirm Email")
end
endWe can now determine at a glance that this class depends on EmailValidator, BCrypt::Password, UserRepository, and ConfirmationMailer by looking in one place. It's not such a big change in this small example, but you've certainly seen larger classes where dependency usage is spread throughout. Think about how each pattern would scale, and which would be harder to comprehend.
Imagine that a requirement comes down that staff emails should use a stronger digest algorithm. Previously, this would have required changing the implementation. But all we would need to do is pass in a different dependency.
This construction allows us to decouple class initialization from its usage.
Note
Use initialize as the interface to inject dependencies for an object.
The validation behavior in this code should be tested in isolation, so in some cases you're going to want it to always pass or always fail.
In the original code, we cannot change this from the outside. So you'd most likely have to stub EmailValidator, which encodes knowledge of the implementation of this object into the test code itself. Test Behavior, Not Implementation
Now that we are injecting it, we can do this by passing in an alternate implementation:
RSpec.describe UserRegistration do
FakeValidator = Data.define(:status?)
subject(:register) do
described_class.new(email_validator: FakeValidator.new(status?: true))
end
endNote
Use injection to replace dependencies with test doubles.
Moving to Dependency Injection via initialize has solved some problems, but this has created a new problem: often objects have many dependencies, and writing them out as keyword arguments becomes very awkward.
In addition to a verbose arguments list, it also requires assigning each kwarg to an instance variable, so you have to type out each argument twice.
There is a system that helps you define initialize without the redundancy:
class AddSites::CreateActivation < Command
option :confirmation, T::Interface(:send), default: -> { ConfirmationMailer.new }
option :digest, T::Interface(:create), default: -> { BCrypt::Password }
option :email_validator, T::Interface(:valid?), default: -> { EmailValidator.new }
option :user_repo, T::Interface(:create), default: -> { UserRepo.new }
# ...etc
endIf we look at the Command base class, we see extend Dry::Initializer, which is providing this option helper.
class Command
extend Dry::Initializer
# ...etc
endoption defines a keyword argument for the initialize function to accept, and provides an instance method to access the value. It also has the added benefit of supporting type-checks.
You can also include it inline into any plain Ruby class:
class UserRegistration
include Dry::Initializer.define -> do
option :confirmation, T::Interface(:send), default: -> { ConfirmationMailer.new }
option :digest, T::Interface(:create), default: -> { BCrypt::Password }
option :email_validator, T::Interface(:valid?), default: -> { EmailValidator.new }
option :user_repo, T::Interface(:create), default: -> { UserRepo.new }
end
def register(email, password)
# etc
end
endInstead of a permanent option interface, define uses a module builder pattern instead which keeps the class interface simple. Using extend is fine for cases where you already have a base class, and don't want to repeat yourself in the children.
By using a simple DSL, we can eliminate the redundancy of defining kwargs explicitly. This works well for simple cases.
Note
Object-building should be handled in a systematic way that follows a generic convention.
- Use
Dry::Initializer.definebuilder for single classes - Use
extend Dry::Initializerfor class hierarchies
So far, there is no need for the instance of a class to have its own identity separate from the class itself. But, what if we need to replace one class with another based on a feature gate?
This is where a simple DSL like Dry::Initializer breaks down; we need to be able to dynamically choose our injected dependencies, and this means we need a way of identifying them aside from their class constant.
This is where Dry::Container comes in. Its purpose is to provide identifying key names for registered objects. Let's suppose that you're replacing ConfirmationEmailer with a Notification Service, but you need roll this change out by a feature gate. The naive approach would do this:
if Enabled?(:notification_service)
UserRegistration.new(confirmation: NotificationService.new)
else
UserRegistration.new
endThe code consuming UserRegistration should ideally not have to know these internal details in order to call it. You really shouldn't have to think about how to construct this object when all you need to do is call it.
You can simplify the caller code by pushing this branch down into UserRegistration:
class UserRegistration
include Dry::Initializer.define -> do
option :confirmation, T::Interface(:send), default: -> { choose_notifier }
# ... etc
end
private
def choose_notifier
if Enabled?(:notification_service)
NotificationService.new
else
ConfirmationMailer.new
end
end
endBut this this has merely moved the problem: now, UserRegistration is made responsible for knowing which client to use. Dependents of NotificationService should not know about this, because this requires duplicating that branch every place it is used.
Dry::Core::Container gives us a better place to encode this information:
require 'dry/core/container'
container = Dry::Core::Container.new
container.register :confirmation do
if Enabled?(:notification_service)
NotificationService.new
else
ConfirmationMailer.new
end
endThis defines a key in container named confirmation. Next we define a Deps constant using this container:
require 'dry/auto_inject'
Deps = Dry::AutoInject(container)You can now inject this into any object by name:
class UserRegistration
include Deps['confirmation']
endYou can alter this name to suit yourself:
include Deps[notify: 'confirmation']Will become notify within the instance. Let's move the remaining dependencies:
container.instance_eval do
register(:digest) { BCrypt::Password }
register :email_validator do
EmailValidator.new
end
register :user_repo do
UserRepo.new
end
endAnd now we can simplify the original code:
class UserRegistration
include Deps['confirmation', 'digest', 'email_validator', 'user_repo']
def register(email, password)
raise "Invalid email" unless email_validator.valid?(email)
password_digest = digest.create(password)
user_repo.create(email:, password: password_digest)
confirmation.send(to: email, subject: "Confirm Email")
end
endIn practice, the frameworks will establish this Deps injector for you, but by showing how it's done manually you can see that it's very simple. include Deps registers an initialize method for your object that does all the tedious wire-up automatically.
Note
A Container key registration represents the details of how to instantiate a particular dependency that the consuming code can use by name without knowing the internal details.
When we reserve initialize arguments for injecting dependencies, we can systematize it to reduce toil.
When we systematize building our objects, we can give them unique identities apart from their class constants.
When we decouple class constant from instance identity, our code can eliminate knowledge of how they are constructed and only focus on our defined public interface.
The purpose of a class in this paradigm is to define dependency relationships between classes and establish a public interface.
The purpose of the class instance is to do work, perform some kind of business function. You no longer need to hold both these things in your head simultaneously; they are separate concerns.
Johnson, R.E. & Foote, B. (1988). Designing Reusable Classes. Journal of Object-Oriented Programming http://www.laputan.org/drc/drc.html
Fowler, M. (2005). Inversion of Control. https://martinfowler.com/bliki/InversionOfControl.html
Weirich, J. (2004). Dependency Injection In Ruby. { | one, step, back | }. https://web.archive.org/web/20080203042721/http://onestepback.org/index.cgi/Tech/Ruby/DependencyInjectionInRuby.rdoc
CodeAesthetic. (2023). Dependency Injection, The Best Pattern [Video]. YouTube. https://youtu.be/J1f5b4vcxCQ