The Gnar Company
The Gnar Company

Dependency Injection: Plug In

by Kevin Murphy

Ruby Software Design Concert Series

  1. Dependency Injection: Plug In
  2. Shedding a Light on Duck Typing
  3. Synthesizing A Composition With Delegation
  4. Inheritance: Derivative Songwriting
  5. Using Sonic Pi To Play Music With Ruby
  6. Stringing Code Together To Play Music

Setting the Stage

Dependency injection is a fancy term. It sounds intimidating. The purpose of this post is to explain what dependency injection is, how to use it, and why it can be beneficial. To illustrate, let's talk about playing a guitar in a concert, which comes from my RubyConf 2020 talk about Ruby's Coverage module.

Sound Check

A guitarist in a band uses an amplifier when playing a live concert.

class Guitar
  def initialize
    @amplifier = Amplifier.new
  end
end

When the guitar is played the sound travels through the amplifier, so the audience can hear the notes being played.

class Guitar
  def strum(chord)
    chord.phrasing.each do |string_sound|
      @amplifier.play(string_sound.amp_value)
    end
  end
end

You can play a great show with this setup! Your guitar uses the amplifier it defines, and all is well. Until...

Wall of Sound

Some guitarists experiment with gear - a lot. Different amplifiers are going to make different sounds. However, we've made it very difficult for our guitar to be plugged in to different amplifiers.

Right now, our dependence on the amplifier class to play the sound from the guitar is hard-coded in the Guitar class. The initializer sets up an explicit dependency with the Amplifier class.

If we want to plug the guitar into a LouderAmplifier, we can't do that without changing our Guitar class. Every different amplifier will require a change to our Guitar class.

Plug and Play

We can resolve this limitation by instead passing in the amplifier that'll be used with the guitar when we make a new guitar.

class Guitar
  def initialize(amplifier)
    @amplifier = amplifier
  end
end

With this small change, our Guitar can work with any amplifier that responds to the play method. Rather than being coupled to the Amplifier class, we require that any users of the Guitar class instead explicitly pass in this collaborating class. This is a form of dependency injection, specifically Constructor Injection.

Sound Engineering

Now that we've seen an example of what dependency injection is, let's discuss why we would want to use it.

Flexibility

This is the motivation described in the example above. By removing the hard-coded dependency as an implementation detail of our class, we can instead use any dependency desired, as long as it responds to the methods that we need to use within the class. For us, this means that guitars can use any amplifier they'd like; the guitarist isn't limited to the amp they had when first buying the guitar.

Testing

Testing may be the first situation where the value of this flexibility can be appreciated. Tests are the first consumers of your implementation, and it's important to listen to the implicit feedback they give you. If a class or a method is hard to test, it very likely will be hard to use - or at least complex to understand.

In reality, the difficulty in testing the Guitar class is what led to the decision to inject the amplifier in. That's because the Amplifier class is essentially a wrapper around Sonic Pi. Sonic Pi describes itself as a "code-based music creation and performance tool", so playing the guitar with this amplifier will actually play a sound on your computer.

As exciting as that is, I don't want to have Sonic Pi running just to execute my tests. And even if I did, I don't need to hear the sound it would generate every time I run my tests. And so, I created a separate amp for testing: a PracticeAmplifier. What does that amp do? Absolutely nothing! And that's perfect for my unit tests. They're not concerned with the sound the amplifier makes when playing the guitar. They're interested in exercising the logic that's within the Guitar class only.

More generally, maybe your class is collaborating with another class that makes an API call or performs file I/O. You don't want to have to execute or mock out those actions in your class's tests - it's the collaborator's tests that should be concerned with that. You can instead pass in another class that doesn't do those things, providing speedy and relevant feedback in your tests.

Complexity Identification

The responsibility of systems tends to grow over time. This is true not only for your entire application, but the different components of it, down to the individual classes or methods. As functionality continues to get added to classes, you may need to add in more and more collaborators. If each of these changes are made piecemeal over time, it can be difficult to step back and realize not only how coupled a class is to other classes, but how many classes it's coupled to.

Injecting dependencies explicitly makes it more clear what this class is dependent on, and how many things. As the list of things you need to pass in to a constructor or a method grows to support new features, it can serve as a proxy to gauge how complexity within the class or method is growing. This may exert more natural pressure to identify different abstractions or refactorings to implement.

Rock On

Ruby's inherent flexibility can make dependency injection a less-likely tool to reach for, particularly if testing is when you would notice that pain initially, given the tools at our disposal to make testing interactions with dependencies easier.

Dependency injection is also a daunting term that often carries the assumption that you need a heavyweight framework to implement it. However, if you can pass in an object as an argument to an initializer (constructor) or even an individual method - congratulations, you've injected a dependency!

Using dependency injection can lead to less tightly-coupled code, which allows for more flexibility in collaborating with others, reduces the burden of testing, and makes it more clear when classes are growing to the point where their current design needs to be reconsidered.

The next post in our series explains using duck typing in ruby.