ServiceObjects

Gem Version Build Status Dependency Status Code Climate Coverage License

The module implements two design patterns:

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.messages
  else
    publish :created, @foo, messages
  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: 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, *, messages)
      render "created", locals: { foo: foo, messages: messages }
    end

    # The method to be called when a service publishes
    # the 'error' notification.
    def on_error(*, messages)
      render "error", locals: { messages: 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, messages
  rescue Invalid => err
    publish :error, err.messages
  else
    publish :added, @foo, messages
  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 messages
  • add_message - adds the new message to the array
  • escape - rescues from StandardErrors and re-raises them as ServiceObject::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(*, messages)
      __getobj__.messages = messages
    end

    def otherwise
      add_message "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:

message = ServiceObjects::Message.new priority: 0, type: "bar", text: "foo"
message.priority    # => 0.0
message.type        # => "info"
message.text        # => "some text"
message.to_h        # => { type: "bar", type: "foo" }
message.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" ...>]

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