Dependency Injection in Ruby
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
- /guides/ruby-struct-guide/ — Structs are often used alongside DI to create lightweight data objects that hold dependencies
- /guides/ruby-blocks-procs-lambdas/ — Lambdas and procs are useful for injectable callable objects in DI patterns
- /guides/ruby-comparable-and-enumerable/ — Composition and enumerables relate to how DI enables flexible object collaboration