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.
Add the gem to your application’s Gemfile by executing:
bundle add service_actorFor Rails generators, you can use the service_actor-rails gem:
bundle add service_actor-railsFor TTY prompts, you can use the service_actor-promptable gem:
bundle add service_actor-promptableActors 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
endTrigger 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.
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
endYou 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
endThe result you get from calling an actor will include the outputs you set:
actor = BuildGreeting.call
actor.greeting # => "Have a wonderful day!"
actor.greeting? # => trueTo 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
endThis 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
endTo 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
endCalling 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.
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
endRollback 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!.
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") }
endYou 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
endIf 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
endActors 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" }
endYou 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
endInputs 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 =~ /^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!"By default inputs accept nil values. To raise an error instead:
class UpdateUser < Actor
input :user, allow_nil: false
# …
endYou can ensure an input is included in a collection by using inclusion:
class Pay < Actor
input :currency, inclusion: %w[EUR USD]
# …
endThis 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? }
}
# …
endThis will raise an argument error if any of the given lambdas returns a falsey value.
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]
# …
endYou may also use strings instead of constants, such as type: "User".
When using a type condition, allow_nil defaults to false.
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"
}
}
# ...
endYou 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)
}
# ...
endSee examples of custom messages on all input arguments
class Pay < Actor
input :provider,
inclusion: {
in: ["MANGOPAY", "PayPal", "Stripe"],
message: (lambda do |value:, **|
"Payment system \"#{value}\" is not supported"
end)
}
endclass Pay < Actor
input :provider,
must: {
exist: {
is: -> provider { PROVIDERS.include?(provider) },
message: (lambda do |value:, **|
"The specified provider \"#{value}\" was not found."
end)
}
}
endclass MultiplyThing < Actor
input :multiplier,
default: {
is: -> { rand(1..10) },
message: (lambda do |input_key:, **|
"Input \"#{input_key}\" is required"
end)
}
endclass 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)
}
endclass CreateUser < Actor
input :name,
allow_nil: {
is: false,
message: (lambda do |input_key:, **|
"The value \"#{input_key}\" cannot be empty"
end)
}
endIn 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.
Howtos and frequently asked questions can be found on the wiki.
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.
See CONTRIBUTING.md.
The gem is available as open source under the terms of the MIT License.
