sunny / actor

Composable Ruby service objects
MIT License
714 stars 29 forks source link
ruby-on-rails 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 ```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.