https://github.com/akuzko/zen-service
flexible and highly extensible Service Objects for business logic organization
https://github.com/akuzko/zen-service
flexible ruby service-objects
Last synced: 2 months ago
JSON representation
flexible and highly extensible Service Objects for business logic organization
- Host: GitHub
- URL: https://github.com/akuzko/zen-service
- Owner: akuzko
- License: mit
- Created: 2017-12-02T14:08:44.000Z (over 8 years ago)
- Default Branch: master
- Last Pushed: 2025-12-30T16:48:52.000Z (3 months ago)
- Last Synced: 2026-01-14T11:28:49.258Z (2 months ago)
- Topics: flexible, ruby, service-objects
- Language: Ruby
- Homepage:
- Size: 126 KB
- Stars: 3
- Watchers: 1
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE.txt
Awesome Lists containing this project
README
# Zen::Service
Flexible and highly extensible Service Objects for business logic organization.
[](https://rubygems.org/gems/zen-service)
[](https://github.com/akuzko/zen-service/actions/workflows/ci.yml)
[](https://codecov.io/gh/akuzko/zen-service)
[](https://www.ruby-lang.org)
[](LICENSE)
## Installation
Add this line to your application's Gemfile:
```ruby
gem 'zen-service'
```
And then execute:
$ bundle
Or install it yourself as:
$ gem install zen-service
## Usage
The most basic usage of `Zen::Service` can be demonstrated with the following example:
```ruby
# 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
end
```
### Service Attributes
`Zen::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
```ruby
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 # => 2
```
### Service Extensions (Plugins)
`zen-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 `.call` and `.[]` that instantiate and call the service
- `:attributes` - Manages service initialization parameters with runtime validation
In addition, `zen-service` provides optional built-in plugins:
#### `:persisted_result`
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) - When `true`, accessing `service.result` will automatically
call `#call` if it hasn't been called yet.
```ruby
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? # => true
```
#### `:result_yielding`
Enables 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.
```ruby
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
end
```
### Creating Custom Plugins
Creating custom plugins is straightforward. Below is an example of a plugin that transforms results to
camelCase notation (using ActiveSupport's core extensions):
```ruby
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 }
```
#### Plugin Registration
Plugins that extend `Zen::Service::Plugins::Plugin` are automatically registered when the module is loaded.
You can also register plugins manually:
```ruby
# 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")
```
#### Plugin Lifecycle
When using a plugin on a service class:
- **First use**: Both `used` and `configure` callbacks are invoked, and the module is included
- **Inheritance**: If a plugin was already used by an ancestor class, only `configure` is called,
allowing reconfiguration without re-including the module
This design enables child classes to customize inherited plugin behavior:
```ruby
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
end
```
#### Plugin DSL
Plugins can use several DSL methods when extending `Zen::Service::Plugins::Plugin`:
```ruby
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
end
```
## Testing
The gem has 100% test coverage with both line and branch coverage. To run the test suite:
```bash
bundle exec rspec
```
## 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 allows you to experiment.
To install this gem onto your local machine, run `bundle exec rake install`.
## Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/akuzko/zen-service.
## License
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).