rubyguides

Dependency injection in Ruby: constructor, setter, and method patterns

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.

The key idea is simple, but the effect is bigger than it first looks. When a class does not create its own collaborators, the class becomes easier to swap, easier to test, and easier to reuse in a different environment. That is why dependency injection shows up in everything from small command objects to large Rails applications.

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. The class is coupled to one concrete mailer, which means every test either sends real email (slow and unreliable) or monkey-patches the Mailer constant (brittle and order-dependent).

With dependency injection, the Mailer is passed in from the outside, and the class only knows about the interface it needs:

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.

This is the core tradeoff. The class becomes a little more explicit about what it needs, but the gain is worth it because the caller can provide the right dependency for production, tests, or a one-off script. That flexibility is what makes the pattern useful in Ruby code that needs to grow over time.

constructor injection

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

It works well when a dependency is required for the object to do its job at all. In that case, putting the dependency in initialize makes the contract obvious and prevents half-initialized objects from slipping through.

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:

When the application runs for real, you pass concrete implementations that connect to live services. The PaymentProcessor receives a real payment gateway and a real logger, and the wiring is visible in one place where the object is created.

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:

In a test, you swap in doubles that implement the same interface but never touch a network or filesystem. The test verifies the object’s behavior, not the gateway’s, and the setup stays fast and reliable because no external state is involved.

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.

That early failure is helpful. Instead of discovering missing dependencies halfway through a request, you learn about the problem the moment the object is created. Tests benefit from the same rule because the setup has to match the object’s real needs.

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.

The pattern works best when the object can still exist without the dependency, but some later step is allowed to supply it. That gives you a little more flexibility than constructor injection, but it also means you need to guard against nil values or missing configuration.

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. A common pattern is to raise a clear error in the method body or provide a sensible default, so the caller gets useful feedback instead of a cryptic NoMethodError on nil. Here is the defensive version of generate:

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.

Because the dependency arrives later, the code that uses it needs to be careful. A method that depends on @formatter or @renderer should either raise a clear error or provide a sensible fallback instead of assuming the value is always present.

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.

It keeps the object itself lean because the dependency only exists for the duration of the call. That is a good fit for things like pricing rules, rate providers, or feature flags that can change from one request to the next.

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 downside is that the method signature can grow if too many collaborators get pushed in at once. When that happens, constructor injection or a small helper object usually reads better.

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.

The container does not replace dependency injection, it organizes it. Instead of constructing every object in-line, you describe the wiring once and let the container resolve the graph for you. That can be helpful in larger systems where several objects share the same lower-level collaborators.

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:

Each service is defined as a block that the container can call later. The block receives the container itself, so a service can ask for other services by name during construction. This creates a lazy dependency graph where nothing is built until it is first requested.

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:

Calling resolve on the container runs the registered block and returns the fully constructed object. Each resolution triggers the construction of any dependencies that service needs, so the full object graph is built on demand rather than all at once.

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.

The biggest advantage here is consistency. Every caller gets the same wiring rules, and the application no longer has to repeat the same assembly logic in multiple places. That reduces drift between production code, tests, and scripts.

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.

That isolation makes tests faster and more precise. A test double can stand in for a database, email provider, or payment gateway, which lets you focus on the behavior of the class under test instead of spending time setting up real infrastructure.

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:

The OrderService receives a repository object through its constructor. It does not know whether that repository talks to a database, a file, or an in-memory store. It only knows that save(order) exists. This is dependency injection at its most practical: the service focuses on the workflow, and the repository handles storage.

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

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

Testing with a stub:

A fake repository lets you verify the interaction without a real database. The test checks two things: that the order status changed to completed and that the repository received the order object. Both assertions are fast, reliable, and independent of any external system.

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.

This is where dependency injection and testing fit together cleanly. When the dependency comes from the outside, the test can provide a tiny fake with the same interface and verify the interaction directly.

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.

That makes the injected object easy to replace without a special interface layer. As long as the replacement responds to the same methods, the class using it does not need to know whether it is talking to a real service, a fake, or a lightweight wrapper.

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.

That is the same shape as dependency injection in general. You pass in the behavior you need, then let the object concentrate on its own work instead of learning how to build every collaborator itself.

practical example: refactoring to di

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

The point of the refactor is not just to move code around. It is to make the object easier to extend, easier to test, and easier to read when another developer comes back to it later.

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:

The refactored version accepts its collaborators as keyword arguments. The keyword-argument style makes the dependency list self-documenting at the call site, so anyone reading the instantiation code can see what the object needs without opening the class definition.

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 in production follows the same pattern as the constructor injection examples earlier: you build concrete implementations and pass them as keyword arguments. The keyword-argument style makes the dependency list self-documenting at each call site, so anyone reading the instantiation code can see what the object needs without opening the class definition:

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 the refactored class takes the same shape as the production code, but with fake collaborators that never touch a real database or external service. You create test doubles, pass them through the same keyword arguments, and verify the interactions without hitting any network or filesystem:

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.

That final property is the heart of dependency injection. The class says what it needs, the caller chooses how to satisfy that need, and the rest of the code stays free to evolve without forcing a rewrite of the object itself.

See Also