Back to Course

DIP Series · 1 of 3

Dependency Injection

Where the Dependency Inversion Principle comes from, why hardcoded collaborators trap your tests and your refactors, and how to pass collaborators in instead of looking them up.

Where this rule comes from

The Dependency Inversion Principle is the D in SOLID. Robert Martin formalized it in the mid-1990s, and the one-line version is: high-level modules should not depend on low-level modules. Both should depend on abstractions. There is a second clause that matters: abstractions should not depend on details. Details should depend on abstractions.

The "inversion" in the name refers to the direction of dependency. In a naive design, a high-level class calls into low-level classes that do the actual work. The high-level class depends on the low-level ones, which means a change to the low-level ones ripples up. Dependency Inversion reverses that arrow: both ends depend on an abstraction in the middle, and either side can change without disturbing the other.

In practice, "dependency inversion" in Rails almost always shows up as dependency injection: instead of a class looking up its collaborators directly, the collaborators are passed in. The class still uses them; it does not own the relationship.

That sounds abstract, so here is the practical version: a service that uses a notifier should take the notifier as an argument, not call NotifierClass directly. The "abstraction" is the notifier's interface (whatever methods the service calls on it). In production, the caller passes in the real notifier. In tests, the caller passes in a fake one. The service is unchanged either way.

The anti-pattern

Picture a Rails service that sends a Slack message when an order ships:

class ShipOrderService
  def call(order)
    order.update!(shipped_at: Time.current)
    Slack::Notifier.new(ENV["SLACK_WEBHOOK"]).post(
      "Order ##{order.id} shipped to #{order.user.email}"
    )
    order
  end
end

This works. The problem is what it does to everything that touches the class:

  • Tests have to stub Slack::Notifier. Every test that exercises ShipOrderService has to monkey-patch the class to prevent a real HTTP call. Test files accumulate stubs that drift from the real notifier's behavior.
  • Local development needs Slack credentials. A developer running the test suite or kicking the service in a console hits ENV["SLACK_WEBHOOK"], which is either nil (the service raises) or production-real (a real message gets sent). Both are bad.
  • Swapping notifiers requires a code change. The day the team moves to Discord, every service that calls Slack::Notifier gets edited. The dependency is hardcoded.
  • The service has two responsibilities entangled. "Mark the order shipped" and "find a Slack webhook and send a message" live in the same method. Reading the service tells you everything about how Slack works at this company, which is information the service should not carry.

Each of these is a small symptom of the same underlying coupling: the service depends on a specific notifier class. The arrow points the wrong way.

The fix: inject the collaborator

Invert the dependency by accepting the notifier as an argument:

class ShipOrderService
  def initialize(notifier: default_notifier)
    @notifier = notifier
  end

  def call(order)
    order.update!(shipped_at: Time.current)
    @notifier.post("Order ##{order.id} shipped to #{order.user.email}")
    order
  end

  private

  def default_notifier
    Slack::Notifier.new(ENV["SLACK_WEBHOOK"])
  end
end

Three changes, all small, with disproportionate payoffs:

  • initialize accepts a notifier. In production, callers pass nothing and get the Slack default. In tests, they pass a fake.
  • The default lives in a private method, so the class still works without any setup in the common case. You did not push the configuration burden onto every caller.
  • The post call uses the injected notifier, not a hardcoded class. The service depends on a role (whatever responds to post), not on the Slack::Notifier class.

Production code calls ShipOrderService.new.call(order) and gets Slack. Tests call ShipOrderService.new(notifier: FakeNotifier.new).call(order) and assert against the fake. No stubs, no monkey patches, no global state.

The fake can be three lines:

class FakeNotifier
  attr_reader :messages
  def initialize; @messages = []; end
  def post(text); @messages << text; end
end

# in a test:
notifier = FakeNotifier.new
ShipOrderService.new(notifier: notifier).call(order)
assert_equal ["Order #1 shipped to alice@example.com"], notifier.messages

The test reads like the actual behavior: send a message, check that the message was sent. No setup ritual, no library knowledge required.

Where Rails uses this pattern itself

Rails core injects dependencies in multiple places, although it does not call the pattern by name. Three examples worth knowing:

1. ActiveJob's queue adapter. When you write MyJob.perform_later(arg), the job class does not know whether the queue is Sidekiq, Solid Queue, or the test adapter. Rails injects the adapter via configuration: config.active_job.queue_adapter = :sidekiq in production, = :test in tests. The job code is the same; the dependency it talks to swaps based on which adapter was injected.

2. ActionMailer's delivery method. Mailers call mail(to: ..., subject: ...). The mail object knows nothing about SMTP or test mode. The delivery method is injected: config.action_mailer.delivery_method = :smtp in production, = :test in tests. The mailer's code is identical across environments because the dependency it uses is injected, not looked up.

3. ActiveStorage's service. The OCP series covered this: the storage backend is injected via config.active_storage.service. Disk in dev, S3 in production, a test fake in tests. The model code that calls user.avatar.attach(io) never knows which one it is talking to.

These are all examples of dependency injection at the framework level. Your application code can use the same shape at a smaller scale: pass collaborators to services as constructor arguments. The same payoffs apply.

When and how to inject

Three signals that a class is begging for an injected dependency:

  • The class calls a specific external service. A mailer client, a Slack client, a Stripe client, a Twilio client. Anything that makes network calls is a candidate, because tests do not want to make them and production wants flexibility.
  • The class calls a class that depends on global state. A class that reads from ENV directly, talks to Rails.cache, or instantiates a connection pool is making decisions that should be made elsewhere.
  • The class has to be stubbed in every test that uses it. If every test of ShipOrderService starts with Slack::Notifier.expects(:post), the stub is doing the role of an injected dependency, badly.

When you inject, three style rules:

  • Provide a default. Callers in production should not have to wire up a notifier every time. The class's constructor accepts a keyword argument with a default that does the obvious production thing. The injection is for tests and special cases; the default is for the common case.
  • Inject into the constructor, not the method. A class that takes a notifier in its constructor has one place to set it. A class that takes a notifier as an argument to every method makes every caller responsible for passing it. The constructor wins.
  • Inject the collaborator, not its configuration. Pass in notifier: SlackNotifier.new(webhook_url), not webhook_url: "...". The service should not know that the notifier needs a webhook URL.

When NOT to inject

Dependency injection has a cost: the constructor grows, and every place that uses the class has to know what defaults are appropriate. Do not inject ActiveRecord models, framework classes, or anything that is already trivially testable. A service that calls Order.find(id) does not need order_class: injected; ActiveRecord is already a global, the framework is already there, and you can use a real Order in tests via fixtures or factories.

The rule of thumb: inject what crosses an external boundary (network, filesystem, third-party services) or what carries hidden state (clocks, randomness, IDs). Do not inject what is already part of your application's normal vocabulary.

One special case worth knowing: Time.current and SecureRandom.uuid are often worth injecting if your tests need to control them. The Rails ecosystem also offers ActiveSupport::Testing::TimeHelpers (freeze_time, travel_to) and the timecop gem as alternatives. Both work; injection is more explicit, helpers are easier.

The principle at play

Dependency Inversion is about who controls the wiring. In a class that looks up its own collaborators, the class is in charge of finding them, which means changing the collaborator changes the class. In a class that receives its collaborators, the class is in charge of using them — and the caller can change what gets passed in without modifying the class itself.

The deeper move is to separate "what does this class do?" from "what does it talk to?" A service that ships an order is doing the same logical thing whether the notification goes to Slack, Discord, or a test inbox. The work is the same; the collaborator changes. Injecting the collaborator captures that distinction in the code.

For Ruby specifically, dependency injection is not exotic. Constructor injection in Ruby is keyword arguments with sensible defaults. That is it. No DI containers, no IoC frameworks, no XML config. The simple version — pass the thing in, default to the production version — gets you 95% of the benefit at 5% of the ceremony.

Practice exercise

  1. Run grep -rEn "Slack|Twilio|Stripe|HTTParty|Faraday|Net::HTTP" app/services/ app/jobs/. Each hit is a candidate for injection.
  2. Pick the worst offender (the class your tests stub most often). Open its test file. Count the stubs at the top of each test. That is the cost of not injecting.
  3. Refactor the class to accept the collaborator as a keyword argument with a default. Production code does not change; the tests get to drop the stubs and pass in a fake instead.
  4. Bonus: write the smallest possible fake notifier (or fake API client, or fake SMS sender). Three or four lines. Notice how much clearer the tests get when the fake is in plain sight instead of hidden inside a stub block.