This Ruby gem lets you move your application logic into into small composable service objects. It is a lightweight framework that helps you keep your models and controllers thin.
Add these lines to your application's Gemfile:
# Composable service objects
gem 'service_actor'
Actors are single-purpose actions in your application that represent your
business logic. They start with a verb, inherit from Actor
and implement a
call
method.
# app/actors/send_notification.rb
class SendNotification < Actor
def call
# …
end
end
Trigger them in your application with .call
:
SendNotification.call # => <Actor::Result…>
When called, actors return a Result. Reading and writing to this result allows actors to accept and return multiple arguments. Let's find out how to do that.
To accept arguments, use input
to create a method named after this input:
class GreetUser < Actor
input :user
def call
puts "Hello #{user.name}!"
end
end
You can now call your actor by providing the correct arguments:
GreetUser.call(user: User.first)
An actor can return multiple arguments. Declare them using output
, which adds
a setter method to let you modify the result from your actor:
class BuildGreeting < Actor
output :greeting
def call
self.greeting = 'Have a wonderful day!'
end
end
The result you get from calling an actor will include the outputs you set:
result = BuildGreeting.call
result.greeting # => "Have a wonderful day!"
Inputs can be marked as optional by providing a default:
class BuildGreeting < Actor
input :name
input :adjective, default: 'wonderful'
input :length_of_time, default: -> { ['day', 'week', 'month'].sample }
output :greeting
def call
self.greeting = "Have a #{adjective} #{length_of_time} #{name}!"
end
end
This lets you call the actor without specifying those keys:
result = BuildGreeting.call(name: 'Jim')
result.greeting # => "Have a wonderful week Jim!"
If an input does not have a default, it will raise a error:
result = BuildGreeting.call
=> Actor::ArgumentError: Input name on BuildGreeting is missing.
You can add simple conditions that the inputs must verify, with the name of your
choice under must
:
class UpdateAdminUser < Actor
input :user,
must: {
be_an_admin: ->(user) { user.admin? }
}
# …
end
In case the input does not match, it will raise an argument error.
Sometimes it can help to have a quick way of making sure we didn't mess up our
inputs. For that you can use type
with the name of a class or an array of
possible classes it must be an instance of.
class UpdateUser < Actor
input :user, type: 'User'
input :age, type: %w[Integer Float]
# …
end
An exception will be raised if the type doesn't match.
By default inputs accept nil
values. To raise an error instead:
class UpdateUser < Actor
input :user, allow_nil: false
# …
end
All actors return a successful result by default. To stop the execution and
mark an actor as having failed, use fail!
:
class UpdateUser
input :user
input :attributes
def call
user.attributes = attributes
fail!(error: 'Invalid user') unless user.valid?
# …
end
end
This will raise an error in your app with the given data added to the result.
To test for the success of your actor instead of raising an exception, use
.result
instead of .call
. This lets you use success?
and failure?
on the
result.
For example in a Rails controller:
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def create
result = UpdateUser.result(user: user, attributes: user_attributes)
if result.success?
redirect_to result.user
else
render :new, notice: result.error
end
end
end
Any keys you add to fail!
will be added to the result, for example you could
do: fail!(error_type: "validation", error_code: "uv52", …)
.
To help you create actors that are small, single-responsibility actions, an
actor can use play
to call other actors:
class PlaceOrder < Actor
play CreateOrder,
Pay,
SendOrderConfirmation,
NotifyAdmins
end
This creates a call
method where each actor will be called, taking their
arguments from the previous actor's result. In fact, every actor along shares
the same result instance to help shape the final result your application needs.
When using play
, when an actor calls fail!
, the following actors will not be
called.
Instead, all the actors that succeeded will have their rollback
method called
in reverse order. This allows actors a chance to cleanup, for example:
class CreateOrder < Actor
output :order
def call
self.order = Order.create!(…)
end
def rollback
order.destroy
end
end
Rollback is only called on the previous actors in play
and is not called on
the failing actor itself. Actors should be kept to a single purpose and not have
anything to clean up if they call fail!
.
When using play
you can use succeed!
to stop the execution of the following
actors, but still consider the actor to be successful.
You can use inline actions using lambdas. Inside these lambdas, you don't have getters and setters but have access to the shared result:
class Pay < Actor
play ->(result) { result.payment_provider = "stripe" },
CreatePayment,
->(result) { result.user_to_notify = result.payment.user },
SendNotification
end
Like in this example, lambdas can be useful for small work or preparing the
result for the next actors. If you want to do more work before, or after the
whole play
, you can also override the call
method. For example:
class Pay < Actor
# …
def call
Time.with_timezone('Paris') do
super
end
end
end
Actors in a play can be called conditionally:
class PlaceOrder < Actor
play CreateOrder,
Pay
play NotifyAdmins, if: ->(ctx) { ctx.order.amount > 42 }
end
In your application, add automated testing to your actors as you would do to any other part of your applications.
You will find that cutting your business logic into single purpose actors makes your application much simpler to test.
This gem is heavily influenced by
Interactor ♥.
However there are a few key differences which make actor
unique:
- Does not hide errors when an actor fails inside another actor.
- Requires you to document all arguments with
input
andoutput
. - Defaults to raising errors on failures: actor uses
call
andresult
instead ofcall!
andcall
. This way, the default is to raise an error and failures are not hidden away because you forgot to use!
. - Allows defaults, type checking, requirements and conditions on inputs.
- Delegates methods on the context:
foo
vscontext.foo
,self.foo =
vscontext.foo =
, fail!vs
context.fail!`. - Shorter setup syntax: inherit from
< Actor
vs having toinclude Interactor
andinclude Interactor::Organizer
. - Organizers allow lambdas, being called multiple times, and having conditions.
- Allows triggering an early success with
succeed!
. - No
before
,after
andaround
hooks, prefer usingplay
with lambdas or overridingcall
.
Thank you to @nicoolas25, @AnneSottise & @williampollet for the early thoughts and feedback on this gem.
After checking out the repo, run bin/setup
to install dependencies. Then, run
rake
to run the tests and linting. You can also run bin/console
for an
interactive prompt.
To release a new version, update the version number in version.rb
, and then
run rake release
, which will create a git tag for the version, push git
commits and tags, and push the gem to rubygems.org.
Bug reports and pull requests are welcome on GitHub.
This project is intended to be a safe, welcoming space for collaboration, and everyone interacting in the project’s codebase and issue tracker is expected to adhere to the Contributor Covenant code of conduct.
The gem is available as open source under the terms of the MIT License.