ruby

How to Use Dependency Injection in Rails for Cleaner, More Testable Code

Learn how dependency injection in Rails keeps your code flexible and testable. Build cleaner, maintainable apps with practical DI patterns and containers.

How to Use Dependency Injection in Rails for Cleaner, More Testable Code

Let’s talk about building Rails applications that are easier to manage as they grow. Often, when a class needs another class to work—like a payment processor needing a payment gateway—it creates that dependency directly inside itself. This makes the code rigid and hard to test. What if we could flip that relationship?

The core idea is simple: instead of an object building the things it needs, we provide them from the outside. This is the essence of dependency injection. It’s about giving an object its dependencies, rather than letting the object create them for itself. The control over what is provided is inverted, moving from the class itself to the code that uses it. This small shift has profound effects on testability and flexibility.

Think about a kitchen. If a chef has a built-in oven in their station, they can only bake with that specific oven. But if we give the chef an oven as a tool, we can give them a conventional oven, a convection oven, or even a test oven that just simulates baking. The chef’s job (to bake) remains the same, but we have complete control over the equipment. Our classes should work the same way.

In a typical Rails controller, you might see code that directly calls other classes.

# Without dependency injection
class OrdersController < ApplicationController
  def create
    @order = Order.new(order_params)
    processor = OrderProcessor.new # This creates its own dependencies internally
    if processor.charge(@order)
      # ...
    end
  end
end

The OrderProcessor might be creating its own PaymentGateway and Logger deep inside its initialize method. To test the controller’s logic, you’re forced to use the real OrderProcessor, which tries to use the real PaymentGateway. This makes tests slow, brittle, and dependent on external services.

Let’s change that. We start by designing our service objects to receive their collaborators as arguments.

# With constructor injection
class OrderProcessor
  # Dependencies are explicitly declared as required keyword arguments
  def initialize(payment_gateway:, inventory_service:, logger: Rails.logger)
    @payment_gateway = payment_gateway
    @inventory_service = inventory_service
    @logger = logger
  end

  def process(order)
    @logger.info("Processing order #{order.id}")
    # Use the injected dependencies
    @inventory_service.reserve_items(order.line_items)
    @payment_gateway.charge(amount: order.total_amount, token: order.payment_token)
    true
  end
end

Now, the OrderProcessor doesn’t care which payment gateway it gets. It just knows it needs an object that responds to a charge method. This is a contract, often referred to as “duck typing.” If it walks like a payment gateway and quacks like a payment gateway, it’s a payment gateway as far as the processor is concerned.

This makes testing straightforward.

# In your test (RSpec example)
describe OrderProcessor do
  let(:fake_gateway) { instance_double('PaymentGateway', charge: { status: 'success' }) }
  let(:fake_inventory) { instance_double('InventoryService', reserve_items: true) }
  let(:test_logger) { Logger.new(nil) }

  it 'processes an order successfully' do
    processor = OrderProcessor.new(
      payment_gateway: fake_gateway,
      inventory_service: fake_inventory,
      logger: test_logger
    )
    order = Order.new(total_amount: 100)

    result = processor.process(order)

    expect(result).to be true
    expect(fake_gateway).to have_received(:charge).with(amount: 100, token: nil)
  end
end

We’ve swapped the real, complex PaymentGateway with a simple test double. The test is fast, isolated, and only checks the interaction we care about: did the processor tell the gateway to charge the correct amount?

But now we have a new question. If the OrderProcessor doesn’t create its dependencies, who does? Where do we put the code that decides to use RealPaymentGateway in production and FakePaymentGateway in tests? This is where a central registry, often called a container or context, comes in.

A container is a simple object responsible for building and holding the components of your application. It knows how to wire things together.

# A simple container
class AppContainer
  def self.payment_gateway
    # Decide which implementation to use based on environment
    if Rails.env.test?
      FakePaymentGateway.new
    else
      RealPaymentGateway.new(api_key: ENV['PAYMENT_API_KEY'])
    end
  end

  def self.order_processor
    # Build the processor, injecting its dependencies
    OrderProcessor.new(
      payment_gateway: payment_gateway,
      inventory_service: inventory_service,
      logger: Rails.logger
    )
  end

  def self.inventory_service
    @inventory_service ||= InventoryService.new
  end
end

Then, in your controller, you would ask the container for what you need.

class OrdersController < ApplicationController
  def create
    @order = Order.new(order_params)
    # Get the fully-assembled processor from the container
    processor = AppContainer.order_processor

    if processor.process(@order)
      redirect_to @order, notice: 'Order was successfully created.'
    else
      render :new
    end
  end
end

This approach works, but the container class can become a giant, hard-to-manage blob. A more dynamic and configurable pattern is to create a container that can register and resolve services by name.

Let me show you a more sophisticated container I’ve used in projects. It handles different lifecycle strategies, like singletons (one shared instance) and factories (a new instance each time).

class Container
  def initialize
    @factories = {}
    @instances = {}
  end

  # Register a service. The block is a factory that creates the service.
  def register(name, &factory)
    @factories[name] = factory
  end

  # Resolve a service by name.
  def resolve(name)
    # Return a cached instance for singletons
    return @instances[name] if @instances.key?(name)

    factory = @factories[name]
    raise "Service #{name} not registered" unless factory

    # Call the factory, passing the container itself
    # so the factory can resolve its own dependencies.
    instance = factory.call(self)

    # Cache it if it's meant to be a singleton.
    # You might base this on a naming convention or a separate config.
    @instances[name] = instance if name.to_s.end_with?('_service')
    instance
  end
end

# Setting up the application's container
AppContainer = Container.new

# Register a logger as a simple singleton.
AppContainer.register(:logger) { Rails.logger }

# Register a payment gateway. The factory decides the implementation.
AppContainer.register(:payment_gateway) do |c|
  if Rails.env.test?
    FakePaymentGateway.new
  else
    RealPaymentGateway.new(api_key: ENV['PAYMENT_KEY'])
  end
end

# Register the order processor, whose factory resolves its dependencies.
AppContainer.register(:order_processor) do |c|
  OrderProcessor.new(
    payment_gateway: c.resolve(:payment_gateway),
    logger: c.resolve(:logger)
  )
end

Now, the wiring is all in one place. The OrderProcessor factory asks the container for a :payment_gateway and a :logger. The container provides them, and it might be providing a fake or a real gateway without the OrderProcessor ever knowing.

This pattern shines when integrating with Rails. You can create a module to cleanly inject dependencies into your controllers or jobs.

module Injectable
  def self.included(base)
    base.extend(ClassMethods)
  end

  module ClassMethods
    # Defines methods that resolve dependencies from the container.
    def inject(*dependencies)
      dependencies.each do |dep|
        define_method(dep) do
          AppContainer.resolve(dep)
        end
      end
    end
  end
end

# In a controller
class OrdersController < ApplicationController
  include Injectable
  inject :order_processor, :notification_service # These become instance methods

  def create
    # Use the injected methods
    if order_processor.process(@order)
      notification_service.send_receipt(@order)
      # ...
    end
  end
end

The inject class method defines order_processor and notification_service methods on the controller that fetch the live services from the container. It’s clean and declarative.

Sometimes, a service might have an optional dependency, like a cache. For this, setter injection can be useful.

class ReportGenerator
  attr_writer :cache_store # Optional dependency setter

  def generate(data)
    key = "report-#{data.id}"
    # Use the cache if it was injected
    if @cache_store&.exist?(key)
      @cache_store.read(key)
    else
      report = build_report(data)
      @cache_store&.write(key, report)
      report
    end
  end
end

# Later, you can configure it
generator = ReportGenerator.new
generator.cache_store = Rails.cache if Rails.env.production?

Another powerful technique is using modules for interface injection, which is great for adding cross-cutting concerns like logging or metrics to many classes.

module Measurable
  attr_accessor :metrics_client

  def track_operation(name, &block)
    start_time = Time.now
    result = yield
    duration = Time.now - start_time
    metrics_client&.timing("operation.#{name}", duration)
    result
  end
end

class ApiClient
  include Measurable

  def fetch_data
    track_operation('api.fetch') do
      # ... make HTTP request
    end
  end
end

# Configure it
client = ApiClient.new
client.metrics_client = StatsD.client if ENV['STATSD_ENABLED']

As your application grows, you might want to move the wiring configuration out of Ruby files and into something like YAML. This allows different setups per environment without changing code.

# config/dependencies.yml
development:
  payment_gateway:
    class: SandboxPaymentGateway
    args:
      endpoint: 'https://sandbox.example.com'

test:
  payment_gateway:
    class: FakePaymentGateway

production:
  payment_gateway:
    class: RealPaymentGateway
    args:
      api_key: <%= ENV['PROD_API_KEY'] %>
      endpoint: 'https://live.example.com'

You would then build a resolver that reads this YAML, interprets the class and args, and instantiates objects accordingly. This is how more complex frameworks operate, but for many Rails apps, the plain Ruby container is sufficient and clearer.

One concern people often have is performance. Constantly creating new objects can be expensive. Our container example already caches singletons. For more advanced scenarios, you can use lazy loading with a proxy object.

class LazyProxy
  def initialize(container, service_name)
    @container = container
    @service_name = service_name
    @object = nil
  end

  # This forwards any method call to the real object,
  # resolving it only on the first call.
  def method_missing(name, *args, &block)
    @object ||= @container.resolve(@service_name)
    @object.public_send(name, *args, &block)
  end

  def respond_to_missing?(name, include_private = false)
    @object ||= @container.resolve(@service_name)
    @object.respond_to?(name, include_private)
  end
end

# In your container
def lazy(name)
  LazyProxy.new(self, name)
end

AppContainer.register(:heavy_service) do |c|
  HeavyService.new # Expensive to initialize
end

# This won't create the HeavyService until `lazy_service.do_something` is called
processor = OrderProcessor.new(heavy_service: AppContainer.lazy(:heavy_service))

The real test of these patterns is, well, in testing. Dependency injection makes unit testing a pleasure, but you also need to test the integration—that the container wires everything correctly. I like to write a simple test that verifies all critical services can be built without errors.

RSpec.describe 'Dependency Container' do
  it 'resolves all registered services without error' do
    AppContainer.instance_variable_get(:@factories).each_key do |service_name|
      expect { AppContainer.resolve(service_name) }.not_to raise_error
    end
  end
end

You can also swap the entire container in a test to isolate a specific component.

RSpec.describe OrderProcessor, type: :service do
  let(:test_container) do
    c = Container.new
    c.register(:payment_gateway) { FakePaymentGateway.new }
    c.register(:logger) { NullLogger.new }
    c
  end

  it 'works with a test container' do
    processor = test_container.resolve(:order_processor)
    # ... test logic
  end
end

Moving to this style requires a shift in thinking. It asks you to design your classes as collaborators from the start. The immediate benefit is testability. The long-term benefit is maintainability. When a new regulation requires you to switch payment providers, you change one line in your container configuration, not a hundred lines across dozens of classes.

It does add an initial layer of abstraction. For a small, simple Rails app, it might feel like overkill. But I’ve found that the moment an app starts to grow beyond a handful of models and complex business logic, this discipline pays for itself many times over. It keeps your code honest, modular, and ready for change. Start small. Try taking one external service—like an email sender or a geocoder—and inject it into your classes. You’ll quickly see how it simplifies your tests and clarifies your design.

Keywords: dependency injection Rails, Rails dependency injection, inversion of control Rails, Rails service objects, Rails application architecture, dependency injection pattern Ruby, Ruby on Rails design patterns, Rails IoC container, testable Rails code, Rails unit testing, Rails service layer, constructor injection Ruby, Ruby dependency injection container, Rails application scalability, clean Rails architecture, Rails testing best practices, Rails test doubles, RSpec mocking Rails, Rails maintainable code, Ruby inversion of control, dependency injection vs service locator, Rails controller dependencies, Ruby duck typing, Rails application design, service object pattern Rails, Rails container pattern, Ruby singleton pattern, Rails code organization, dependency management Rails, Rails refactoring techniques, Rails loose coupling, modular Rails application, Ruby interface design, Rails background jobs testing, injectable dependencies Rails, Rails production code quality, Ruby factory pattern, Rails lazy loading, Rails environment configuration, YAML configuration Rails, Rails integration testing, RSpec instance doubles, Rails service registry, Ruby class dependencies, Rails object composition, Rails code reusability, testable Ruby classes, Rails application maintainability, Ruby collaborator pattern, dependency inversion principle Ruby



Similar Posts
Blog Image
Are You Ready to Revolutionize Your Ruby Code with Enumerators?

Unlocking Advanced Enumerator Techniques for Cleaner, Efficient Ruby Code

Blog Image
6 Proven Techniques for Building Efficient Rails Data Transformation Pipelines

Discover 6 proven techniques for building efficient data transformation pipelines in Rails. Learn architecture patterns, batch processing strategies, and error handling approaches to optimize your data workflows.

Blog Image
Is Pry the Secret Weapon Missing from Your Ruby Debugging Toolbox?

Mastering Ruby Debugging: Harnessing the Power of Pry

Blog Image
Is Pagy the Secret Weapon for Blazing Fast Pagination in Rails?

Pagy: The Lightning-Quick Pagination Tool Your Rails App Needs

Blog Image
From Development to Production: 7 Proven Methods for Automated Rails Application Deployment

Learn 7 proven methods for automating Rails deployments: from infrastructure as code to zero-downtime releases. Turn scary deployments into reliable, one-click processes. Start building better delivery today.

Blog Image
7 Powerful Ruby Debugging Techniques for Efficient Problem-Solving

Discover 7 powerful Ruby debugging techniques to streamline your development process. Learn to use puts, byebug, raise, pp, caller, logging, and TracePoint for efficient troubleshooting. Boost your coding skills now!