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!