ServiceObjects
The module implements two design patterns:
- The Interactor pattern to decouple business logics from both models and delivery mechanisms, such as Rails.
- The Observer pattern to follow the Tell, don't Ask design princible. The pattern is implemented with the help of wisper gem by Kris Leech.
The module API provides 3 classes:
ServiceObjects::Base
- for service objects.ServiceObjects::Listener
- for decorating objects with methods called by service notificiations.ServiceObjects::Message
- for messages published by service objects.
Installation
Add this line to your application's Gemfile:
gem "service_objects"
And then execute:
bundle
Or install it yourself as:
gem install service_objects
Usage
The basic usage of the services by example of Rails controller:
# lib/my_gem.rb
require "service_objects"
# app/services/add_foo.rb
class AddFoo < ServiceObjects::Base
# whitelists parameters and defines #params, #bar and #baz
allows_params :bar, :baz
# declares external dependencies
depends_on :find_foo, default: FindFoo
# calls the service, sorts out and reports its results
def run
run!
rescue Invalid => err
publish :error, err.
else
publish :created, @foo,
ensure
self
end
private
# business logic lives here
def run!
# ... usage of the external service find_foo postponed
add_foo
end
# rescues errors and re-raises them as Invalid with list of #messages
def add_foo
escape { @foo ||= Foo.create! bar: , baz: baz }
end
end
# app/controllers/foos_controller.rb
class FoosController < ApplicationController
def create
# Create the service object with necessary parameters.
# All the business logic is encapsulated inside the service,
# and the controller knows nothing what the service will do.
service = AddFoo.new params.allow(:bar, :baz)
# Subscribe the listener for the service's notifications
service.subscribe listener, prefix: :on
# Run the service
service.run
# If the service doesn't call any listener method,
# then a listener provides some default actions
listener.finalize
end
private
# The listener decorates the controller with methods
# to listen the service object's notifications
# (see FoosListener#otherwise method below).
def listener
@listener ||= FoosListener.new self
end
# The class to work out service object notifications
# The #render method is delegated to the controller
class FoosListener < ServiceObjects::Listener
# The method to be called when a service publishes
# the 'added' notification.
def on_added(foo, *, )
render "created", locals: { foo: foo, messages: }
end
# The method to be called when a service publishes
# the 'error' notification.
def on_error(*, )
render "error", locals: { messages: }
end
# The method is called by the #finalize in case no methods has been called
# by the service object.
#
# This allows to focuse only on a subset of service notifications above.
def otherwise
render "no_result"
end
end
end
The service can notify several listeners (controller itself, mailer etc.).
Base
The ServiceObjects::Base
provides base class for services.
require "service_objects"
class AddFoo < ServiceObjects::Base
end
Parameters declaration
Define allowed parameters for objects:
class AddFoo < ServiceObjects::Base
allows_params :bar, :baz
end
Parameters are whitelisted and assigned to #params
hash (all keys are symbolized).
Attributes are also defined as aliases for corresponding params, so that #bar
and #bar=
are equivalent to #params[:bar]
, #params[:bar]=
.
Note: The service ignores parameters except for explicitly declared. The client can give relevant data to the service, and leave the latter to figure them out by itself.
Validation
The ServiceObject::Base
includes ActiveModel::Validations with methods .validates
, .validate
, #errors
, #valid?
and #invalid?
. Use them to add action context - specific validations.
The method #validate!
raises the ServiceObject::Invalid
if validation fails.
class AddFoo < ServiceObjects::Base
allows_params :bar, :baz
validates :bar, presence: true
validates :baz, numericality: { greater_than: 1 }, allow_nil: true
# ...
def run!
# ...
validate!
# ...
end
end
Note: You aren't restricted in selecting time for validation. Prepare attributes (either "real" or virtual) and run #validate!
when necessary.
Dependencies declaration
As a rule, services uses each other to keep the code DRY. For example, the service that adds a new foo (whatever it means) can use another service to find an existing foo.
To made all that dependencies injectable via setter injection, define them explicitly:
class AddFoo < ServiceObjects::Base
# ...
# Providing the FindFoo is available at the moment AddFoo being defined:
depends_on :find_foo, default: FindFoo
end
Default value can be either assigned or skipped. In the last case the Null Object will be assigned by default.
The class method is public to postpone the default implementation until it is defined:
class AddFoo < ServiceObjects::Base
# ...
depends_on :find_foo
end
# later
FindFoo = Class.new
AddFoo.depends_on :find_foo, default: FindFoo
This provides the instance attribute #find_foo
. You can inject the dependency to the via setter:
service = AddFoo.new bar: "bar", baz: "baz"
service.find_foo = GetFoo
Run method
It is expected the run
method to provide all the necessary staff and notify listeners via #publish
method.
See wisper for details on #publish
and #subscribe
methods.
class AddFoo < ServiceObjects::Base
# ...
# The method contains the reporting logic only
def run
run!
rescue Found
publish :found, @foo,
rescue Invalid => err
publish :error, err.
else
publish :added, @foo,
ensure
self
end
# ...
private
Found = Class.new(RuntimeError) # the internal message
# Business logic lives here
def run!
get_foo
create_foo
end
def get_foo
# ... finds and assigns @foo somehow
fail Found if @foo
end
def add_foo
# ...
end
end
There are some helper available:
messages
- an array of collected service messagesadd_message
- adds the new message to the arrayescape
- rescues fromStandardErrors
and re-raises them asServiceObject::Invalid
with collection of#messages
.
Note Following command-query separation the #run
method (being a command) returns self
.
External services
External services should be used in just the same way as in the controller example.
class AddFoo < ServiceObjects::Base
depends_on :find_foo, default: FindFoo
# ...
def get_foo
service = find_foo.new params
service.subscribe listener, prefix: :on
service.run
# the method runs #otherwise callback in case
# no other notificaton has been received
listener.finalize
end
# decorates the service with methods to listen to external service
def listener
@listener ||= FindListener.new self
end
class FindListener < ServiceObjects::Listener
def on_found(foo, *)
__getobj__.foo = foo
end
def on_error(*, )
__getobj__. =
end
def otherwise
"complain", "haven't been informed"
end
end
end
Here the #get_foo
runs the external service and listens to its notifications. Instead of the long syntax above, you can use a shortcut:
# ...
def get_foo
run_service find_foo.new(params), listener, prefix: :on
end
Listener
The listener is a decorator that:
- defines callbacks to listen to service notifications.
- delegates all undefined methods to the encapsulated object (available via getobj instance method).
- defines the
#finalize
method to run#otherwise
callback in case no other methods has been checked (via#respond_to?
method).
class FooListener < ServiceObjects::Listener
def on_success(*)
"Notified on success"
end
def otherwise
"Hasn't been notified"
end
end
listener = FooListener.new
listener.finalize
# => "Hasn't been notified"
listener.respond_to? :on_error
# => false
listener.finalize
# => "Hasn't been notified"
listener.respond_to? :on_success
# => true
listener.finalize
# => nil
Message
The ServiceObjects::Base#messages
collects messages with text, type and optional priority:
= ServiceObjects::Message.new priority: 0, type: "bar", text: "foo"
.priority # => 0.0
.type # => "info"
.text # => "some text"
.to_h # => { type: "bar", type: "foo" }
.to_json # => "{\"type\":\"bar\",\"text\":"\foo\"}"
When a priority hasn't been defined explicitly, it is set to -1.0
for errors, and to 0.0
otherwise. Messages are sorted by priority, type and text in a "natural" order.
Use the #add_message
helper to add a message to the collection:
service = ServiceObjects::Base.new
service.send :add_message type: "info", text: "some text"
service.send :messages
# => [<Message type="info" text="some text" priority=0.0>]
When a text:
value is a symbol, it is translated in the scope of current service class:
# config/locales/en.yml
---
en:
activemodel:
messages:
models:
foo:
excuse: # the type of the message
not_informed: "I haven't been informed on the %{subject}"
service.send :add_message type: "excuse", text: :not_informed, subject: "issue"
# => [<Message text="I haven't been informed on the issue" ...>]
Scaffolding
Use CLI command to scaffold a service object with its specification and translations:
service new my_service
To see available options run the command with -h
option:
service new -h
Compatibility
Tested under MRI rubies >= 2.1
RSpec 3.0+ used for testing
Collection of testing, debugging and code metrics is defined in the hexx-suit gem.
To run tests use rake test
, to run code metrics use rake check
. All the metric settings are collected in the config/metrics
folder.
Contributing
- Fork the project.
- Read the Styleguide.
- Make your feature addition or bug fix.
- Add tests for it. This is important so I don't break it in a future version unintentionally.
- Commit, do not mess with Rakefile or version (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
- Send me a pull request. Bonus points for topic branches.
License
See MIT LICENSE