Dependency Injection in Ruby

· 5 min read · Updated March 31, 2026 · intermediate
ruby dependency-injection design-patterns guides

Dependency injection is a design pattern where an object receives the dependencies it needs from the outside rather than creating them internally. This simple idea has a big impact: it makes your code easier to test, easier to change, and easier to reason about.

In Ruby, you have several ways to implement dependency injection. This guide walks through each approach with practical examples.

What Problem Does It Solve?

Consider a class that sends email notifications:

class OrderNotifier
  def initialize(order)
    @order = order
  end

  def send_confirmation
    mailer = Mailer.new          # creates dependency internally
    mailer.deliver(to: @order.email, subject: "Order confirmed")
  end
end

The problem here: OrderNotifier creates its own Mailer instance. You cannot easily swap the mailer for a test double, and you cannot reuse OrderNotifier with a different mailer implementation.

With dependency injection, the Mailer is passed in from outside:

class OrderNotifier
  def initialize(order, mailer)
    @order = order
    @mailer = mailer
  end

  def send_confirmation
    @mailer.deliver(to: @order.email, subject: "Order confirmed")
  end
end

Now the caller decides which mailer to use. The class itself has no say.

Constructor Injection

Constructor injection passes dependencies through the initializer. This is the most common and recommended form of dependency injection.

class PaymentProcessor
  def initialize(payment_gateway, logger)
    @payment_gateway = payment_gateway
    @logger = logger
  end

  def charge(order, amount)
    @logger.info("Charging #{amount} for order #{order.id}")
    @payment_gateway.charge(order.customer_id, amount)
  end
end

Usage in production:

gateway = StripeGateway.new(api_key: "sk_live_...")
logger = FileLogger.new("/var/log/payments.log")
processor = PaymentProcessor.new(gateway, logger)
processor.charge(order, 99.99)

Usage in tests:

fake_gateway = double("PaymentGateway")
allow(fake_gateway).to receive(:charge).and_return(true)

fake_logger = double("Logger")
allow(fake_logger).to receive(:info)

processor = PaymentProcessor.new(fake_gateway, fake_logger)
processor.charge(order, 99.99)

Constructor injection makes dependencies explicit and visible. If you instantiate the object without the right dependencies, Ruby raises an ArgumentError immediately.

Setter Injection

Setter injection uses setter methods to inject dependencies after object creation. This is useful when a dependency is optional or when you want to defer its injection.

class ReportGenerator
  def formatter=(formatter)
    @formatter = formatter
  end

  def renderer=(renderer)
    @renderer = renderer
  end

  def generate(data)
    formatted = @formatter.format(data)
    @renderer.render(formatted)
  end
end

The object can be used without the dependencies set, but you need to handle the case where they are nil:

def generate(data)
  raise "Formatter not set" unless @formatter
  formatted = @formatter.format(data)
  @renderer ? @renderer.render(formatted) : formatted
end

Setter injection is common in frameworks like Ruby on Rails, where controllers receive dependencies through setters configured by the framework.

Method Injection

Method injection passes a dependency directly to the method that needs it. This is useful when a dependency varies with each method call.

class TaxCalculator
  def calculate(order, tax_rate_provider)
    rate = tax_rate_provider.current_rate(order.region)
    order.subtotal * rate
  end
end

Here, tax_rate_provider is only needed for the calculate method and changes based on context. Passing it as an argument keeps the object stateless with respect to tax rates.

Method injection is lightweight and works well for one-off or situational dependencies.

The Dependency Injection Container

When an application has many objects with many dependencies, manually wiring everything becomes tedious. A DI container centralizes the recipe for building objects.

class Container
  def initialize
    @services = {}
  end

  def register(key, &block)
    @services[key] = block
  end

  def resolve(key)
    @services[key].call(self)
  end
end

Register services:

container = Container.new

container.register(:logger) { FileLogger.new("app.log") }

container.register(:mailer) do |c|
  SmtpMailer.new(
    host: "smtp.example.com",
    logger: c.resolve(:logger)
  )
end

container.register(:order_notifier) do |c|
  OrderNotifier.new(c.resolve(:mailer))
end

Resolve dependencies:

notifier = container.resolve(:order_notifier)

The container handles the wiring. Each service is built on demand, and the container ensures the correct dependency graph is constructed.

For more complex applications, consider using a dedicated gem like dry-container or rom-repository, which provide structured DI container implementations.

Injecting Stubs and Mocks for Testing

Testing is where dependency injection really shines. By injecting test doubles, you can test a class in isolation without touching external systems.

class Order
  attr_reader :id, :subtotal, :status

  def initialize(id, subtotal)
    @id = id
    @subtotal = subtotal
    @status = :pending
  end

  def complete!
    @status = :completed
  end
end

A service that depends on persistence:

class OrderService
  def initialize(repository)
    @repository = repository
  end

  def place_order(order)
    @repository.save(order)
    order.complete!
  end
end

Testing with a stub:

class FakeOrderRepository
  def initialize
    @saved = []
  end

  attr_reader :saved

  def save(order)
    @saved << order
    order
  end
end

fake_repo = FakeOrderRepository.new
service = OrderService.new(fake_repo)
order = Order.new(1, 99.99)

service.place_order(order)

expect(order.status).to eq(:completed)
expect(fake_repo.saved).to include(order)

You test OrderService without a database. The fake repository lets you assert that save was called with the right object.

Relationship to Duck Typing and Composition

Dependency injection pairs naturally with duck typing. Because Ruby objects are defined by what they do, not what class they inherit from, any object that responds to the right messages can fill in for another.

class JSONSerializer
  def dump(data)
    JSON.generate(data)
  end
end

class XMLSerializer
  def dump(data)
    # XML generation logic
    "<data>#{data}</data>"
  end
end

class DataExporter
  def initialize(serializer)
    @serializer = serializer
  end

  def export(data)
    @serializer.dump(data)
  end
end

Both JSONSerializer and XMLSerializer implement dump(data). DataExporter does not care which one it receives — duck typing makes them interchangeable. This is composition at work: the exporter is composed of a serializer that it receives from the outside.

This approach follows the Interface Segregation Principle: your class only depends on the behavior it actually uses. You do not care about the full class, only that the object responds to the messages you send.

Practical Example: Refactoring to DI

Here is a before-and-after of a notification system.

Before — tight coupling:

class UserRegistration
  def initialize(username, email)
    @username = username
    @email = email
  end

  def register
    db = PostgreSQLDatabase.new
    db.execute("INSERT INTO users ...")
    SlackNotifier.notify("New user: #{@username}")
  end
end

After — dependency injection:

class UserRegistration
  def initialize(username, email, database:, notifier:)
    @username = username
    @email = email
    @database = database
    @notifier = notifier
  end

  def register
    @database.save_user(username: @username, email: @email)
    @notifier.notify("New user: #{@username}")
  end
end

Usage:

db = PostgreSQLDatabase.new
notifier = SlackNotifier.new(webhook_url: "https://hooks.slack.com/...")
registrar = UserRegistration.new("alice", "alice@example.com",
                                  database: db,
                                  notifier: notifier)
registrar.register

Testing:

fake_db = FakeDatabase.new
fake_notifier = FakeNotifier.new
registrar = UserRegistration.new("alice", "alice@example.com",
                                  database: fake_db,
                                  notifier: fake_notifier)
registrar.register

expect(fake_db.users).to include(hash_including(username: "alice"))
expect(fake_notifier.messages).to include(/New user: alice/)

The refactored version is honest about what it needs, easy to test in isolation, and flexible enough to swap out database or notification backends without changing the UserRegistration class itself.

See Also