โจ Features
BetterService is a comprehensive Service Objects framework for Rails that brings clean architecture and powerful features to your business logic layer:
- ๐ฏ 5-Phase Flow Architecture: Structured flow with search โ process โ transform โ respond โ viewer phases
- โ 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
- ๐พ 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 (v1.1.0+): Multi-path workflow execution with
branch/on/otherwiseDSL for clean conditional logic - ๐๏ธ Powerful Generators: 10 generators for rapid scaffolding (scaffold, CRUD services, action, workflow, locale, presenter)
- ๐ฆ 6 Service Types: Specialized services for different use cases
- ๐จ 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 install
Or install it yourself as:
gem install better_service
๐ Quick Start
1. Generate a Service
# Generate a complete CRUD scaffold
rails generate serviceable:scaffold Product
# Or generate individual services
rails generate serviceable:create Product
rails generate serviceable:update Product
rails generate serviceable:action Product publish
2. Use the Service
# Create a product
result = Product::CreateService.new(current_user, params: {
name: "MacBook Pro",
price: 2499.99
}).call
if result[:success]
product = result[:resource]
# => Product object
action = result[:metadata][:action]
# => :created
else
errors = result[:errors]
# => { name: ["can't be blank"], price: ["must be greater than 0"] }
end
๐ 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 Structure
All services follow a 5-phase flow:
class Product::CreateService < BetterService::Services::CreateService
# 1. Schema Validation (mandatory)
schema do
required(:name).filled(:string)
required(:price).filled(:decimal, gt?: 0)
end
# 2. Authorization (optional)
do
user.admin? || user.can_create_products?
end
# 3. Search Phase - Load data
search_with do
{ category: Category.find_by(id: params[:category_id]) }
end
# 4. Process Phase - Business logic
process_with do |data|
product = user.products.create!(params)
{ resource: product }
end
# 5. Respond Phase - Format response
respond_with do |data|
success_result("Product created successfully", data)
end
end
Available 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 products
2. ๐๏ธ 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
}).call
4. โ๏ธ 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
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
end
5. โ DestroyService - Delete Resource
class Product::DestroyService < BetterService::Services::DestroyService
# Transaction enabled by default โ
schema do
required(:id).filled(:integer)
end
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
end
6. โก ActionService - Custom Actions
class Product::PublishService < BetterService::Services::ActionService
action_name :publish
schema do
required(:id).filled(:integer)
end
do
user.can_publish_products?
end
search_with do
{ resource: user.products.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 < BetterService::Services::CreateService
do
user.admin?
end
end
Resource Ownership Check
class Product::UpdateService < BetterService::Services::UpdateService
do
product = Product.find(params[:id])
product.user_id == user.id
end
end
Pundit Integration
class Product::UpdateService < BetterService::Services::UpdateService
do
ProductPolicy.new(user, Product.find(params[:id])).update?
end
end
CanCanCan Integration
class Product::DestroyService < BetterService::Services::DestroyService
do
Ability.new(user).can?(:destroy, :product)
end
end
Authorization 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
end
Disable 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)
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. # => "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.,
errors: e.context[:validation_errors]
}, status: :unprocessable_entity
end
2. 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. # => "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. }, status: :forbidden
end
3. 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. # => "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
end
4. 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. # => "Database error: Validation failed..."
e.code # => :database_error
e.original_error # => ActiveRecord::RecordInvalid instance
# Render in controller
render json: { error: e. }, status: :unprocessable_entity
end
5. 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. # => "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. # => "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
end
Error Information
All BetterServiceError exceptions provide rich debugging information:
begin
service.call
rescue BetterService::BetterServiceError => e
# Basic info
e. # Human-readable error message
e.code # Symbol code for programmatic handling
e. # 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.
# => "Validation failed | Code: validation_failed | Context: {...} | Original: ..."
# Enhanced backtrace (includes original error backtrace)
e.backtrace
# => ["...", "--- Original Error Backtrace ---", "..."]
end
Controller 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.,
errors: e.context[:validation_errors]
}, status: :unprocessable_entity
rescue BetterService::Errors::Runtime::AuthorizationError => e
render json: { error: e. }, 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. }, 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
end
Or use a centralized error handler:
class ApplicationController < ActionController::API
rescue_from BetterService::Errors::Runtime::ValidationError do |e|
render json: {
error: e.,
errors: e.context[:validation_errors]
}, status: :unprocessable_entity
end
rescue_from BetterService::Errors::Runtime::AuthorizationError do |e|
render json: { error: e. }, 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. }, 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_all
Cache Utilities
Fetch with Caching
# Wrapper around Rails.cache.fetch
result = BetterService::CacheService.fetch("my_key", expires_in: 1.hour) do
expensive_computation
end
Check Cache Existence
if BetterService::CacheService.exist?("my_key")
# Key exists in cache
end
Get 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
end
After 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
end
In 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
end
Async 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
end
When 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
end
Async 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
end
Note: 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(("create.success"), data)
end
end
Default 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 products
This 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
:products
respond_with do |data|
# Uses products.services.create.success
success_result(("create.success"), data)
end
end
Fallback 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(
("create.success", product_name: data[:resource].name),
data
)
end
Locale 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 Product
This 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
end
Using Presenters in Services
Configure presenters via the presenter DSL:
class Products::IndexService < BetterService::Services::IndexService
presenter ProductPresenter
do
{ current_user: user }
end
# Items are automatically formatted via ProductPresenter#as_json
end
Presenter Features
Available Methods:
object- The resource being presentedoptions- Options hash passed viapresenter_optionscurrent_user- Shortcut foroptions[:current_user]as_json(opts)- Format object as JSONto_json(opts)- Serialize to JSON stringto_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 --presenter
Creates:
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
}).call
Controller 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_email
This 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
end
Using 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
end
Workflow 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 down
4. 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
end
6. 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?
end
after_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?}"
end
around_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"
end
Workflow 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 rake test
Manual Testing
A manual test script is included for hands-on verification:
cd test/dummy
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])
end
See Configuration Guide for more details.
๐ License
The gem is available as open source under the terms of the WTFPL License.