Skip to content

Instantly share code, notes, and snippets.

@alassek
Created February 16, 2026 20:40
Show Gist options
  • Select an option

  • Save alassek/1a185a8cdae20ddd991a8cd3cd90d347 to your computer and use it in GitHub Desktop.

Select an option

Save alassek/1a185a8cdae20ddd991a8cd3cd90d347 to your computer and use it in GitHub Desktop.

Why We Should Inject Dependencies

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.

1. The Distinction Between Dependencies and Arguments

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
end

We 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.

2. initialize as the Standard Interface for Declaring Dependencies

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
end

We 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.

3. Test Doubles Are Alternate Dependencies

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
end

Note

Use injection to replace dependencies with test doubles.

4. Simplify Dependency Declaration With A Domain-Specific Language (DSL)

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
end

If we look at the Command base class, we see extend Dry::Initializer, which is providing this option helper.

class Command
  extend Dry::Initializer
  # ...etc
end

option 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
end

Instead 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.define builder for single classes
  • Use extend Dry::Initializer for class hierarchies

5. Knowing How to Instantiate an Object is a Separate Concern

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
end

The 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
end

But 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
end

This 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']
end

You 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
end

And 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
end

In 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.

Conclusion

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.

Further Reading

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment