Service Objects
The version 1.0 has been re-written from scratch without ActiveModel dependency.
See v0.0.1 at the legacy git branch
Base classes for services and their listeners following Publish/Subscribe design pattern.
Installation
Add this line to your application's Gemfile:
# Gemfile
gem "service_objects"
Then execute:
bundle
Or add it manually:
gem install service_objects
Introduction
The API contains 3 classes:
ServiceObjects::Base
- for service objects.ServiceObjects::Listener
- for service objects' listeners.ServiceObjects::Message
- for messages published by service objects.
The module is backed on 3 gems:
- wisper that provides Publish/Subscribe features of the service;
- virtus for service object's attributes and dependencies declaration;
- attestor for object's validation.
Basic Use
Define the service object and describe its attributes (via virtus).
class DeleteFoo < ServiceObjects::Base
attribute :id, Integer
end
You can also use another coersion mechanism (via attr_coerced).
class DeleteFoo < ServiceObjects::Base
attribute :id
attr_coerced :id, Integer
end
Declare dependencies from other service objects. This allows setter injection of dependencies in a runtime (for example, in unit tests).
class DeleteFoo < ServiceObjects::Base
# ...
dependency :get_foo, default: GetFoo # another service objects class
end
# This defines an attribute of Class type
service = DeleteFoo.new
service.get_foo # => GetFoo
service.get_foo = FindFoo
service.get_foo # => FindFoo
Notice, the dependency is not an attribute. You cannot set it via hash arguments of the service object constructor.
Declare validations for attributes and dependencies using the validate
and validates
attestor methods.
class DeleteFoo < ServiceObjects::Base
# ...
validate { invalid :blank_id unless id }
validate { invalid :get_foo unless get_foo.is_a? ServiceObjects::Base }
end
Define the #run!
private method.
It is expected the method to publish notifications to listeners (via wisper). The wisper #publish
method is reloaded so that after publication it throws :published
to be caught by public #run
method. That's how #publish
stops running the service.
If you need to publish something and keep running, use the wisper #broadcast
method instead of the #publish
.
class DeleteFoo < ServiceObjects::Base
# ...
private
def run!
validate
find_foo
delete_foo
end
end
Notice the #validate
method (via attestor). It publishes :error
notification when a validation fails.
Call the external service and listen to its notifications with a #run_service
helper:
class DeleteFoo < ServiceObjects::Base
# ...
def find_foo
run_service get_foo.new(id: id), Listener.new(self)
end
end
You should provide a listener for the service. The ServiceObjects::Listener
method decorates its argument (self
) with necessary callbacks.
It should also define the #otherwise
callback for the case when an external service publishes no expected notifications.
All undefined methods are forwarded to the listener argument, so you can modify attributes of mutable services from their listeners.
class DeleteFoo < ServiceObjects::Base
# ...
private
attr_accessor :foo
class Listener < ServiceObjects::Listener
def on_found(foo)
self.foo = foo
end
# the method will be called by #run_service
# unless #on_found received
def otherwise
publish :not_found, (:error, :not_found, id: id)
end
end
end
The #message
helper returns a translated message with 2 attributes: type
and text
. When the text is set as a Symbol
, it is translated by I18n
with given options.
# config/locales/en.yml
---
en:
service_objects:
delete_foo: # the service class name like ActiveModule's one
error: # the type of the message
not_found: "The foo with id %{id} hasn't been found"
success:
deleted: "The foo with id %{id} has been deleted"
It is also recommended to publish successful message for the service not to end up silently:
class DeleteFoo < ServiceObjects::Base
# ...
def delete_foo
# do something to delete foo
publish :deleted, foo, (:success, :deleted, id: id)
end
end
Full Example
class DeleteFoo < ServiceObjects::Base
attribute :id, Integer
dependency :get_foo, GetFoo # another service objects class
validate { invalid :blank_id unless id }
validate { invalid :get_foo unless get_foo.is_a? ServiceObjects::Base }
private
def run!
validate # publishes :error
find_foo # publishes :not_found
delete_foo # publishes :deleted
end
attr_accessor :foo
def find_foo
run_service get_foo.new(id: id), Listener.new(self)
end
def delete_foo
#... do something
publish :deleted, (:error, :not_found, id: id)
end
# @private
class Listener < ServiceObjects::Listener
def on_found(foo)
self.foo = foo
end
def otherwise
publish :not_found, (:error, :not_found, id: id)
end
end
end
Calling the Service
The service can be called like any other wisper publisher. For example, from the Rails controller:
class FooController < ActionController::Base
def delete
listener = DeleteListener.new(self)
service = DeleteFoo.new(id: params[:id])
service.subscribe(listener, prefix: :on)
service.run
end
class DeleteListener < ServiceObjects::Listener
def on_deleted
redirect_to :home
end
def on_not_found
#...
end
def otherwise
#...
end
end
end
Compatibility
Tested under rubies compatible to rubies compatible to API 2.0+:
- MRI 2.0+
- Rubinius (mode 2.0+)
- JRuby 9.0.0.0 (mode 2.0+)
Uses RSpec 3.0+ for testing and hexx-suit for dev/test tools collection.
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 the MIT LICENSE.