• Stars
    star
    709
  • Rank 63,834 (Top 2 %)
  • Language
    Ruby
  • License
    MIT License
  • Created over 4 years ago
  • Updated about 1 month ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

Composable Ruby service objects

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.

Photo of theater seats

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

class Pay < Actor
  input :provider,
        inclusion: {
          in: ["MANGOPAY", "PayPal", "Stripe"],
          message: (lambda do |value:, **|
            "Payment system \"#{value}\" is not supported"
          end)
        }
end

Must

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

class MultiplyThing < Actor
  input :multiplier,
        default: {
          is: -> { rand(1..10) },
          message: (lambda do |input_key:, **|
            "Input \"#{input_key}\" is required"
          end)
        }
end

Type

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

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.

More Repositories

1

so-nice

Small Web interface to control iTunes, Spotify, Rdio, MPD, Rhythmbox, Amarok and XMMS2. ♫
Ruby
145
star
2

edith

The zero-UI Web notepad
PHP
104
star
3

graph_attack

Ruby GraphQL analyser for blocking & throttling calls by IP
Ruby
48
star
4

anyplayer

Interact with the currently running music player. Supports iTunes Mac, iTunes Windows, Spotify Mac, Rdio Mac, MPD, Rhythmbox, Amarok and XMMS2.
Ruby
46
star
5

provide-html5

Drop-and-forget bunch of scripts that mimick some missing html5 goodies automatically
JavaScript
26
star
6

carrierwave-color

Store the dominant color of an image with CarrierWave
Ruby
22
star
7

active_currency

Rails plugin to store currency rates in the database
Ruby
18
star
8

handle_invalid_percent_encoding_requests

Rails Engine to handle badly encoded requests
Ruby
17
star
9

rateaux

A few useful rake tasks for Rails
Ruby
17
star
10

action_mailer_auto_url_options

Make ActionMailer use the current request host and protocol for URL generation
Ruby
15
star
11

craster

Create PNG thumbnails from STL 3D models
JavaScript
14
star
12

pouce

Cute PHP directory lister, index-of style
PHP
12
star
13

actor-rails

Actor Rails provides Rails support to the Actor service objects
Ruby
11
star
14

dev-ideas

A issues-only repository to store a few code ideas
10
star
15

daylist

A small ruby program for unit-testing one's life
Ruby
10
star
16

google-lucky-image

PHP script that returns the first image in a Google Image search
PHP
9
star
17

git-deploy

git alias to do remote git pulls.
Shell
7
star
18

route_localize

Rails 4 engine to translate routes using locale files and subdomains.
Ruby
7
star
19

bigbuttons

Big buttons which one punches to make sweet HTML5 <audio> sound
JavaScript
7
star
20

killbills

Rails app to track expenses with friends
Ruby
7
star
21

srename

Quickly rename TV Series files
Ruby
6
star
22

all3dp

Ruby gem to send 3D files to Craftcloud, All3DP's 3D Printing Price Comparison Service API
Ruby
5
star
23

rimes

Ruby script to find rhymes
Ruby
5
star
24

meuh

A stupid Slack and IRC Bot AI
Ruby
5
star
25

iou

Rails app to track expenses with your friends
JavaScript
5
star
26

git-ics

Makes an icalendar file from a git or github repository
Ruby
5
star
27

html5-slides

A very light slide-show-system using html5 and jQuery
JavaScript
5
star
28

mariokartwiit

Finds your friends' Mario Kart Wii codes on Twitter
Ruby
5
star
29

layout_values

Rails plugin to add helpers to indicate page titles and meta description.
Ruby
5
star
30

wubmail

Ruby lib and cli to send erb emails to people in a CSV
Ruby
5
star
31

wordpress-svn-update

WordPress plugin to update WordPress and svn:external plugins in one click
4
star
32

damn_hotlinkers

Script to use against an HTTP log to find out who points directly towards your files
Ruby
4
star
33

pw-conference-generator

Générateur de conférences Paris Web
JavaScript
4
star
34

frugal

Tiny french Rails app to follow my personnal expenses
JavaScript
3
star
35

m3uh

A PHP m3u music playlist generator
PHP
3
star
36

deezer-scrobbler-userscript

Adds to Last.fm the songs played on Deezer.com
JavaScript
3
star
37

miniatures

PHP script that creates and spits back thumbnails on-the-fly
PHP
3
star
38

remotransmission

Command line interface for remote Transmission
Ruby
3
star
39

difffeed

Makes an RSS feed of the new and deleted files in a path
Ruby
3
star
40

irclink

Stargate between IRC channels
Python
2
star
41

raise_js

Raise JavaScript errors as Ruby exceptions in your Rails app
Ruby
2
star
42

ircbot

IRC Bot supporting plugins
Python
2
star
43

tangram-importer

Web API to import contacts from Google, Yahoo!, etc. with no passwords
Ruby
2
star
44

gnome_random_wp

Periodically change your wallpaper under Gnome
Python
2
star
45

emailid

Rails OpenID provider authenticating users by their email
Ruby
2
star
46

slash3d

Ruby gem to access 3D Slash's API
Ruby
2
star
47

allocine

Allocine.fr parser
Ruby
2
star
48

hubs3d

Ruby gem to access 3D Hub's API
Ruby
2
star
49

carrierwave-processing-dominant_color

Store the dominant color of an image with CarrierWave (MOVED to carrierwave-color)
2
star
50

sunny.github.io

HTML
1
star
51

restaurants-api

Ruby
1
star
52

luc

Slack bot that fetches data from Cults
Ruby
1
star
53

devise-track_locale

Adds support to Devise for remembering a user's last locale
Ruby
1
star
54

reach5

Ruby gem for Reach5's Customer Identity Management Platform API
Ruby
1
star
55

chromium-buildpack

Shell
1
star
56

aoc

Ruby
1
star