Service Objects

The version 1.0 has been re-written from scratch without ActiveModel dependency.

See v0.0.1 at the legacy git branch

Gem Version Build Status Dependency Status Code Climate Coverage Inline docs

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:

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, message(: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, message(: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, message(:error, :not_found, id: id)
  end

  # @private
  class Listener < ServiceObjects::Listener
    def on_found(foo)
      self.foo = foo
    end

    def otherwise
      publish :not_found, message(: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.