0.0
The project is in a healthy, maintained state
A powerful DSL-based framework for building Service Objects in Rails with built-in support for validation, caching, presenters, and more.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
 Dependencies

Development

Runtime

~> 1.13
~> 8.1, >= 8.1.1
 Project Readme

BetterService

Clean, powerful Service Objects for Rails

Gem Version CI Codecov Ruby Rails License

Downloads Documentation GitHub issues GitHub stars Contributors

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::Result with .success?, .resource, .meta, .message and destructuring support
  • 🏛️ Repository Pattern: Clean data access with RepositoryAware concern and repository :model_name DSL
  • Mandatory Schema Validation: Built-in Dry::Schema validation for all params
  • 🔄 Transaction Support: Automatic database transaction wrapping with rollback
  • 🔐 Flexible Authorization: authorize_with DSL 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 CacheService for 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::Presenter base 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/otherwise DSL 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 install

Or 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 publish

2. 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
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

  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
end

5. ❌ 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
end

6. ⚡ 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
end

Resource 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
end

Pundit Integration

class Product::UpdateService < BetterService::Services::UpdateService
  authorize_with do
    ProductPolicy.new(user, Product.find(params[:id])).update?
  end
end

CanCanCan Integration

class Product::DestroyService < BetterService::Services::DestroyService
  authorize_with 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)
├── 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
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.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
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.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
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.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
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.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
end

Error 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 ---", "..."]
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.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
end

Or 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_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:

  1. The product is created/updated/deleted
  2. Cache is automatically invalidated for all defined contexts
  3. 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(message("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
  messages_namespace :products

  respond_with do |data|
    # Uses products.services.create.success
    success_result(message("create.success"), data)
  end
end

Fallback Chain

Messages follow a 3-level fallback:

  1. Custom namespace (e.g., products.services.create.success)
  2. BetterService defaults (e.g., better_service.services.default.created)
  3. 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
  )
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.rb
  • test/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

  presenter_options do
    { current_user: user }
  end

  # Items are automatically formatted via ProductPresenter#as_json
end

Presenter Features

Available Methods:

  • object - The resource being presented
  • options - Options hash passed via presenter_options
  • current_user - Shortcut for options[:current_user]
  • as_json(opts) - Format object as JSON
  • to_json(opts) - Serialize to JSON string
  • to_h - Alias for as_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.rb
  • app/services/product/show_service.rb
  • app/services/product/create_service.rb
  • app/services/product/update_service.rb
  • app/services/product/destroy_service.rb
  • (Optional) app/presenters/product_presenter.rb with --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 rspec

Manual 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:

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. 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.