Actionable actions encapsulate business logic in a composable way that makes it easy to change when requirements change.
It draws inspiration heavily on Trailblazer's Operation gem although
it has a much smaller and simple scope. Still it provides the means to remove business logic from places like Rails
controllers and models, Sidekiq processors, etc. into a shared set of actions with defined steps.
Add this line to your application's Gemfile:
gem 'actionable'
And then execute:
$ bundle
Or install it yourself as:
$ gem install actionable
Let's look at a simple and contrived example:
class CreateInvoice < Actionable::Action
set_model :invoice
step :build
step :validate
step :create
def initialize(params)
super()
@params = params
end
def build
@invoice = Invoice.new
end
def validate
validator = InvoiceValidator.new @invoice
return unless validator.valid?
fail :invalid, 'The invoice was invalid', validator.errors
end
def create
@invoice.save!
end
end
The basic principle is that once we run an action it will follow through with each step. If no steps declare an early success or failure then it will automatically declare success. In any case it will return a result object with some basic properties:
code
: either:success
or some other error code (e.g.:invalid
).message
: a human friendly error message (e.g.The invoice was invalid
).errors
: a hash containing errors (optional).
In the case that any of the steps throws an unguarded exception then processing will stop and the exception will
bubble up to be dealt with. If you are setting a model (e.g. set_model :invoice
) then Actionable will wrap the step's
execution in an ActiveRecord::Transaction
so that it will all succeed or nothing will be committed to the database.
Following the previous example we could use that action in a controller like so:
class CustomerMailerController < ApplicationController
def create
result = CreateInvoice.run params
case result.code
when :success
redirect_to invoice_path(result.invoice.id)
when :invalid
flash[:error] = result.message
render :edit
end
end
end
Now we have removed all business logic into a simple service object and we can leave the controller to take care of
providing the parameters from the user and redirect traffic depending on the results. Notice that the result also
contains all instance variables
created in the process as ready to use methods (e.g. result.invoice
).
Encapsulating all this business logic also makes it easier to add more features when requirements change. Let's say that later we find out that we need to send a notification to our customer. We can easily add another step like so:
class CreateInvoice < Actionable::Action
# ...
step :notify
# ...
def notify
CustomerMailer.send_invoice @invoice
end
end
We know that all these steps will execute successfully or the action will fail.
Steps are able to take an optional options hash. The keys are :if
, :unless
, :params
, and :fixtures
. :if
and :unless
can take either a symbol, which will call a method in the class, or they can take a block. They control if that step is executed or not.
class CreateInvoice < Actionable::Action
step :notify, if: :notify_customer?
end
class CreateInvoice < Actionable::Action
step :notify, unless: { |instance| instance.current_user.disabled_notifications? }
end
An action step can also point to another action to run through all of that action's steps. Maybe we already have an action setup to notify customer's via email and text messages. You can pass parameters, such as the @invoice
we've already created in an array of symbols, like so params: %i[invoice]
class CreateInvoice < Actionable::Action
step NotifyCustomer, params: %i[invoice]
end
And here is an example of the NotifyCustomer
class:
class NotifyCustomer < Actionable::Action
step :email
step :sms
def initialize(deliverable)
super()
@deliverable = deliverable
end
def email
CustomerMailer.send_email @deliverable
end
def sms
CustomerSmsDeliverer.send_sms @deliverable
end
end
Finally, by using the :fixtures
option, you can control which instance variables get sent back when we're calling another actionable action class and are saved in our fixtures and returned.
class CreateInvoice < Actionable::Action
step NotifyCustomer, params: %i[invoice], fixtures: %i[email sms]
end
class NotifyCustomer < Actionable::Action
step :email
step :sms
def initialize(deliverable)
super()
@deliverable = deliverable
end
def email
@email = CustomerMailer.send_email @deliverable
end
def sms
@sms = CustomerSmsDeliverer.send_sms @deliverable
end
end
We can also use case statements in our action steps, called case_steps
. This will allow us to conditionally execute some steps, just like a case statement does. The second, and optional third, arguments to on
are just like normal steps
where you can either pass a symbol or string to call a method, or another Actionable::Action
class.
class ReceiveAchStatus < ::Actionable::Action
:attr_reader :ach_status
case_step :ach_status do
on 'sent', :sent
on 'settled', :settled
on %w[returned internally_returned], :returned
end
def initialize(ach_status)
super()
@ach_status = ach_status
end
def sent
end
def settled
end
def returned
end
end
There are special steps that are only run if the main steps were successful or if they failed. These are called success_steps
and failure_steps
. They work just like any other step, excpet that they are always run at the end.
class CreateInvoice < ::Actionable::Action
step :build
step :validate
step :create
on_failure :log_failure
on_failure :build_failure_response
on_success :build_success_response
def initialize(params)
super()
@params = params
end
def build
end
def vaidate
end
def create
end
def log_failure
logger.warn "failed to create invoice with params: #{@params}"
end
def build_failure_response
@response = {
status: 'error',
message: 'failed to create invoice'
}
end
def build_success_response
@response = {
status: 'success'
}
end
end
There are a couple of special methods that can be called to immediately short circuit the execution of the steps if we know that everything was successful or if things failed early. They are succeed!
and fail!
. In the following example, we won't get to the create
step if the amount is missing because we'll fail before then. succeed!
is going to work the exact same way, it'll just cause the result to have a status of success
rather than fail
.
There are also fail
and sucess
methods, without the bang. These will not short circuit the execution, but will create either a failure or success object, and continue execution. So, because of this, in the example, if we're missing only the name, but not the amount, we'll still go through and complete creating the invoice, but we'll end up with a failure object letting us know that the name was missing. However, if the amount is missing, we won't go on to actually create the invoice after validation.
class CreateInvoice < ::Actionable::Action
step :build
step :validate
step :create
def initialize(params)
super()
@params = params
end
def build
end
def validate
fail! :amount_location, "Amount missing" unless @params[:amount].present?
# additional code
fail :name_invalid, "Name missing" unless @params[:name].present?
# this code is still run after a `fail`
end
def create
end
end
Any instance variables that get created while running through the steps will be available in the result object in a fixtures attribute. For convenience, there are also methods setup on the result object to call those instance variables directly.
class CreateInvoice < ::Actionalbe::Action
step :build
def initialize(params)
super()
@params = params
end
def build
@invoice = Invoice.new
end
end
result = CreateInvoice.run({})
result.params
# => {}
result.invoice
# => #<Invoice invoice_number=1234>
result
# => #<Actionable::Success code=:success, message="Completed successfully.", errors={}, fixtures=["invoice", "params"]>
To make testing easier, a couple of RSpec stubs have been added if you require actionable/rspec/stubs
. The stubs are stub_actionable_success
/allow_actionable_success
and stub_actionable_failure
/allow_actionable_failure
.
stub_actionable_success
/allow_actionable_success
take the klass and an optional hash of fixtures and will return a success object with the fixtures you specified. stub_actionable_failure
/allow_actionable_failure
takes the klass, error_code, optional error_message, and an optional hash of fixtures and will return a failure object with the code, message, and fixtures specified. The stub versions of these run with an expectation that the klass will be called with run
, while the allow version simply allows it to run if we need it, but isn't required.
Rspec.describe CreateInvoice do
let(:invoice_params) { { amount: 1234.56 } }
before { allow_actionable_success CreateInvoice, invoice_params: invoice_params }
context "without errors" do
let(:result) { CreateInvoice.run(invoice_params) }
before { expect_actionable_success CreateInvoice, invoice_params: invoice_params }
it "returns a success object" do
expect(result.code).to eq(:success)
end
end
context "with errors" do
let(:invalid_invoice_params) { { amount: nil } }
let(:result) { CreateInvoice.run(invalid_invoice_params) }
before { expect_actionable_failure CreateInvoice, :invalid_params, "Amount was invalid", invoice_params: invalid_invoice_params }
it "returns a failure object" do
expect(result.code).to eq(:failure)
end
end
end
After checking out the repo, run bin/setup
to install dependencies. Then, run rake spec
to run the tests. You can also run bin/console
for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To release a new version, update the version number in version.rb
, and then run bundle exec rake release
, which will create a git tag for the version, push git commits and tags, and push the .gem
file to rubygems.org.
Bug reports and pull requests are welcome on GitHub at https://github.com/acima-credit/actionable. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.
The gem is available as open source under the terms of the MIT License.