Dependency Injection in Ruby

Posted on 2022-02-07 by Karol Bąk Comments
ruby
di

Introduction

Dependency Injection isn’t something that you’ll see in every Rails application. Ruby allows us to stub basically everything in tests so in most cases there’s no need to inject any dependencies and hard-coding them is good enough. Although even in such flexible language as ruby there is a place for DI.

What is DI?

Before we jump to some examples let’s first take a minute to understand what is Dependency Injection. Imagine a situation when one of your services uses another one. You probably see it every day.

class ServiceA
  # ...
  def do_something
    # ...
    ServiceB.new.do_something_else
    # ...
  end
  # ...
end

ServiceB is hard-coded and tightly coupled with ServiceA. Is that a bad thing? In most cases - no. We can still stub it in the tests or replace it with something else but it will be easier using DI.

class ServiceA
  def initialize(some_dependency: ServiceB.new)
    @some_dependency = some_dependency
  end

  # ...
  def do_something
    # ...
    @some_dependency.do_something_else
    # ...
  end
  # ...
end

Instead of hard-coding ServiceB, we are injecting it into ServiceA. It gives us loose coupling between our services - ServiceA depends on abstraction. We can use a different class without changing the implementation of ServiceA as long as it implements do_something_else method.

Real-world examples

Looking at the code above you’ll probably think “why would I need to change ServiceB?” or “even if I have to, it’s not a problem” and in most cases, you would be right.

Let’s take a look at some real-world examples:

class DataExport
  # ...
  def call
    csv = Writers::CSV.new
    csv << some_data
    # ...
    csv.close
  end
  # ...
end

Our class is responsible for exporting some data to a CSV file using Writers::CSV. Now imagine having many similar classes responsible for exporting different data in your application.

What if we decide to switch to XLS or give users a choice? How should we test it? Should it write to some temp files or should we stub it?

Second one:

class SomeNotifier
  # ...
  def call
    # ... 
      SmsSender.new.call(number, message)
    # ...
  end
  # ...
end

In this case, our class notifies users using an SMS message.

What if we decide to change the SMS provider? Easy - let’s just change SmsSender implementation. How should we test it? Also easy - let’s just stub the whole class. But how it should behave in the development environment? We would probably want to avoid sending real sms messages and printing content to stdout should be enough. How would you handle it? if Rails.development? ?

Dependency Injection to the rescue

Let’s focus on data export first. Using DI it would look like this:

class DataExport
  def initialize(writer: Writers::CSV.new)
    @writer = writer
  end
  # ...
  def call
    writer << some_data
    # ...
    writer.close
  end
  # ...
end

We can easily replace the writer with some Writers::XLS or anything else without touching the actual implementation of DataExport.

In tests for this particular class we don’t really care if a file is created on the disk (assuming we have proper tests for Writer::*). What we focus on is if exported data is correct. Do we need a real file for that? No, we can replace our writer with some Writers::Memory, which uses StringIO instead, without any stubs.

describe DataExport do
  let(:instance) { described_class.new(writer: Writers::Memory.new) }
  
  it 'exports correct data' do
    # ...
    expect(writer.result).to eq(something)
  end
end

Since our test is using memory instead of real files it can result in a significant performance boost. Also, we don’t have any stubs that distract us from what’s important in our test.

To solve our problem from the second example we have to go further.

Dependency Container

Dependency Container helps us keep our dependencies in one centralized place. We have a great implementation called dry-container.

First, we need to initialize the container and register our dependencies:

container = Dry::Container.new
container.register(:sms_service, SmsSender.new)

Then, we can use it in our SomeNotifier class.

class SomeNotifier
  # ...
  def call
    # ... 
    container.resolve(:sms_service).call(number, message)
    # ...
  end
  # ...
end

Now SmsSender is used directly only during container initialization. As a result, we can easily register different classes in different environments. In our development we can register some fake class that will output the message to stdout:

container = Dry::Container.new
container.register(:sms_service, FakeSmsSender.new)

Dependency Container can be extremely useful when working with external APIs and not just that.

Conclusion

Both examples above can be handled without using DI. You can use stubs, if Rails.development? conditions, or any other tricks but in my opinion, DI makes it cleaner, easier to maintain and test.

It can be useful in many other cases:

  • working with external services - you don’t need tons of requests stubs and it’s easy to use fake data in the development
  • when using repository pattern - fake repositories in tests instead of stubs or real calls to the database
  • A/B testing different approaches/implementations

If you don’t agree with me or have any other thoughts on the subject please let me know!