by Kevin Murphy
Ruby Software Design Concert Series
- Dependency Injection: Plug In
- Shedding a Light on Duck Typing
- Synthesizing A Composition With Delegation
- Inheritance: Derivative Songwriting
- Using Sonic Pi To Play Music With Ruby
- 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.
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
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
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.
Now that we've seen an example of what dependency injection is, let's discuss why we would want to use it.
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 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.
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.
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.
Learn more about how The Gnar builds Ruby on Rails applications.