BetterService
Clean, powerful Service Objects for Rails
Features • Installation • Quick Start • Documentation • Usage • Error Handling • Examples
✨ Features
BetterService is a comprehensive Service Objects framework for Rails that brings clean architecture and powerful features to your business logic layer.
Version 2.1.0 • 1,000+ tests passing (812 gem + 275 rails_app)
Core Features
- 🎯 4-Phase Flow Architecture: Structured flow with validation → authorization → search → process → respond
- 📦 Result Wrapper:
BetterService::Resultwith.success?,.resource,.meta,.messageand destructuring support - 🏛️ Repository Pattern: Clean data access with
RepositoryAwareconcern andrepository :model_nameDSL - ✅ Mandatory Schema Validation: Built-in Dry::Schema validation for all params
- 🔄 Transaction Support: Automatic database transaction wrapping with rollback
- 🔐 Flexible Authorization:
authorize_withDSL that works with any auth system (Pundit, CanCanCan, custom) - ⚠️ Rich Error Handling: Pure Exception Pattern with hierarchical errors, rich context, and detailed debugging info
Advanced Features
- 💾 Cache Management: Built-in
CacheServicefor invalidating cache by context, user, or globally with async support - 🔄 Auto-Invalidation: Write operations (Create/Update/Destroy) automatically invalidate cache when configured
- 🌍 I18n Support: Built-in internationalization with
message()helper, custom namespaces, and fallback chain - 🎨 Presenter System: Optional data transformation layer with
BetterService::Presenterbase class - 📊 Metadata Tracking: Automatic action metadata in all service responses
- 🔗 Workflow Composition: Chain multiple services into pipelines with conditional steps, rollback support, and lifecycle hooks
- 🌲 Conditional Branching: Multi-path workflow execution with
branch/on/otherwiseDSL for clean conditional logic - 🏗️ Powerful Generators: 11 generators for rapid scaffolding (base, scaffold, CRUD services, action, workflow, locale, presenter)
- 🎨 DSL-Based: Clean, expressive DSL with
search_with,process_with,authorize_with, etc.
📦 Installation
Add this line to your application's Gemfile:
gem "better_service"And then execute:
bundle installOr install it yourself as:
gem install better_service🚀 Quick Start
1. Generate Services
# Generate BaseService + Repository + locale file
rails generate serviceable:base Product
# Generate all CRUD services inheriting from BaseService
rails generate serviceable:scaffold Product --base
# Or generate individual services
rails generate serviceable:create Product --base_class=Product::BaseService
rails generate serviceable:action Product publish2. Use the Service with Result Wrapper
# Create a product
result = Product::CreateService.new(current_user, params: {
name: "MacBook Pro",
price: 2499.99
}).call
# Check success with Result wrapper
if result.success?
product = result.resource # => Product object
message = result.message # => "Product created successfully"
action = result.meta[:action] # => :created
else
error_code = result.meta[:error_code] # => :unauthorized
message = result.message # => "Not authorized"
end
# Or use destructuring
product, meta = result
redirect_to product if meta[:success]📖 Documentation
Comprehensive guides and examples are available in the /docs directory:
🎓 Guides
- Getting Started - Installation, core concepts, your first service
- Service Types - Deep dive into all 6 service types (Index, Show, Create, Update, Destroy, Action)
- Concerns Reference - Complete reference for all 7 concerns (Validatable, Authorizable, Cacheable, etc.)
💡 Examples
- E-commerce - Complete e-commerce implementation (products, cart, checkout)
🔧 Configuration
See Configuration Guide for all options including:
- Instrumentation & Observability
- Built-in LogSubscriber and StatsSubscriber
- Cache configuration
📚 Usage
Service Architecture
All services inherit from BetterService::Services::Base via a resource-specific BaseService:
# 1. BaseService with Repository (generated with `rails g serviceable:base Product`)
class Product::BaseService < BetterService::Services::Base
include BetterService::Concerns::Serviceable::RepositoryAware
messages_namespace :products
cache_contexts [:products]
repository :product # Injects product_repository method
end
# 2. All services inherit from BaseService
class Product::CreateService < Product::BaseService
performed_action :created
with_transaction true
auto_invalidate_cache true
# Schema Validation (mandatory)
schema do
required(:name).filled(:string, min_size?: 2)
required(:price).filled(:decimal, gt?: 0)
end
# Authorization - IMPORTANT: use `next` not `return`
authorize_with do
next true if user.admin? # Admin bypass
user.seller?
end
# Search Phase - Load data
search_with do
{} # No data to load for create
end
# Process Phase - Business logic
process_with do |_data|
product = product_repository.create!(
name: params[:name],
price: params[:price],
user: user
)
# IMPORTANT: Return { resource: ... } for proper extraction
{ resource: product }
end
# Respond Phase - Format response with Result wrapper
respond_with do |data|
success_result(message("create.success", name: data[:resource].name), data)
end
endAvailable Service Types
1. 📋 IndexService - List Resources
class Product::IndexService < BetterService::Services::IndexService
schema do
optional(:page).filled(:integer, gteq?: 1)
optional(:search).maybe(:string)
end
search_with do
products = user.products
products = products.where("name LIKE ?", "%#{params[:search]}%") if params[:search]
{ items: products.to_a }
end
process_with do |data|
{
items: data[:items],
metadata: {
total: data[:items].count,
page: params[:page] || 1
}
}
end
end
# Usage
result = Product::IndexService.new(current_user, params: { search: "MacBook" }).call
products = result[:items] # => Array of products2. 👁️ ShowService - Show Single Resource
class Product::ShowService < BetterService::Services::ShowService
schema do
required(:id).filled(:integer)
end
search_with do
{ resource: user.products.find(params[:id]) }
end
end
# Usage
result = Product::ShowService.new(current_user, params: { id: 123 }).call
product = result[:resource]3. ➕ CreateService - Create Resource
class Product::CreateService < BetterService::Services::CreateService
# Transaction enabled by default ✅
schema do
required(:name).filled(:string)
required(:price).filled(:decimal, gt?: 0)
end
process_with do |data|
product = user.products.create!(params)
{ resource: product }
end
end
# Usage
result = Product::CreateService.new(current_user, params: {
name: "iPhone",
price: 999
}).call4. ✏️ UpdateService - Update Resource
class Product::UpdateService < BetterService::Services::UpdateService
# Transaction enabled by default ✅
schema do
required(:id).filled(:integer)
optional(:price).filled(:decimal, gt?: 0)
end
authorize_with do
product = Product.find(params[:id])
product.user_id == user.id
end
search_with do
{ resource: user.products.find(params[:id]) }
end
process_with do |data|
product = data[:resource]
product.update!(params.except(:id))
{ resource: product }
end
end5. ❌ DestroyService - Delete Resource
class Product::DestroyService < BetterService::Services::DestroyService
# Transaction enabled by default ✅
schema do
required(:id).filled(:integer)
end
authorize_with do
product = Product.find(params[:id])
user.admin? || product.user_id == user.id
end
search_with do
{ resource: user.products.find(params[:id]) }
end
process_with do |data|
data[:resource].destroy!
{ resource: data[:resource] }
end
end6. ⚡ Custom Action Services
class Product::PublishService < Product::BaseService
# Action name for metadata
performed_action :publish
schema do
required(:id).filled(:integer)
end
authorize_with do
user.can_publish_products?
end
search_with do
{ resource: product_repository.find(params[:id]) }
end
process_with do |data|
product = data[:resource]
product.update!(published: true, published_at: Time.current)
{ resource: product }
end
end
# Usage
result = Product::PublishService.new(current_user, params: { id: 123 }).call
# => { success: true, resource: <Product>, metadata: { action: :publish } }🔐 Authorization
BetterService provides a flexible authorize_with DSL that works with any authorization system:
Simple Role-Based Authorization
class Product::CreateService < Product::BaseService
authorize_with do
# IMPORTANT: Use `next` not `return` (return causes LocalJumpError)
next true if user.admin?
user.seller?
end
endResource Ownership Check (Admin Bypass Pattern)
class Product::UpdateService < Product::BaseService
authorize_with do
# Admin can update any product (even non-existent - will get "not found" error)
next true if user.admin?
# For non-admin, check resource ownership
product = Product.find_by(id: params[:id])
next false unless product # Return unauthorized if product doesn't exist
product.user_id == user.id
end
endPundit Integration
class Product::UpdateService < BetterService::Services::UpdateService
authorize_with do
ProductPolicy.new(user, Product.find(params[:id])).update?
end
endCanCanCan Integration
class Product::DestroyService < BetterService::Services::DestroyService
authorize_with do
Ability.new(user).can?(:destroy, :product)
end
endAuthorization Failure
When authorization fails, the service returns:
{
success: false,
errors: ["Not authorized to perform this action"],
code: :unauthorized
}🔄 Transaction Support
Create, Update, and Destroy services have automatic transaction support enabled by default:
class Product::CreateService < BetterService::Services::CreateService
# Transactions enabled by default ✅
process_with do |data|
product = user.products.create!(params)
# If anything fails here, the entire transaction rolls back
ProductHistory.create!(product: product, action: "created")
NotificationService.notify_admins(product)
{ resource: product }
end
endDisable Transactions
class Product::CreateService < BetterService::Services::CreateService
with_transaction false # Disable transactions
# ...
end📊 Metadata
All services automatically include metadata with the action name:
result = Product::CreateService.new(user, params: { name: "Test" }).call
result[:metadata]
# => { action: :created }
result = Product::UpdateService.new(user, params: { id: 1, name: "Updated" }).call
result[:metadata]
# => { action: :updated }
result = Product::PublishService.new(user, params: { id: 1 }).call
result[:metadata]
# => { action: :publish }You can add custom metadata in the process_with block:
process_with do |data|
{
resource: product,
metadata: {
custom_field: "value",
processed_at: Time.current
}
}
end⚠️ Error Handling
BetterService uses a Pure Exception Pattern where all errors raise exceptions with rich context information. This ensures consistent behavior across all environments (development, test, production).
Exception Hierarchy
BetterServiceError (base class)
├── Configuration Errors (programming errors)
│ ├── SchemaRequiredError - Missing schema definition
│ ├── InvalidSchemaError - Invalid schema syntax
│ ├── InvalidConfigurationError - Invalid config settings
│ └── NilUserError - User is nil when required
│
├── Runtime Errors (execution errors)
│ ├── ValidationError - Parameter validation failed
│ ├── AuthorizationError - User not authorized
│ ├── ResourceNotFoundError - Record not found
│ ├── DatabaseError - Database operation failed
│ ├── TransactionError - Transaction rollback
│ └── ExecutionError - Unexpected error
│
└── Workflowable Errors (workflow errors)
├── Configuration
│ ├── WorkflowConfigurationError - Invalid workflow config
│ ├── StepNotFoundError - Step not found
│ ├── InvalidStepError - Invalid step definition
│ └── DuplicateStepError - Duplicate step name
└── Runtime
├── WorkflowExecutionError - Workflow execution failed
├── StepExecutionError - Step failed
└── RollbackError - Rollback failed
Handling Errors
1. Validation Errors
Validation errors are raised during service initialization (not in call):
begin
service = Product::CreateService.new(current_user, params: {
name: "", # Invalid
price: -10 # Invalid
})
rescue BetterService::Errors::Runtime::ValidationError => e
e.message # => "Validation failed"
e.code # => :validation_failed
# Access validation errors from context
e.context[:validation_errors]
# => {
# name: ["must be filled"],
# price: ["must be greater than 0"]
# }
# Render in controller
render json: {
error: e.message,
errors: e.context[:validation_errors]
}, status: :unprocessable_entity
end2. Authorization Errors
Authorization errors are raised during call:
begin
Product::DestroyService.new(current_user, params: { id: 1 }).call
rescue BetterService::Errors::Runtime::AuthorizationError => e
e.message # => "Not authorized to perform this action"
e.code # => :unauthorized
e.context[:service] # => "Product::DestroyService"
e.context[:user] # => user_id or "nil"
# Render in controller
render json: { error: e.message }, status: :forbidden
end3. Resource Not Found Errors
Raised when ActiveRecord records are not found:
begin
Product::ShowService.new(current_user, params: { id: 99999 }).call
rescue BetterService::Errors::Runtime::ResourceNotFoundError => e
e.message # => "Resource not found: Couldn't find Product..."
e.code # => :resource_not_found
e.original_error # => ActiveRecord::RecordNotFound instance
# Render in controller
render json: { error: "Product not found" }, status: :not_found
end4. Database Errors
Raised for database constraint violations and record invalid errors:
begin
Product::CreateService.new(current_user, params: {
name: "Duplicate", # Unique constraint violation
sku: "INVALID"
}).call
rescue BetterService::Errors::Runtime::DatabaseError => e
e.message # => "Database error: Validation failed..."
e.code # => :database_error
e.original_error # => ActiveRecord::RecordInvalid instance
# Render in controller
render json: { error: e.message }, status: :unprocessable_entity
end5. Workflow Errors
Workflows raise specific errors for step and rollback failures:
begin
OrderPurchaseWorkflow.new(current_user, params: params).call
rescue BetterService::Errors::Workflowable::Runtime::StepExecutionError => e
e.message # => "Step charge_payment failed: Payment declined"
e.code # => :step_failed
e.context[:workflow] # => "OrderPurchaseWorkflow"
e.context[:step] # => :charge_payment
e.context[:steps_executed] # => [:create_order]
rescue BetterService::Errors::Workflowable::Runtime::RollbackError => e
e.message # => "Rollback failed for step charge_payment: Refund failed"
e.code # => :rollback_failed
e.context[:executed_steps] # => [:create_order, :charge_payment]
# ⚠️ Rollback errors indicate potential data inconsistency
endError Information
All BetterServiceError exceptions provide rich debugging information:
begin
service.call
rescue BetterService::BetterServiceError => e
# Basic info
e.message # Human-readable error message
e.code # Symbol code for programmatic handling
e.timestamp # When the error occurred
# Context info
e.context # Hash with service-specific context
# => { service: "MyService", params: {...}, validation_errors: {...} }
# Original error (if wrapping another exception)
e.original_error # The original exception that was caught
# Structured hash for logging
e.to_h
# => {
# error_class: "BetterService::Errors::Runtime::ValidationError",
# message: "Validation failed",
# code: :validation_failed,
# timestamp: "2025-11-09T10:30:00Z",
# context: { service: "MyService", validation_errors: {...} },
# original_error: { class: "StandardError", message: "...", backtrace: [...] },
# backtrace: [...]
# }
# Detailed message with all context
e.detailed_message
# => "Validation failed | Code: validation_failed | Context: {...} | Original: ..."
# Enhanced backtrace (includes original error backtrace)
e.backtrace
# => ["...", "--- Original Error Backtrace ---", "..."]
endController Pattern
Recommended pattern for handling errors in controllers:
class ProductsController < ApplicationController
def create
result = Product::CreateService.new(current_user, params: product_params).call
render json: result, status: :created
rescue BetterService::Errors::Runtime::ValidationError => e
render json: {
error: e.message,
errors: e.context[:validation_errors]
}, status: :unprocessable_entity
rescue BetterService::Errors::Runtime::AuthorizationError => e
render json: { error: e.message }, status: :forbidden
rescue BetterService::Errors::Runtime::ResourceNotFoundError => e
render json: { error: "Resource not found" }, status: :not_found
rescue BetterService::Errors::Runtime::DatabaseError => e
render json: { error: e.message }, status: :unprocessable_entity
rescue BetterService::BetterServiceError => e
# Catch-all for other service errors
Rails.logger.error("Service error: #{e.to_h}")
render json: { error: "An error occurred" }, status: :internal_server_error
end
endOr use a centralized error handler:
class ApplicationController < ActionController::API
rescue_from BetterService::Errors::Runtime::ValidationError do |e|
render json: {
error: e.message,
errors: e.context[:validation_errors]
}, status: :unprocessable_entity
end
rescue_from BetterService::Errors::Runtime::AuthorizationError do |e|
render json: { error: e.message }, status: :forbidden
end
rescue_from BetterService::Errors::Runtime::ResourceNotFoundError do |e|
render json: { error: "Resource not found" }, status: :not_found
end
rescue_from BetterService::Errors::Runtime::DatabaseError do |e|
render json: { error: e.message }, status: :unprocessable_entity
end
rescue_from BetterService::BetterServiceError do |e|
Rails.logger.error("Service error: #{e.to_h}")
render json: { error: "An error occurred" }, status: :internal_server_error
end
end💾 Cache Management
BetterService provides built-in cache management through the BetterService::CacheService module, which works seamlessly with services that use the Cacheable concern.
Cache Invalidation
The CacheService provides several methods for cache invalidation:
Invalidate for Specific User and Context
# Invalidate cache for a specific user and context
BetterService::CacheService.invalidate_for_context(current_user, "products")
# Deletes all cache keys like: products_index:user_123:*:products
# Invalidate asynchronously (requires ActiveJob)
BetterService::CacheService.invalidate_for_context(current_user, "products", async: true)Invalidate Globally for a Context
# Invalidate cache for all users in a specific context
BetterService::CacheService.invalidate_global("sidebar")
# Deletes all cache keys matching: *:sidebar
# Useful after updating global settings that affect all users
BetterService::CacheService.invalidate_global("navigation", async: true)Invalidate All Cache for a User
# Invalidate all cached data for a specific user
BetterService::CacheService.invalidate_for_user(current_user)
# Deletes all cache keys matching: *:user_123:*
# Useful when user permissions or roles change
BetterService::CacheService.invalidate_for_user(user, async: true)Invalidate Specific Key
# Delete a single cache key
BetterService::CacheService.invalidate_key("products_index:user_123:abc:products")Clear All BetterService Cache
# WARNING: Clears ALL BetterService cache
# Use with caution, preferably only in development/testing
BetterService::CacheService.clear_allCache Utilities
Fetch with Caching
# Wrapper around Rails.cache.fetch
result = BetterService::CacheService.fetch("my_key", expires_in: 1.hour) do
expensive_computation
endCheck Cache Existence
if BetterService::CacheService.exist?("my_key")
# Key exists in cache
endGet Cache Statistics
stats = BetterService::CacheService.stats
# => {
# cache_store: "ActiveSupport::Cache::RedisStore",
# supports_pattern_deletion: true,
# supports_async: true
# }Integration with Services
The CacheService automatically works with services using the Cacheable concern:
class Product::IndexService < BetterService::IndexService
cache_key "products_index"
cache_ttl 1.hour
cache_contexts "products", "sidebar"
# Service implementation...
end
# After creating a product, invalidate the cache
Product.create!(name: "New Product")
BetterService::CacheService.invalidate_for_context(current_user, "products")
# Or invalidate globally for all users
BetterService::CacheService.invalidate_global("products")Use Cases
After Model Updates
class Product < ApplicationRecord
after_commit :invalidate_product_cache, on: [ :create, :update, :destroy ]
private
def invalidate_product_cache
# Invalidate for all users
BetterService::CacheService.invalidate_global("products")
end
endAfter User Permission Changes
class User < ApplicationRecord
after_update :invalidate_user_cache, if: :saved_change_to_role?
private
def invalidate_user_cache
# Invalidate all cache for this user
BetterService::CacheService.invalidate_for_user(self)
end
endIn Controllers
class ProductsController < ApplicationController
def create
@product = Product.create!(product_params)
# Invalidate cache for the current user
BetterService::CacheService.invalidate_for_context(current_user, "products")
redirect_to @product
end
endAsync Cache Invalidation
For better performance, use async invalidation with ActiveJob:
# Queues a background job to invalidate cache
BetterService::CacheService.invalidate_for_context(
current_user,
"products",
async: true
)Note: Async invalidation requires ActiveJob to be configured in your Rails application.
Cache Store Compatibility
The CacheService works with any Rails cache store, but pattern-based deletion (delete_matched) requires:
- MemoryStore ✅
- RedisStore ✅
- RedisCacheStore ✅
- MemCachedStore ⚠️ (limited support)
- NullStore ⚠️ (no-op)
- FileStore ⚠️ (limited support)
🔄 Auto-Invalidation Cache
Write operations (Create/Update/Destroy) can automatically invalidate cache after successful execution.
How It Works
Auto-invalidation is enabled by default for Create, Update, and Destroy services when cache contexts are defined:
class Products::CreateService < BetterService::Services::CreateService
cache_contexts :products, :homepage
# Cache is automatically invalidated for these contexts after create!
# No need to call invalidate_cache_for manually
endWhen the service completes successfully:
- The product is created/updated/deleted
- Cache is automatically invalidated for all defined contexts
- All cache keys matching the patterns are cleared
Disabling Auto-Invalidation
Control auto-invalidation with the auto_invalidate_cache DSL:
class Products::CreateService < BetterService::Services::CreateService
cache_contexts :products
auto_invalidate_cache false # Disable automatic invalidation
process_with do |data|
product = user.products.create!(params)
# Manual control: only invalidate for featured products
invalidate_cache_for(user) if product.featured?
{ resource: product }
end
endAsync Invalidation
Combine with async option for non-blocking cache invalidation:
class Products::CreateService < BetterService::Services::CreateService
cache_contexts :products, :homepage
# Auto-invalidation happens async via ActiveJob
cache_async true
endNote: Auto-invalidation only applies to Create, Update, and Destroy services. Index and Show services don't trigger cache invalidation since they're read-only operations.
🌍 Internationalization (I18n)
BetterService includes built-in I18n support for service messages with automatic fallback.
Using the message() Helper
All service templates use the message() helper for response messages:
class Products::CreateService < BetterService::Services::CreateService
respond_with do |data|
success_result(message("create.success"), data)
end
endDefault Messages
BetterService ships with English defaults in config/locales/better_service.en.yml:
en:
better_service:
services:
default:
created: "Resource created successfully"
updated: "Resource updated successfully"
deleted: "Resource deleted successfully"
listed: "Resources retrieved successfully"
shown: "Resource retrieved successfully"Custom Messages
Generate custom locale files for your services:
rails generate better_service:locale productsThis creates config/locales/products_services.en.yml:
en:
products:
services:
create:
success: "Product created and added to inventory"
update:
success: "Product updated successfully"
destroy:
success: "Product removed from catalog"Then configure the namespace in your service:
class Products::CreateService < BetterService::Services::CreateService
messages_namespace :products
respond_with do |data|
# Uses products.services.create.success
success_result(message("create.success"), data)
end
endFallback Chain
Messages follow a 3-level fallback:
- Custom namespace (e.g.,
products.services.create.success) - BetterService defaults (e.g.,
better_service.services.default.created) - Key itself (e.g.,
"create.success")
Message Interpolations
Pass dynamic values to messages:
respond_with do |data|
success_result(
message("create.success", product_name: data[:resource].name),
data
)
endLocale file:
en:
products:
services:
create:
success: "Product '%{product_name}' created successfully"🎨 Presenter System
BetterService includes an optional presenter layer for formatting data for API/view consumption.
Creating Presenters
Generate a presenter class:
rails generate better_service:presenter ProductThis creates:
app/presenters/product_presenter.rbtest/presenters/product_presenter_test.rb
class ProductPresenter < BetterService::Presenter
def as_json(opts = {})
{
id: object.id,
name: object.name,
price: object.price,
display_name: "#{object.name} - $#{object.price}",
# Conditional fields based on user permissions
**(admin_fields if current_user&.admin?)
}
end
private
def admin_fields
{
cost: object.cost,
margin: object.price - object.cost
}
end
endUsing Presenters in Services
Configure presenters via the presenter DSL:
class Products::IndexService < BetterService::Services::IndexService
presenter ProductPresenter
presenter_options do
{ current_user: user }
end
# Items are automatically formatted via ProductPresenter#as_json
endPresenter Features
Available Methods:
-
object- The resource being presented -
options- Options hash passed viapresenter_options -
current_user- Shortcut foroptions[:current_user] -
as_json(opts)- Format object as JSON -
to_json(opts)- Serialize to JSON string -
to_h- Alias foras_json
Example with scaffold:
# Generate services + presenter in one command
rails generate serviceable:scaffold Product --presenter🏗️ Generators
BetterService includes 10 powerful generators:
Scaffold Generator
Generates all 5 CRUD services at once:
rails generate serviceable:scaffold Product
# With presenter
rails generate serviceable:scaffold Product --presenterCreates:
app/services/product/index_service.rbapp/services/product/show_service.rbapp/services/product/create_service.rbapp/services/product/update_service.rbapp/services/product/destroy_service.rb- (Optional)
app/presenters/product_presenter.rbwith--presenter
Individual Generators
# CRUD Services
rails generate serviceable:index Product
rails generate serviceable:show Product
rails generate serviceable:create Product
rails generate serviceable:update Product
rails generate serviceable:destroy Product
# Custom action service
rails generate serviceable:action Product publish
# Workflow for composing services
rails generate serviceable:workflow OrderPurchase --steps create_order charge_payment
# Presenter for data transformation
rails generate better_service:presenter Product
# Custom locale file for I18n messages
rails generate better_service:locale products🎯 Examples
Complete CRUD Workflow
# 1. List products
index_result = Product::IndexService.new(current_user, params: {
search: "MacBook",
page: 1
}).call
products = index_result[:items]
# 2. Show a product
show_result = Product::ShowService.new(current_user, params: {
id: products.first.id
}).call
product = show_result[:resource]
# 3. Create a new product
create_result = Product::CreateService.new(current_user, params: {
name: "New Product",
price: 99.99
}).call
new_product = create_result[:resource]
# 4. Update the product
update_result = Product::UpdateService.new(current_user, params: {
id: new_product.id,
price: 149.99
}).call
# 5. Publish the product (custom action)
publish_result = Product::PublishService.new(current_user, params: {
id: new_product.id
}).call
# 6. Delete the product
destroy_result = Product::DestroyService.new(current_user, params: {
id: new_product.id
}).callController Integration
class ProductsController < ApplicationController
def create
result = Product::CreateService.new(current_user, params: product_params).call
if result[:success]
render json: {
product: result[:resource],
message: result[:message],
metadata: result[:metadata]
}, status: :created
else
render json: {
errors: result[:errors]
}, status: :unprocessable_entity
end
end
private
def product_params
params.require(:product).permit(:name, :price, :description)
end
end🔗 Workflows - Service Composition
Workflows allow you to compose multiple services into a pipeline with explicit data mapping, conditional execution, automatic rollback, and lifecycle hooks.
Creating a Workflow
Generate a workflow with the generator:
rails generate serviceable:workflow OrderPurchase --steps create_order charge_payment send_emailThis creates app/workflows/order_purchase_workflow.rb:
class OrderPurchaseWorkflow < BetterService::Workflow
# Enable database transactions for the entire workflow
with_transaction true
# Lifecycle hooks
before_workflow :validate_cart
after_workflow :clear_cart
around_step :log_step
# Step 1: Create order
step :create_order,
with: Order::CreateService,
input: ->(ctx) { { items: ctx.cart_items, total: ctx.total } }
# Step 2: Charge payment with rollback
step :charge_payment,
with: Payment::ChargeService,
input: ->(ctx) { { amount: ctx.order.total } },
rollback: ->(ctx) { Payment::RefundService.new(ctx.user, params: { charge_id: ctx.charge.id }).call }
# Step 3: Send email (optional, won't stop workflow if fails)
step :send_email,
with: Email::ConfirmationService,
input: ->(ctx) { { order_id: ctx.order.id } },
optional: true,
if: ->(ctx) { ctx.user.notifications_enabled? }
private
def validate_cart(context)
context.fail!("Cart is empty") if context.cart_items.empty?
end
def clear_cart(context)
context.user.clear_cart! if context.success?
end
def log_step(step, context)
Rails.logger.info "Executing: #{step.name}"
yield
Rails.logger.info "Completed: #{step.name}"
end
endUsing a Workflow
# In your controller
result = OrderPurchaseWorkflow.new(current_user, params: {
cart_items: [...],
payment_method: "card_123"
}).call
if result[:success]
# Access context data
order = result[:context].order
charge = result[:context].charge_payment
render json: {
order: order,
metadata: result[:metadata]
}, status: :created
else
render json: {
errors: result[:errors],
failed_at: result[:metadata][:failed_step]
}, status: :unprocessable_entity
endWorkflow Features
1. Explicit Input Mapping
Each step defines how data flows from the context to the service:
step :charge_payment,
with: Payment::ChargeService,
input: ->(ctx) {
{
amount: ctx.order.total,
currency: ctx.order.currency,
payment_method: ctx.payment_method
}
}2. Conditional Steps
Steps can execute conditionally:
step :send_sms,
with: SMS::NotificationService,
input: ->(ctx) { { order_id: ctx.order.id } },
if: ->(ctx) { ctx.user.sms_enabled? && ctx.order.total > 100 }3. Optional Steps
Optional steps won't stop the workflow if they fail:
step :update_analytics,
with: Analytics::TrackService,
input: ->(ctx) { { event: 'order_created', order_id: ctx.order.id } },
optional: true # Won't fail workflow if analytics service is down4. Automatic Rollback
Define rollback logic for each step:
step :charge_payment,
with: Payment::ChargeService,
input: ->(ctx) { { amount: ctx.order.total } },
rollback: ->(ctx) {
# Automatically called if a later step fails
Stripe::Refund.create(charge: ctx.charge_payment.id)
}When a step fails, all previously executed steps' rollback blocks are called in reverse order.
5. Transaction Support
Wrap the entire workflow in a database transaction:
class MyWorkflow < BetterService::Workflow
with_transaction true # DB changes are rolled back if workflow fails
end6. Lifecycle Hooks
before_workflow: Runs before any step executes
before_workflow :validate_prerequisites
def validate_prerequisites(context)
context.fail!("User not verified") unless context.user.verified?
endafter_workflow: Runs after all steps complete (success or failure)
after_workflow :log_completion
def log_completion(context)
Rails.logger.info "Workflow completed: success=#{context.success?}"
endaround_step: Wraps each step execution
around_step :measure_performance
def measure_performance(step, context)
start = Time.current
yield # Execute the step
duration = Time.current - start
Rails.logger.info "Step #{step.name}: #{duration}s"
endWorkflow Response
Workflows return a standardized response:
{
success: true/false,
message: "Workflow completed successfully",
context: <Context object with all data>,
metadata: {
workflow: "OrderPurchaseWorkflow",
steps_executed: [:create_order, :charge_payment, :send_email],
steps_skipped: [],
failed_step: nil, # :step_name if failed
duration_ms: 245.67
}
}Context Object
The context object stores all workflow data and is accessible across all steps:
# Set data
context.order = Order.create!(...)
context.add(:custom_key, value)
# Get data
order = context.order
value = context.get(:custom_key)
# Check status
context.success? # => true
context.failure? # => false
# Fail manually
context.fail!("Custom error message", field: "error detail")Generator Options
# Basic workflow
rails generate serviceable:workflow OrderPurchase
# With steps
rails generate serviceable:workflow OrderPurchase --steps create charge notify
# With transaction enabled
rails generate serviceable:workflow OrderPurchase --transaction
# Skip test file
rails generate serviceable:workflow OrderPurchase --skip-test🧪 Testing
BetterService includes comprehensive test coverage. Run tests with:
# Run all tests
bundle exec rake
# Or
bundle exec rspecManual Testing
A manual test script is included for hands-on verification:
cd spec/rails_app
rails console
load '../../manual_test.rb'This runs 8 comprehensive tests covering all service types with automatic database rollback.
🤝 Contributing
Contributions are welcome! Here's how you can help:
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
Please make sure to:
- Add tests for new features
- Update documentation
- Follow the existing code style
🎉 Recent Features
Observability & Instrumentation ✨
BetterService now includes comprehensive instrumentation powered by ActiveSupport::Notifications:
-
Automatic Event Publishing:
service.started,service.completed,service.failed,cache.hit,cache.miss - Built-in Subscribers: LogSubscriber and StatsSubscriber for monitoring
- Easy Integration: DataDog, New Relic, Grafana, and custom subscribers
- Zero Configuration: Works out of the box, fully configurable
# Enable monitoring in config/initializers/better_service.rb
BetterService.configure do |config|
config.instrumentation_enabled = true
config.log_subscriber_enabled = true
config.stats_subscriber_enabled = true
end
# Custom subscriber
ActiveSupport::Notifications.subscribe("service.completed") do |name, start, finish, id, payload|
DataDog.histogram("service.duration", payload[:duration])
endSee Configuration Guide for more details.
📄 License
The gem is available as open source under the terms of the WTFPL License.
Made with ❤️ by Alessio Bussolari