Dependency Injection Containers vs Hard-coded Constants

Dependency injection (DI) is a somewhat contentious topic in the Ruby community. Some argue that DI containers are unnecessary complexity cargo-culted from Java. Some argue that DI is the path to cleaner, simpler, more-testable code.

In this article, I want to compare and contrast two approaches: hard-coded constants versus using a DI container. The difference might not be as big as you think!

Let’s start with a quick explanation of DI, and DI containers.

What Is A Dependency?

A dependency is some external object that is relied upon. Take this module, for example:

module RegisterUser
  def self.call(params)
    if UserValidator.validate(params)
      UserRepository.save(params)
    else
      nil
    end
  end
end

UserValidator and UserRepository are both dependencies of RegisterUser.

What Is Dependency Injection?

There are a few issues with the module above.

  • The dependencies are not replaceable. What if we want to reuse this code with a different validator or repository?

  • The code does not work by itself. What if we want to test it independently? What if we want to extract it into a separate gem, without its dependencies?

  • The dependencies aren’t explicitly declared. To identify the dependencies, the code must be read in its entirety.

Here is the same code, rewritten as a class with DI:

class RegisterUser
  attr_reader :validator, :repo

  def initialize(validator:, repo:)
    @validator = validator
    @repo = repo
  end

  def call(params)
    if validator.validate(params)
      repo.save(params)
    else
      nil
    end
  end
end

This is an example of constructor injection. There are other ways of injecting dependencies.

The class is no longer directly coupled to its dependencies. The dependencies are passed in (injected) as arguments to initialize, and they are stored in instance variables.

  • Different dependencies can be passed in.

  • The class is independent. It can be tested in isolation, or extracted into a separate gem.

  • All dependencies are explicitly listed as arguments to initialize.

What Is A DI Container?

Now that RegisterUser is decoupled from its dependencies, how do we use it?

One option is to instantiate an object manually, passing in all the required dependencies:

RegisterUser.new(
  validator: UserValidator.new,
  repo: UserRepository.new,
)

Dependencies often have their own dependencies, however, and the dependencies of the dependences can have their own dependencies too. This can quickly become a chore to write.

What would be nice is a kind of factory that creates an instance for us. We’d like to say to this factory:

Give me a RegisterUser object. You handle creating and injecting all of the dependencies. I don’t care about any of the details, just give me a working object I can use.

In DI terminology, fetching an object from a container is called “resolving”.

This is essentially what a DI container does. It encapsulates all of work involved in creating objects, and injecting their dependencies. We don’t have to think about how to create an object, we just ask for an object, and the container provides one.

register_user = container.resolve(:register_user)

In order for the DI container to do its job, it needs to be configured. In Ruby, this usually involves “registering” blocks with the container. These blocks will be run to create the necessary objects, when they are requested from the container.

To demonstrate what this could look like in code, let’s start by creating a container:

container = Container.new

We can register the dependencies first, with blocks that create those objects:

container.register(:user_validator) do
  UserValidator.new
end

container.register(:user_repository) do
  UserRepository.new
end

Then we can register the RegisterUser object, taking its dependencies from the container instead of hard-coding them:

container.register(:register_user) do
  RegisterUser.new(
    validator: container.resolve(:user_validator),
    repo: container.resolve(:user_repository),
  )
end

This is just an example implementation of a DI container, but real implementations are used in much the same way.

Testing With Dependency Injection

If we want to test a dependency injected class in isolation, we can pass in mock dependencies:

it "saves if validation passes" do
  validator = double(validate: true)
  repo = spy
  register_user = RegisterUser.new(validator: validator, repo: repo)

  register_user.call(name: 'Tom')

  expect(repo).to have_received(:save).with(name: 'Tom')
end

If we want to integration test the class, some of the dependencies can be stubbed in the container. For example, if we want to use the real validator, but we don’t want to actually save to the database, then we could replace only the user repository.

it "saves if validation passes" do
  repo = spy
  params = { email: '[email protected]' }

  container.while_stubbing(user_repository: repo) do
    register_user = container.resolve(:register_user)
    register_user.call(params)
  end

  expect(repo).to have_received(:save).with(params)
end

Hard-coded Constants

And now for something completely different, let’s look at hard-coded constants within Ruby’s global namespace, and how that compares to a DI container.

We saw that things need to be registered with the DI container. That is, the DI container needs to be configured. That is somewhat similar to defining modules, creating constants within the global namespace.

module RegisterUser
  # (implementation goes here)
end

We saw that we can request/resolve an object from a DI container. That is somewhat similar to a constant lookup within the global namespace. This lookup happens just by using the name of the module.

RegisterUser

We saw that DI containers can inject dependencies recursively — creating the dependencies, and the dependencies of the dependencies. All code has access to the global namespace, so hard-coded dependencies are able to hard-code their own dependencies, in a recursive fashion.

module RegisterUser
  def self.call(params)
    if UserValidator.validate(params)
      UserRepository.save(params)
    else
      nil
    end
  end
end

We have arrived back at the original implementation, from the beginning of this article.

Testing With Hard-coded Constants

Replacing dependencies is easy with DI, but how can hard-coded dependencies be replaced? If we were writing C++ or Go, this would be basically impossible. Once the code is compiled an running, classes and modules can not be swapped out for different ones.

But we are writing Ruby — a lovely, dynamic language. We can replace any constant within the global namespace using the stubbing functionality of RSpec Mocks.

it "saves if validation passes" do
  repo = spy
  stub_const('UserValidator', double(validate: true))
  stub_const('UserRepository', repo)

  RegisterUser.call(name: 'Tom')

  expect(repo).to have_received(:save).with(name: 'Tom')
end

Even mocking out hard-coded dependencies looks somewhat similar to the DI equivalent.

Differences Between The Two

Having shown the similarities between DI containers and hard-coded constants, let’s look at the differences between the two approaches.

  1. DI objects are more flexible and reusable. Hard-coded dependencies can be stubbed out during testing, but only during testing. It is not possible to change behaviour or reuse code by providing different dependencies, if they are hard-coded.

  2. DI introduces additional code, concepts and complexity. DI prompts us to think about dependencies, constructor injection, resolving objects from a container, configuring the container by registering objects, and so on. These concepts are not native to Ruby, and need to be learnt by the developer. These extra concepts also lead to extra boilerplate code.

    Hard-coded constants are a basic feature of the Ruby language. They hardly need any explanation, even to less-experienced developers.

  3. During testing, mocking out dependencies in DI objects doesn’t require anything other than normal Ruby code. In contrast, stubbing out constants is dark magic. RSpec Mocks does a fanstastic job of hiding this magic, to make constant stubbing feel easy, but there is a lot going on under the hood.

  4. DI objects have explicit dependencies, whereas hard-coded constants have implicit dependencies. To test an object in isolation, we need to replace all of its dependencies. This is easy with DI, because usually all of the dependencies are declared as arguments to initialize. To identify hard-coded dependencies we need to read all of the code, and metaprogramming can make this task quite difficult.

  5. DI is about abstraction and indirection. It’s not obvious what the specific dependencies of a DI object will be. That is the point of DI: dependencies are treated as mere interfaces, abstractions, which could theoretically be implemented by any object.

    Hard-coded constants are the opposite — they are concrete, and direct. We always know exactly what the dependency is.

  6. DI leads to different designs, with less coupling. DI prompts us to think in terms of abstract dependencies while we’re writing code. It makes the coupling to those dependencies more obvious. This way of thinking about code leads to designs with less coupling — more classes, with fewer dependencies per class.

    In contrast, hard-coded dependencies are easy to ignore. This often leads to classes growing larger over time, instead of being decomposed. When we have easy access to everything in the global namespace, we tend to introduce dependencies without much thought. This is a recipe for tightly-coupled spaghetti code, if the developer is not careful.

The Hybrid Approach

There is an approach that attempts to combine the benefits of DI and hard-coding, without the need for a container:

class RegisterUser
  attr_reader :validator, :repo

  def self.build
    new(
      validator: UserValidator.new,
      repo: UserRepository.new,
    )
  end

  def initialize(validator:, repo:)
    @validator = validator
    @repo = repo
  end

  def call(params)
    if validator.validate(params)
      repo.save(params)
    else
      nil
    end
  end
end

This class is exactly the same as the DI version, but it has one additional class method called build. Dependencies are still injected, and we can still call RegisterUser.new with any dependencies that we wish. However, if we want to create an object without thinking about its dependencies, in much the same way that we would use a container, then we can call RegisterUser.build.

This approach retains most of the benefits of DI.

  • The class can still be tested in isolation.
  • The dependencies are still explicit — even more explicit than a pure DI approach.
  • The class is still mostly decoupled from its dependencies, although a little bit of coupling was introduced.

The extra little bit of coupling can actually be helpful, because we can see what the concrete dependencies will (probably) be, by reading the build method. In a pure DI approach, this information does not exist in the class — we would have to read the container configuration.

There is still some extra boilerplate, but no container is required. Each class is capable of providing default dependencies for itself.

Summary

Objects depend upon other objects. These dependencies can be hard-coded, or injected. DI containers can encapsulate the creation of objects, injecting the correct dependencies, and recursively create those dependencies if needed. Objects that have their dependencies injected are generally easier to test, because dependencies can be replaced easily with test doubles.

Ruby’s global namespace functions somewhat similarly to a DI container. Hard-coded dependencies can be replaced during testing, using RSpec Mocks.

There are, however, definite differences between the two approaches. DI objects are more flexible, testable, and reusable than objects with hard-coded dependencies. This comes at the cost of extra complexity, indirection, and boilerplate. DI also prompts the developer to think in terms of dependencies while writing code, leading to designs with less coupling — more classes with fewer dependencies per class.

There is a hybrid approach that attempts to combine the benefits of DI with hard-coding. It starts with the DI design, and adds a build class method, which contains hard-coded dependencies. This approach does not require a container, because each class can provide its own dependencies.

Takeaways

For people who dislike DI containers, the use of DI containers is not that different from “normal” Ruby. It could be said that you’re already using Ruby’s global namespace as a kind of container, consciously or not.

For proponents of DI containers, “normal” Ruby can function similarly to DI container code. Where dependencies only ever change for the purpose of testing — a common situation — constant stubbing might be a legitimate choice.

Where the two approaches really differ is in the design of the code they produce.

DI gravitates toward abstract designs. Is it desirable for all code to be maximally abstract? No. At some point, there must be a concrete implementation. Abusing abstraction leads to unnecessary complexity — simple, cohesive code is broken apart and spread across multiple classes with layers of indirection between them.

Hard-coding gravitates toward concrete designs. Is it desirable for all code to be maximally concrete? Again, no. There are good reasons why almost nobody writes assembly code anymore. Abusing concretion leads to duplication, tight coupling, testing difficulties, and prevents encapsulation.

The trick is learning to recognise when a technique is appropriate, and, just as importantly, when it isn’t.

As of 2018, the most popular DI-related gems are probably the ones from dry-rb, like dry-container and dry-system.

Tim Riley, a core team member of dry-rb, has a few blog posts about using DI in Ruby web apps:

For more information on the constant stubbing functionality of RSpec Mocks, see the official documentation, the blog post, and the GitHub issue.

Justin Searls, one of the world’s foremost authorities on test doubles, recently gave a talk titled Please Don’t Mock Me, detailing the uses and abuses of mocking, distilled from many years of experience. It’s worth a watch.

DHH, the original creator of Rails, loves huge classes. He is also dislikes DI and designing classes for isolated testing. This should come as no surprise to anyone familiar with Rails.


Special thanks to Nathan Ladd and James McLaren for reviewing an earlier version of this article.

Got questions? Comments? Milk?

Shoot an email to [email protected] or hit me up on Twitter (@tom_dalling).

← Previously: Testing Example Code In Your Jekyll Posts

Join The Pigeonhole

Don't miss the next post! Subscribe to Ruby Pigeon mailing list and get the next post sent straight to your inbox.