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.