Payload
Payload is a lightweight framework for specifying, injecting, and using dependencies in Ruby on Rails applications. It facilitates run-time assembly of dependencies and makes it plausible to use inversion of control beyond the controller level. It also attempts to remove boilerplate code for common patterns such as defining factories and applying decorators.
Overview
The framework makes it easy to define dependencies from application code without resorting to singletons, constants, or globals. It also won't cause any class-reloading issues during development.
The central object in the framework is a "container." Dependencies are specified using Ruby configuration files. Configured dependencies are then available anywhere a reference to the container is available.
Define simple dependencies in config/dependencies.rb
using services:
service :payment_client do |container|
PaymentClient.new(ENV['PAYMENT_HOST'])
end
The configuration block receives a reference to the container, which will contain all configured dependencies. This makes it possible to define dependencies without knowing how or when their sub-dependencies will be defined.
Controllers receive a reference named dependencies
, so you can easily inject
dependencies into controllers.
For example, in app/controllers/payments_controller.rb
:
class PaymentsController < ApplicationController
def create
payment = Payment.new(params[:payment], dependencies[:payment_client])
receipt = payment.process
redirect_to receipt
end
end
You can easily test this dependency in a controller spec:
describe PaymentsController do
describe '#create' do
it 'processes a payment' do
payment_params = { product_id: '123', amount: '25' }
client = stub_service(:payment_client)
payment = double('payment', process: true)
Payment.stub(:new).with(payment_params, client).and_return(payment)
post :create, payment_params
expect(payment).to have_received(:process)
end
end
end
You can further invert control and use factories to hide low-level dependencies from controllers entirely.
In config/dependencies.rb
:
factory :payment do |container, attributes|
Payment.new(attributes, container[:payment_client])
end
In app/controllers/payments_controller.rb
:
class PaymentsController < ApplicationController
def create
payment = dependencies[:payment].new(params[:payment])
payment.process
redirect_to payment
end
end
You can also stub factories in tests:
describe PaymentsController do
describe '#create' do
it 'processes a payment' do
payment_params = { product_id: '123', amount: '25' }
payment = stub_factory_instance(:payment, payment_params)
post :create, payment_params
expect(payment).to have_received(:process)
end
end
end
The controller and its tests are now completely ignorant of payment_client
,
and deal only with the collaborator they need: payment
.
Setup
Add payload to your Gemfile:
gem 'payload'
To access dependencies from controllers, include the Controller
module:
class ApplicationController < ActionController::Base
include Payload::Controller
end
Specifying Dependencies
Edit config/dependencies.rb
to specify dependencies.
Use the service
method to define dependencies which can be fully instantiated
when the application boots:
service :payment_client do |container|
PaymentClient.new(ENV['PAYMENT_HOST'])
end
Other dependencies are accessible from the container:
service :payment_notifier do |container|
PaymentNotifier.new(container[:mailer])
end
Use the factory
method to define dependencies which require dependencies from
the container as well as runtime state which varies per-request:
factory :payment do |container, attributes|
Payment.new(attributes, container[:payment_client])
end
Any additional arguments passed to new
when instantiating the factory will
also be passed to the factory definition block.
Use the decorate
method to extend or replace a previously defined dependency:
decorate :payment do |payment, container|
NotifyingPayment.new(payment, container[:payment_notifier])
end
Decorated dependencies have access to other dependencies through the container, as well as the current definition for that dependency.
Using Dependencies
The Railtie inserts middleware into the stack which will inject a container into
the Rack environment for each request. This is available as dependencies
in
controllers and env[:dependencies]
in the Rack stack.
Use []
to access services:
class PaymentsController < ApplicationController
def create
dependencies[:payment_client].charge(params[:amount])
redirect_to payments_path
end
end
Use new
to instantiate dependencies from factories:
class PaymentsController < ApplicationController
def create
payment = dependencies[:payment].new(params[:payment])
payment.process
redirect_to payment
end
end
All arguments to the new
method will be passed to the factory definition
block.
Grouping Dependencies
You can enforce simplicity in your dependency graph by grouping dependencies and explicitly exporting only the dependencies you need to expose to the application layer.
For example, you can specify payment dependencies in
config/dependencies/payments.rb
:
service :payment_client do |container|
PaymentClient.new(ENV['PAYMENT_HOST'])
end
service :payment_notifier do |container|
PaymentNotifier.new(container[:mailer])
end
factory :payment do |container, attributes|
Payment.new(attributes, container[:payment_client])
end
decorate :payment do |payment, container|
NotifyingPayment.new(payment, container[:payment_notifier])
end
export :payment
In this example, the final, decorated :payment
dependency will be available in
controllers, but :payment_client
and :payment_notifier
will not.
You can use this approach to hide low-level dependencies behind a facade and only expose the facade to the application layer.
Testing
To activate testing support, require and mix in the Testing
module:
require 'payload/testing'
RSpec.configure do |config|
config.include Payload::Testing
end
During integration tests, the fully configured container will be used. During controller tests, an empty container will be initialized for each test. Tests can inject the dependencies they need for each interaction.
This module provides two useful methods:
stub_service
: Injects a stubbed service into the test container and returns it.stub_factory_instance
: Finds or injects a stubbed factory into the test container and expects an instance to be created with the given attributes.
Contributing
Please see the contribution guidelines.
License
Payload is Copyright © 2014 Joe Ferris and thoughtbot. It is free software, and may be redistributed under the terms specified in the LICENSE file.
Payload is maintained and funded by thoughtbot, inc.
The names and logos for thoughtbot are trademarks of thoughtbot, inc.