Flexible and highly extensible Service Objects for business logic organization.
Add this line to your application's Gemfile:
gem 'zen-service'And then execute:
$ bundle
Or install it yourself as:
$ gem install zen-service
The most basic usage of Zen::Service can be demonstrated with the following example:
# app/services/todos/update.rb
module Todos
class Update < ApplicationService # Base class for app services, inherits from Zen::Service
attributes :todo, :params
def call
if todo.update(params)
[:ok, todo]
else
[:error, todo.errors.messages]
end
end
end
end
# app/controllers/todos_controller.rb
class TodosController < ApplicationController
def update
case Todos::Update.call(todo, params: todo_params)
in [:ok, todo] then render json: Todos::Show.call(todo)
in [:error, errors] then render json: errors, status: :unprocessable_content
end
end
endZen::Service instances are initialized with attributes. To specify the list of available attributes, use the attributes
class method. All attributes are optional during initialization. You can omit keys and pass attributes as positional
parameters—they will be assigned in the order they were declared. However, you cannot:
- Pass more attributes than declared
- Pass the same attribute multiple times (both as positional and keyword argument)
- Pass undeclared attributes
class MyService < Zen::Service
attributes :foo, :bar
def call
# Your business logic here
end
def foo
super || 5 # Provide default value
end
end
# Different ways to initialize services
s1 = MyService.new
s1.foo # => 5
s1.bar # => nil
s2 = MyService.new(6)
s2.foo # => 6
s2.bar # => nil
s3 = MyService.new(foo: 1, bar: 2)
s3.foo # => 1
s3.bar # => 2
# Create a new service from an existing one with some attributes changed
s4 = s3.with_attributes(bar: 3)
s4.foo # => 1
s4.bar # => 3
# Create a service from another service's attributes
s5 = MyService.from(s3)
s5.foo # => 1
s5.bar # => 2zen-service is built with extensibility at its core. Even fundamental functionality like callable behavior
and attributes are implemented as plugins. The base Zen::Service class uses two core plugins:
:callable- Provides class methods.calland.[]that instantiate and call the service:attributes- Manages service initialization parameters with runtime validation
In addition, zen-service provides optional built-in plugins:
Provides #result method that returns the value from the most recent #call invocation, along with a
#called? helper method.
Options:
call_unless_called: false(default) - Whentrue, accessingservice.resultwill automatically call#callif it hasn't been called yet.
class MyService < Zen::Service
use :persisted_result, call_unless_called: true
attributes :value
def call
value * 2
end
end
service = MyService.new(5)
service.called? # => false
service.result # => 10 (automatically calls #call)
service.called? # => trueEnables nested service calls to return block-provided values instead of the nested service's return value. Useful for wrapping service calls with cross-cutting concerns like logging or error handling.
class Logger < Zen::Service
use :result_yielding
# Will result with value return by `yield` expression
def call
Rails.logger.info("Starting operation")
result = yield
Rails.logger.info("Operation completed: #{result.inspect}")
end
end
class UpdateTodo < Zen::Service
attributes :todo, :params
def call
Logger.call do
todo.update!(params)
[:ok, todo]
rescue ActiveRecord::RecordInvalid
[:error, todo.errors.messages]
end
end
endCreating custom plugins is straightforward. Below is an example of a plugin that transforms results to camelCase notation (using ActiveSupport's core extensions):
module CamelizeResult
extend Zen::Service::Plugins::Plugin
def self.used(service_class)
service_class.prepend(Extension)
end
def self.camelize(obj)
case obj
when Array then obj.map { |item| camelize(item) }
when Hash then obj.deep_transform_keys { |key| key.to_s.camelize(:lower).to_sym }
else obj
end
end
module Extension
def call
CamelizeResult.camelize(super)
end
end
end
class Todos::Show < Zen::Service
attributes :todo
use :camelize_result
def call
{
id: todo.id,
is_completed: todo.completed?
}
end
end
Todos::Show[todo] # => { id: 1, isCompleted: true }Plugins that extend Zen::Service::Plugins::Plugin are automatically registered when the module is loaded.
You can also register plugins manually:
# Register a plugin module
Zen::Service::Plugins.register(:my_plugin, MyPlugin)
# Register by class name (useful when autoload isn't available yet, e.g., during Rails initialization)
Zen::Service::Plugins.register(:my_plugin, "MyApp::Services::MyPlugin")When using a plugin on a service class:
- First use: Both
usedandconfigurecallbacks are invoked, and the module is included - Inheritance: If a plugin was already used by an ancestor class, only
configureis called, allowing reconfiguration without re-including the module
This design enables child classes to customize inherited plugin behavior:
class BaseService < Zen::Service
use :persisted_result, call_unless_called: false
end
class ChildService < BaseService
use :persisted_result, call_unless_called: true # Reconfigures without re-including
endPlugins can use several DSL methods when extending Zen::Service::Plugins::Plugin:
module MyPlugin
extend Zen::Service::Plugins::Plugin
# Override the auto-generated registration name
register_as :custom_name
# Set default options
default_options foo: 5, bar: false
# Called when plugin is first used on a class
def self.used(service_class, **options, &block)
# Include/prepend modules, add class methods, etc.
end
# Called every time the plugin is used (including on child classes)
def self.configure(service_class, **options, &block)
# Configure behavior based on options
end
endThe gem has 100% test coverage with both line and branch coverage. To run the test suite:
bundle exec rspecAfter 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 allows you to experiment.
To install this gem onto your local machine, run bundle exec rake install.
Bug reports and pull requests are welcome on GitHub at https://github.com/akuzko/zen-service.
The gem is available as open source under the terms of the MIT License.