Composable Ruby service objects
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_actor
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
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 "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 "Have a wonderful day, Fred!"
To stop the execution and mark an actor as having failed, use fail!:
class UpdateUser [!WARNING]
> If you specify the type option for output fields, it will not be enforced for
> failed actors.
> As a result, their output might not match the specified type.
## 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:
```rb
class PlaceOrder [!TIP]
> 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:
```rb
class PayOrder 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 { actor.order.amount > 42 }
play CreatePayment, unless: -> actor { actor.order.currency == "USD" }
end
You can use alias_input to transform the output of an actor into the input of
the next actors.
class PlaceComment { "wonderful" }
input :length_of_time, default: -> { ["day", "week", "month"].sample }
input :article,
default: -> actor { actor.adjective.match?(/^[aeiou]/) ? "an" : "a" }
output :greeting
def call
self.greeting = "Have #{article} #{adjective} #{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!"
While lambdas are the preferred way to specify defaults, you can also provide a default value without using lambdas by using an immutable object.
# frozen_string_literal: true
class ExampleActor { Registry::DEFAULT_OPTIONS }
def call
options[:names] = nil
end
end
By default inputs accept nil values. To raise an error instead:
class UpdateUser user { user.admin? }
}
# …
end
This 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 user { user.admin? },
message: "The user is not an administrator"
}
}
# ...
end
You can also use incoming arguments when shaping your error text:
class UpdateUser
See examples of custom messages on all input arguments
#### Inclusion
```ruby
class Pay provider { PROVIDERS.include?(provider) },
message: (lambda do |value:, **|
"The specified provider \"#{value}\" was not found."
end)
}
}
end
class ReduceOrderAmount
### Custom type validations
This gem provides a minimal API for checking the types of `input` and `output`
values:
- A direct class match: `input :age, type: Integer`
- A choice between classes: `output :height, type: [Integer, Float]`
More complex type checks are outside the scope of this gem. However, type
checking is performed using Ruby’s `===` method.
This means you can define a custom class with a `===` method to implement your
own type logic.
For example, to define a “positive integer” type, you can create a custom class:
```ruby
class PositiveInteger
class #
AgeActor.call(age: -42)
# ServiceActor::ArgumentError: The "age" input on "AgeActor" must be of type
# "PositiveInteger" but was "Integer" (ServiceActor::ArgumentError)
This approach also allows you to define adapters for third-party validation gems, providing the flexibility to integrate custom type checks.
See more examples.
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.
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.
No open issues yet, or sync has not completed.