ServiceActor
This Ruby gem lets you move your application logic into small composable service objects. It is a lightweight framework that helps you keep your models and controllers thin.
Contents
Installation
Add the gem to your application’s Gemfile by executing:
bundle add service_actor
Extensions
For Rails generators, you can use the service_actor-rails gem:
bundle add service_actor-rails
For TTY prompts, you can use the service_actor-promptable gem:
bundle add service_actor-promptable
Usage
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 # => <ServiceActor::Result…>
When called, an actor returns a result. Reading and writing to this result allows actors to accept and return multiple arguments. Let’s find out how to do that and then we’ll see how to chain multiple actors together.
Inputs
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)
Outputs
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:
actor = BuildGreeting.call
actor.greeting # => "Have a wonderful day!"
actor.greeting? # => true
If you only have one value you want from an actor, you can skip defining an
output by making it the return value of .call()
and calling your actor with
.value()
:
class BuildGreeting < Actor
input :name
def call
"Have a wonderful day, #{name}!"
end
end
BuildGreeting.value(name: "Fred") # => "Have a wonderful day, Fred!"
Fail
To stop the execution and mark an actor as having failed, use fail!
:
class UpdateUser < Actor
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 application 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
. You can then call success?
or failure?
on
the result.
For example in a Rails controller:
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def create
actor = UpdateUser.result(user: user, attributes: user_attributes)
if actor.success?
redirect_to actor.user
else
render :new, notice: actor.error
end
end
end
Play actors in a sequence
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,
PayOrder,
SendOrderConfirmation,
NotifyAdmins
end
Calling this actor will now call every actor along the way. Inputs and outputs will go from one actor to the next, all sharing the same result set until it is finally returned.
If you use .value()
to call this actor, it will give the return value of
the final actor in the play chain.
Rollback
When using play
, if 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!
.
Inline actors
For small work or preparing the result set for the next actors, you can create inline actors by using lambdas. Each lambda has access to the shared result. For example:
class PayOrder < Actor
input :order
play -> actor { actor.order.currency ||= "EUR" },
CreatePayment,
UpdateOrderBalance,
-> actor { Logger.info("Order #{actor.order.id} paid") }
end
You can also call instance methods. For example:
class PayOrder < Actor
input :order
play :assign_default_currency,
CreatePayment,
UpdateOrderBalance,
:log_payment
private
def assign_default_currency
order.currency ||= "EUR"
end
def log_payment
Logger.info("Order #{order.id} paid")
end
end
If you want to do work around the whole actor, you can also override the call
method. For example:
class PayOrder < Actor
# …
def call
Time.with_timezone("Paris") do
super
end
end
end
Play conditions
Actors in a play can be called conditionally:
class PlaceOrder < Actor
play CreateOrder,
Pay
play NotifyAdmins, if: -> actor { actor.order.amount > 42 }
play CreatePayment, unless: -> actor { actor.order.currency == "USD" }
end
Input aliases
You can use alias_input
to transform the output of an actor into the input of
the next actors.
class PlaceComment < Actor
play CreateComment,
NotifyCommentFollowers,
alias_input(commenter: :user),
UpdateUserStats
end
Input options
Defaults
Inputs can be optional by providing a default
value or lambda.
class BuildGreeting < Actor
input :name
input :adjective, default: "wonderful"
input :length_of_time, default: -> { ["day", "week", "month"].sample }
input :article,
default: -> context { context.adjective.match?(/^aeiou/) ? "an" : "a" }
output :greeting
def call
self.greeting = "Have #{article} #{length_of_time}, #{name}!"
end
end
actor = BuildGreeting.call(name: "Jim")
actor.greeting # => "Have a wonderful week, Jim!"
actor = BuildGreeting.call(name: "Siobhan", adjective: "elegant")
actor.greeting # => "Have an elegant week, Siobhan!"
Allow nil
By default inputs accept nil
values. To raise an error instead:
class UpdateUser < Actor
input :user, allow_nil: false
# …
end
Conditions
You can ensure an input is included in a collection by using inclusion
:
class Pay < Actor
input :currency, inclusion: %w[EUR USD]
# …
end
This raises an argument error if the input does not match one of the given values.
Declare custom conditions with the name of your choice by using must
:
class UpdateAdminUser < Actor
input :user,
must: {
be_an_admin: -> user { user.admin? }
}
# …
end
This will raise an argument error if any of the given lambdas returns a falsey value.
Types
Sometimes it can help to have a quick way of making sure we didn’t mess up our inputs.
For that you can use the type
option and giving a class or an array
of possible classes. If the input or output doesn’t match these types, an
error is raised.
class UpdateUser < Actor
input :user, type: User
input :age, type: [Integer, Float]
# …
end
You may also use strings instead of constants, such as type: "User"
.
When using a type condition, allow_nil
defaults to false
.
Custom input errors
Use a Hash
with is:
and message:
keys to prepare custom
error messages on inputs. For example:
class UpdateAdminUser < Actor
input :user,
must: {
be_an_admin: {
is: -> user { user.admin? },
message: "The user is not an administrator"
}
}
# ...
end
You can also use incoming arguments when shaping your error text:
class UpdateUser < Actor
input :user,
allow_nil: {
is: false,
message: (lambda do |input_key:, **|
"The value \"#{input_key}\" cannot be empty"
end)
}
# ...
end
See examples of custom messages on all input arguments
#### Inclusion ```ruby class Pay < Actor input :provider, inclusion: { in: ["MANGOPAY", "PayPal", "Stripe"], message: (lambda do |value:, **| "Payment system \"#value\" is not supported" end) } end ``` #### Must ```ruby class Pay < Actor input :provider, must: { exist: { is: -> provider { PROVIDERS.include?(provider) }, message: (lambda do |value:, **| "The specified provider \"#value\" was not found." end) } } end ``` #### Default ```ruby class MultiplyThing < Actor input :multiplier, default: { is: -> { rand(1..10) }, message: (lambda do |input_key:, **| "Input \"#input_key\" is required" end) } end ``` #### Type ```ruby class ReduceOrderAmount < Actor input :bonus_applied, type: { is: [TrueClass, FalseClass], message: (lambda do |input_key:, expected_type:, given_type:, **| "Wrong type \"#given_type\" for \"#input_key\". " \ "Expected: \"#expected_type\"" end) } end ``` #### Allow nil ```ruby class CreateUser < Actor input :name, allow_nil: { is: false, message: (lambda do |input_key:, **| "The value \"#input_key\" cannot be empty" end) } end ```Testing
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 will make it easier for you to test your application.
FAQ
Howtos and frequently asked questions can be found on the wiki.
Thanks
This gem is influenced by (and compatible with) Interactor.
Thank you to the wonderful contributors.
Thank you to @nicoolas25, @AnneSottise & @williampollet for the early thoughts and feedback on this gem.
Photo by Lloyd Dirks.
Contributing
See CONTRIBUTING.md.
License
The gem is available as open source under the terms of the MIT License.