No commit activity in last 3 years
No release in over 3 years
Simple Ruby Service is a lightweight framework for creating Services and Service Objects (SOs) in Ruby. The framework makes Services and SOs look and feel like ActiveModels, complete with: 1. validations and robust error handling; 2. workflows and method chaining; and 3. consistent interfaces. Additionally, Simple Ruby Service Objects can stand in for Procs, wherever Procs are expected (via ducktyping).
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
 Dependencies

Development

~> 1.17
~> 2.5
~> 6.2.0
~> 2.18
~> 3.5.1
~> 6.1.3.2
~> 10.0
~> 3.0
~> 5.0.1
~> 0.60
~> 0.16
~> 1.4.2
~> 3.13
~> 0.5.2

Runtime

 Project Readme

Simple Ruby Service

Build Status Test Coverage

Simple Ruby Service is a lightweight framework for creating Services and Service Objects (SOs) in Ruby.

The framework makes Services and SOs look and feel like ActiveModels, complete with:

  1. Validations and robust error handling
  2. Workflows and method chaining
  3. Consistent interfaces

Additionally, Simple Ruby Service Objects can stand in for Procs, wherever Procs are expected (via ducktyping).

What problem does Simple Ruby Service solve?

Currently, most ruby developers roll their own services from scratch. As a result, most services are hastely built (in isolation), and this leads to inconsistant interfaces that are difficult to read. Also, error handling tends to vary wildly within an application, and support code tends to be implemented over and over again.

Simple Ruby Service addresses these problems and encourages succinct, idiomatic coding styles.

Should I be using Services & SOs in Ruby / Rails?

LMGTFY to learn more about Services & SOs.

TLDR - Fat models and fat controllers are bad! Services and Service Objects help you DRY things up.

How is a Service different from an SO?

An SO is just a Service that encapsulates a single operation (i.e. one, and only one, responsibility).

Requirements

  • Ruby 1.9.2+

Simple Ruby Service includes helpers for Rails 3.0+, but does not require Rails.

Download and installation

Add this line to your application's Gemfile:

gem 'simple_ruby_service'

And then execute:

$ bundle

Or install it yourself as:

$ gem install simple_ruby_service

Source code can be downloaded on GitHub github.com/amazing-jay/simple_ruby_service/tree/master

Quick Start

See Usage & Creating Simple Ruby Services for more information.

How to refactor complex business logic with Simple Ruby Service

::Before:: Vanilla Rails with a fat controller (a contrived example)

# in app/controllers/some_controller.rb
class SomeController < ApplicationController
  def show
    raise unless params[:id].present?
    resource = SomeModel.find(id)
    authorize! resource
    resource.do_something
    value = resource.do_something_related
    raise unless resource.errors
    render value
  end
end

::After:: Refactored using an Simple Ruby Service Object

# in app/controllers/some_controller.rb
class SomeController < ApplicationController
  def show
    # NOTE: That's right... just one, readable line of code
    render DoSomething.call!(params)
  end
end

::Alternate After:: Refactored using a Simple Ruby Service

# in app/controllers/some_controller.rb
class SomeController < ApplicationController
  def show
    # NOTE: Simple Ruby Service methods can be chained together
    render SomeService.new(params)
      .do_something
      .do_something_related
      .value
  end
end

Taking a peek under the hood

DoSomething.call!(params) is deliberately designed to look and feel like ActiveRecord::Base#save!.

The following (simplified) implementation illustrates what happens under the hood:

module SimpleRubyService::Object
  def self.call!(params)
    instance = new(params)
    raise Invalid unless instance.valid?
    self.value = instance.call
    raise Invalid unless instance.failed?
    value
  end
end

Anatomy of a Simple Ruby Service Object

# in app/service_objects/do_something.rb
class DoSomething
  include SimpleRubyService::ServiceObject
  
  # `attribute` behaves similar to ActiveRecord::Base#attribute, but is not typed, or bound to persistant storage
  attribute :id
  attr_accessor :resource

  # Validations are executed prior to the business logic encapsulated in `perform`
  validate do                                 
    @resource ||= SomeModel.find(id)
    authorize! resource
  end
  
  # The result of `perform` is automatically stored as the SO's `value`
  def perform
    resource.do_something    
    result = resource.do_something_related  
    
    # Adding any kind of error indicates failure
    add_errors_from_object resource    
    result
  end
end

Anatomy of a Simple Ruby Service

# in app/services/do_something.rb
class SomeService
  include SimpleRubyService::Service
  
  attribute :id
  attr_accessor :resource

  # Similar to SOs, validations are executed prior to the first service method called
  validate do
    @resource ||= SomeModel.find(id)
    authorize! @resource
  end
  
  # Unlike SOs, Services can define an arbitrary number of service methods with arbitrary names
  service_methods do
    def do_something
      resource.do_something      
    end

    # Unlike SOs, `value` must be explicitely set for Service methods
    def do_something_related
      self.value ||= resource.tap &:do_something_related
      add_errors_from_object resource
    end
  end
end

A special note about Simple Ruby Service Objects, Procs, and Ducktyping

Simple Ruby Service Objects respond to (#call) so they can stand in for Procs, i.e.:

# in app/models/some_model.rb
class SomeModel < ApplicationRecord
  validates :some_attribute, if: SomeServiceObject
  [...]

See To bang!, or not to bang to learn about .call! vs. .call.

Usage

Service Objects

Service Object names should begin with a verb and should not include the words service or object:

  • GOOD = CreateUser
  • BAD = UserCreator, CreateUserServiceObject, etc.

Also, only one operation should be made public, it should always be named call, and it should not accept arguments (except for an optional block).

Short form (recommended)

result = DoSomething.call!(foo: 'bar')

Instance form

result = DoSomething.new(foo: 'bar').call!

Rescue form

result = begin
  DoSomething.call!(foo: 'bar')
rescue SimpleRubyService::Invalid => e
  # do something with e.target.attributes
rescue SimpleRubyService::Failure
  # do something with e.target.value
end

Conditional form

result = DoSomething.call(foo: 'bar')
if result.invalid?
  # do something with result.attributes
elsif result.failure?
  # do something with result.value
else
  # do something with result.errors
end

Block form

note: blocks, if present, are envoked prior to failure check.

result = DoSomething.call!(foo: 'bar') do |obj|
  obj.errors.clear # clear errors
  'new value'      # set result = 'new value'
end

Dependency injection form

DoSomething.call!(with: DoSomethingFirst.call!)

Services

Unlike Service Objects, Service class names should begin with a noun (and may include the words service or object):

  • GOOD = UserCreator
  • BAD = CreateUser, UserCreatorService, etc.

Also, any number of operations may be made public, any of these operations may be named call, and any of these operations may accept arguments.

Short form

not available for Services

Instance form

result = SomeService.new(foo: 'bar').do_something!

Chained form

result = SomeService.new(foo: 'bar')
  .do_something
  .do_something_else
  .value

Rescue form

result = begin
  SomeService.new(foo: 'bar').do_something!
rescue SimpleRubyService::Invalid => e
  # do something with e.target.attributes
rescue SimpleRubyService::Failure
  # do something with e.target.value
end

Conditional form

result = SomeService.new(foo: 'bar').do_something
if result.invalid?
  # do something with result.attributes
elsif result.failure?
  # do something with result.value
else
  # do something with result.errors
end

Block form

note: blocks, if present, are envoked prior to failure check.

result = SomeService.new(foo: 'bar').do_something! do |obj|
  obj.errors.clear # clear errors
  'new value'      # set result = 'new value'
end

Creating Simple Ruby Services

Service Objects

To implement a Simple Ruby Service Object:

  1. include SimpleRubyService::ServiceObject
  2. declare attributes with the attribute keyword (class level DSL)
  3. declare validations see Active Record Validations
  4. implement the special perform method (automatically invoked by call wrapper method)
  5. add errors to indicate the operation failed

note: perform may optionally accept a block param, but no other args.

Example::

class DoSomething
  include SimpleRubyService::ServiceObject
  
  attribute :attr1, :attr2             # should include all params required to execute, similar to ActiveRecord
  validates_presence_of :attr1         # validate params

  def perform
    errors.add(:some critical service, message: 'down') and return unless some_critical_service.up?
    yield if block_given?

    'hello world'                      # set `value` to the returned value of the operation
  end
end

Services

To implement a Simple Ruby Service:

  1. include SimpleRubyService::Service
  2. declare attributes with the attribute keyword (class level DSL)
  3. declare validations see Active Record Validations
  4. define operations within a service_methods block (each method defined will be wrapped)
  5. set (or modify) self.value (if your service method creates artifacts
  6. add errors to indicate the operation failed

note: service methods may accept any arguments, required or otherwise.

Example::

class SomeService 
  include SimpleRubyService::Service
  
  attribute :attr1, :attr2               # should include all params required to execute, similar to ActiveRecord
  validates_presence_of :attr1           # validate params

  service_methods do
    def hello
      self.value = 'hello world'         # set value
    end
    
    def oops
      errors.add(:foo, :bar)             # indicate failure
    end

    
    def goodbye(arg1, arg2 = nil, arg3: arg4: nil, &block)
      self.value += '...goodnight sweet world'                # modify value
    end
  end
end

Workflows

Simple Ruby Services are inherently a good fit for workflows because they support chaining, i.e.:

SomeService.new(params)
  .do_something
  .do_something_related
  .value

But SOs can also implement various workflows with dependency injection:

class PerformSomeWorkflow < SimpleRubyService::ServiceObject
  def perform
    dependency = SimpleRubyService1.call!
    result = SimpleRubyService2.call(dependency)
    raise unless result.success?                 
    SimpleRubyService3(dependency, result.value).call!
  end
end

MISC

To bang!, or not to bang

Use the bang! version of an operation whenever you expect the operation to succeed more often than fail, and you don't need to chain operations together.

Similar in pattern to ActiveRecord#save!, the bang version of each operation:

  • raises SimpleRubyService::Invalid if valid? is falsey
  • raises SimpleRubyService::Failure if the block provided returns a falsey value
  • returns @value

Whereas, similar in pattern to ActiveRecord#save, the regular version of each operation:

  • doesn't raise any exceptions
  • passes the return value of the block provided to #success?
  • returns self << note: this is unlike ActiveRecord#save

Service or SO?

Use a Service when encapsulating related operations that share dependencies & validations.

i.e.:

  • Create a Service with two service methods when operation A and operation B both act on a User (and are related in some way).
  • Create two Service Objects when operation A and operation B are related, but A acts on a User while B acts on a Company.

note: Things get fuzzy when operations share some, but not all, dependencies & validations. Use your best judgement when operation A and operation B are related but A acts on a User while B acts on both a User & a Company.

Control Flow

Rescue exceptions that represent internal control flow and propogate the rest.

For example, if an internal call to User.create! is expected to always succeed, allow ActiveRecord::RecordInvalid to propogate to the caller. If, on the otherhand, an internal call to User.create! is anticipated to conditionally fail on a uniqueness constraint, rescue ActiveRecord::RecordInvalid and rely on the framework to raise SimpleRubyService::Failure.

Example::

class DoSomethingDangerous < SimpleRubyService::ServiceObject
  attribute :attr1, :attr2             # should include all params required to execute
  validates_presence_of :attr1         # validate params to call

  def perform
    ActiveRecord::Base.transaction do  # optional
      self.value = # ... do work ...   # save results for the caller
    end
  rescue SomeDependency::Failed
    errors[:base] << e.message         # notify the caller of error
  rescue ActiveRecord::RecordInvalid
    # ... fix things and retry ...
  end
end

Development

After checking out the repo, run bin/setup to install dependencies. Then, run bundle exec rspec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/amazing-jay/simple_ruby_service. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.

License

The gem is available as open source under the terms of the MIT License.

DEVELOPMENT ROADMAP

  1. Create a class level DSL to stop before each Service method unless errors.empty?
  2. Create a helper to dynamically generate default SOs for ActiveRecord models (create, update, and destroy) (when used in a project that includes ActiveRecord).
  3. Consider isolating validation errors from execution errors (so that invalid? is not always true when failed? is true)