Open-source project
sunny/actor avatar
sunny/actor

ServiceActor: composable Ruby service objects with inputs, outputs and rollback

Composable Ruby service objects

775 stars33 forksRubyMIT

At a glance

What is it?
The gem published as service_actor from the sunny/actor repository turns application logic into small actors that declare inputs and outputs, pass results down a play chain, and roll back completed steps when one fails. It suits Rails codebases whose models and controllers have grown fat, and it asks you to accept a fixed calling convention.
Who is it for?
ServiceActor is the right fit for a Rails application whose controllers and models have absorbed business logic and whose team wants one object per action with a declared interface, and it is a poor fit for a library that needs a plain method call with no result object, or for a codebase that already has a service layer convention you would have to replace.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 87 days ago.
What is it written in?
Mainly Ruby, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

One object per business action, with a declared interface

ServiceActor is a lightweight Ruby framework for moving application logic out of models and controllers into small service objects. The unit of work is an actor: a class that starts with a verb, inherits from Actor, and implements a call method.

rb
# app/actors/send_notification.rb
class SendNotification < Actor
  def call
    # …
  end
end

The actor is triggered with .call, which returns a result object:

rb
SendNotification.call # => <ServiceActor::Result…>

The interesting design decision is that the result, not the return value, is the interface. Reading and writing to that result is how an actor accepts and returns multiple arguments, and the same result set is threaded through a chain of actors. That is the mechanism behind the composability claim: actors do not call each other with method arguments, they share one result bag.

The stated purpose is keeping models and controllers thin, and the project topics name Ruby on Rails and service objects, so the audience is a Rails developer whose fat controllers are the problem to solve.

input and output declare the contract before the body runs

An actor declares its inputs with the input macro, which creates a reader method named after the input:

rb
class GreetUser < Actor
  input :user

  def call
    puts "Hello #{user.name}!"
  end
end

The actor is then called with keyword arguments matching those declarations:

rb
GreetUser.call(user: User.first)

Outputs work in the same declarative style, and output adds a setter so the actor can populate the result:

rb
class BuildGreeting < Actor
  output :greeting

  def call
    self.greeting = "Have a wonderful day!"
  end
end

The result then exposes the value and a predicate for whether it was set:

rb
actor = BuildGreeting.call
actor.greeting # => "Have a wonderful day!"
actor.greeting? # => true

If an actor has a single return value, you can skip the output declaration and use .value instead, which returns the value rather than the result object:

rb
class BuildGreeting < Actor
  input :name

  def call
    "Have a wonderful day, #{name}!"
  end
end

BuildGreeting.value(name: "Fred") # => "Have a wonderful day, Fred!"

The two styles coexist, which is convenient, and it does mean a reader has to know whether a given actor produces a result or a value. The documentation also lists input options such as defaults, allowing nil, conditions and types, plus custom input errors, custom type validations, input aliases through alias_input, and the case where a type is specified for output fields and is not enforced for failed actors.

fail! marks the run as failed, .call raises and .result returns

An actor stops the execution and marks itself failed with fail!, passing an error:

rb
class UpdateUser < Actor
  input :user
  input :attributes

  def call
    user.attributes = attributes

    fail!(error: "Invalid user") unless user.valid?

    # …
  end
end

Calling with .call raises an error in the application, with the given data added to the result. Calling with .result instead returns the result rather than raising, and you can then ask the result whether it worked. The documented controller pattern is the clearest illustration:

rb
# 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

The two entry points are the main decision a team has to make. Raising is convenient inside a service layer and turns every failure into an exception, while returning a result keeps failure as data and makes the caller handle it explicitly. Mixing them within one codebase is possible but inconsistent, and it is worth settling that at the start rather than discovering it in a controller.

One documented caveat: if you specify the type option for output fields, it is not enforced for failed actors, so a failed actor's output might not match the specified type.

play chains share one result set from actor to actor

The composition mechanism is play, which lists other actors to call in sequence:

rb
class PlaceOrder < Actor
  play CreateOrder,
       PayOrder,
       SendOrderConfirmation,
       NotifyAdmins
end

Calling PlaceOrder calls every actor along the way, and inputs and outputs flow from one actor to the next, sharing the same result set until it is finally returned. If you call with .value, you get the return value of the final actor in the chain.

The consequence to internalise is that a chain is not a sequence of isolated calls. An actor that needs an input expects some earlier actor in the chain to have produced it, and if that producer is skipped by a condition, the later actor receives nothing. That coupling is what makes the chain readable, and it is also what makes the ordering load-bearing.

Two more composition features shape the chain. Actors can be called conditionally, with if and unless taking a lambda that receives the actor:

rb
class PlaceOrder < Actor
  play CreateOrder,
       Pay
  play NotifyAdmins, if: -> actor { actor.order.amount > 42 }
  play CreatePayment, unless: -> actor { actor.order.currency == "USD" }
end

And alias_input transforms the output of one actor into the input of the next, which is how you rename a field across a chain boundary:

rb
class PlaceComment < Actor
  play CreateComment,
       NotifyCommentFollowers,
       alias_input(commenter: :user),
       UpdateUserStats
end

Rollback runs the completed steps in reverse, but skips the failing one

When an actor in a play chain calls fail!, the following actors are not called. Instead, all the actors that succeeded have their rollback method called in reverse order, which gives each one a chance to clean up:

rb
class CreateOrder < Actor
  output :order

  def call
    self.order = Order.create!(…)
  end

  def rollback
    order.destroy
  end
end

The rule has a sharp edge, and the documentation states it plainly: 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!.

That is the design's central assumption, and it is where the framework puts the burden on the author. A typical Rails service object often does two things, creates a record and then updates another; if the second step fails, the first has already written to the database and the failing actor is the one that has to undo it, but its rollback will not run. The framework's answer is to make actors single-purpose so the failing actor has nothing to clean up, which is a sound design but a real constraint on how you cut your code.

Rollback is also a cooperative mechanism, not a transaction. An actor has to implement the method, and there is no documentation of a timeout, a retry or a guarantee that the cleanup itself succeeds.

Inline lambdas and instance methods as chain steps

Not every step in a chain deserves its own class. For small work, or to prepare the result set for the next actor, the chain accepts lambdas, and each lambda has access to the shared result:

rb
class PayOrder < Actor
  input :order

  play -> actor { actor.order.currency ||= "EUR" },
       CreatePayment,
       UpdateOrderBalance,
       -> actor { Logger.info("Order #{actor.order.id} paid") }
end

Instance methods work too, referenced by symbol, with the methods defined under private:

rb
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

The two forms exist for the same reason, to keep a chain readable without inventing a class for a one-line transformation. The tradeoff is that a chain can mix three kinds of step, class, lambda and symbol, and the ordering constraints between them are not visible from the chain alone.

There is a third way to wrap the whole actor. Overriding call and calling super lets you run code around the entire body, which is how you would apply a timezone for the duration:

rb
class PayOrder < Actor
  # …

  def call
    Time.with_timezone("Paris") do
      super
    end
  end
end

That override interacts with the play mechanism in a way worth thinking about, since super here is the chain of the actor's own call, and a class that both defines a play list and overrides call has to get the ordering right.

Installing the gem, the Rails generator and the testing story

The gem is added to a Rails application with Bundler:

sh
bundle add service_actor

Two extensions are listed for the framework. service_actor-rails provides Rails generators, and service_actor-promptable adds TTY prompts, both maintained outside the core repository.

The repository itself is a normal Ruby gem layout, with lib/, spec/, bin/, a service_actor.gemspec, a Rakefile, a .rspec file and a .rubocop.yml for linting, plus a CHANGELOG.md, CONTRIBUTING.md and CODE_OF_CONDUCT.md. The gem is MIT licensed and the repository is not archived, with the last push on 2026-07-28. There are no GitHub releases, so the version history lives in the CHANGELOG rather than in tags, which is worth knowing if you pin a version in a Gemfile.

The README has a Testing section listed in its table of contents, but the text of that section is not visible in the published documentation, so the specifics of how the gem expects you to test actors are not something this page can claim. What the rest of the documentation does show is a testable design: actors expose their inputs and outputs as explicit methods, and failures are data through .result, so an actor can be exercised without a Rails request cycle.

Where ServiceActor is the wrong shape of solution

The framework is opinionated, and the opinions are the cost of using it.

The first cost is the result object in the path. Every actor returns a result rather than a value, and a caller who wants the value has to remember .value, while a caller who wants to check status has to remember .result. Code that calls actors from many different places has to be consistent about which entry point it uses, and the difference is invisible at the call site of .call alone.

The second is the single-purpose rule. The rollback design only works if an actor does one thing, so a service object that naturally handles a transaction across several writes has to be split, and the split is not always along the lines of a clean rollback boundary. The documentation is candid about this, and it is a real constraint rather than a style preference.

The third is the convention itself. For an application that already has a service layer with plain classes and module functions, adopting ServiceActor means rewriting call sites to a different calling convention without gaining a capability that was missing. The gem is a good answer to a specific problem, thin models and fat controllers, and a poor answer to a team that wanted plain objects with a shared result hash and no macros.

The audience, then, is a Rails team standardising on one calling convention and willing to enforce single-purpose actors. A team that wants a one-file helper for a small operation should write the helper.

Editorial conclusion

ServiceActor is the right fit for a Rails application whose controllers and models have absorbed business logic and whose team wants one object per action with a declared interface, and it is a poor fit for a library that needs a plain method call with no result object, or for a codebase that already has a service layer convention you would have to replace. Before adopting it, check whether your actions really have one purpose, since the rollback design assumes a single responsibility, and decide up front whether you want .call to raise or .result to return a failed result, because that choice changes every call site.

Frequently asked questions

What is ServiceActor and how do I install it?

It is a Ruby gem for moving application logic into small composable service objects that keep models and controllers thin. It is installed with bundle add service_actor, and Rails generators are available through the separate service_actor-rails extension.

How do I declare inputs and outputs in a ServiceActor actor?

Use the input macro to create a reader method for each argument, and the output macro to create a setter that populates the shared result. A single return value can skip the output declaration and be read with .value instead of .call.

What happens when an actor calls fail! inside a play chain?

The following actors are not called, and the rollback method of the actors that already succeeded is invoked in reverse order. Rollback is not called on the failing actor itself, so actors should stay single purpose and have nothing to clean up when they fail.

Should I call .call or .result on an actor?

Calling with .call raises an error in the application when an actor fails, while .result returns the result so you can check success? or failure? and read actor.error. Mixing the two across a codebase is possible but inconsistent, so the choice is best made once.

Can I add small steps to a play chain without writing a class for each?

Yes. A chain accepts lambdas that receive the actor and access the shared result, and instance methods referenced by symbol, with the method defined under private. You can also override the call method and call super to wrap the whole actor.

Official sources

  1. Issues
  2. License: MIT
  3. README
  4. sunny/actor on GitHub
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/sunny-actor.svg)](https://hysenlabs.com/projects/sunny-actor)

Was this useful?