CtrlK
BlogDocsLog inGet started
Tessl Logo

ruby-service-objects

Use when creating or refactoring Ruby service classes in Rails. Covers the .call pattern, module namespacing, YARD documentation, standardized responses, orchestrator delegation, transaction wrapping, and error handling conventions.

90

Quality

88%

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

SecuritybySnyk

Passed

No known issues

SKILL.md
Quality
Evals
Security

Ruby Service Objects

HARD-GATE: Tests Gate Implementation

EVERY service object MUST have its test written and validated BEFORE implementation.
  1. Write the spec for .call (with contexts for success, error, edge cases)
  2. Run the spec — verify it fails because the service does not exist yet
  3. ONLY THEN write the service implementation
See rspec-best-practices for the full gate cycle.

Quick Reference

ConventionRule
Entry point.call class method delegating to new.call
Response format{ success: true/false, response: { ... } }
File locationapp/services/module_name/service_name.rb
Pragmafrozen_string_literal: true in every file
DocsYARD on every public method (see yard-documentation)
ValidationRaise early on invalid input
ErrorsRescue, log, return error hash — don't leak exceptions
TransactionsWrap multi-step DB operations

Structure

All service objects live under app/services/ namespaced by module. Use frozen_string_literal: true in every file.

app/services/
└── module_name/
    ├── README.md
    ├── main_service.rb
    ├── validator.rb
    ├── classifier.rb
    ├── creator.rb
    ├── response_builder.rb
    ├── auth.rb
    ├── client.rb
    ├── fetcher.rb
    └── builder.rb

Core Patterns

1. The .call Pattern (Orchestrator)

module AnimalTransfers
  class TransferService
    attr_reader :source_shelter_id, :target_shelter_id

    def self.call(params)
      new(params).call
    end

    def initialize(params)
      @source_shelter_id = params.dig(:source_shelter, :shelter_id)
      @target_shelter_id = params.dig(:target_shelter, :shelter_id)
    end

    def call
      validate_shelters!
      result = process_data
      build_success_response(result)
    rescue ActiveRecord::RecordInvalid => e
      log_error('Validation Error', e)
      build_error_response(e.message, [])
    rescue StandardError => e
      log_error('Processing Error', e, include_backtrace: true)
      build_error_response(e.message, [])
    end
  end
end

2. Standardized Response Format

# Success
{ success: true, response: { successful_items: [...] } }

# Error
{ success: false, response: { error: { message: '...', failed_items: [...] } } }

# Partial success
{
  success: true,
  response: {
    successful_transfers: ['TAG001'],
    error: { message: 'Some animals were not found...', failed_transfers: ['TAG002'] }
  }
}

3. Orchestrator Pattern

Main service coordinates sub-services, each with a single responsibility:

def call
  validate_shelters!
  return empty_response if items.blank?

  classification = Classifier.classify(items, context)
  return all_failed_response(classification) if all_failed?(classification)

  persistence = Creator.create(classification, context)
  ResponseBuilder.success_response(classification, persistence)
rescue StandardError => e
  log_error('Processing Error', e, include_backtrace: true)
  ResponseBuilder.error_response(e.message)
end

4. Class-only Services (Static Methods)

When no instance state is needed:

class ShelterValidator
  def self.validate_source_shelter!(shelter_id)
    shelter = Shelter.find_by(id: shelter_id)
    raise ArgumentError, 'Source shelter not found' unless shelter
    shelter
  end
end

5. Response Builder Pattern

class ResponseBuilder
  def self.success_response(shelter_id, result)
    { success: true, response: build_base_response(shelter_id, result[:items]) }
  end

  def self.error_response(shelter_id, message, failed_items)
    { success: false, response: { shelter: { shelter_id: }, error: { message:, failed_items: } } }
  end
end

Conventions

Module namespacing

# frozen_string_literal: true

module ModuleName
  class ServiceName
  end
end

Constants for configuration

MISSING_CONFIGURATION_ERROR = 'Missing required configuration'
DEFAULT_TIMEOUT = 30

Factory methods with self.default

def self.default
  token = Auth.default.token
  host = Rails.configuration.secrets[:service_host]
  new(token:, host:)
end

YARD documentation

# @param params [Hash] Transfer parameters
# @option params [Hash] :source_shelter Shelter hash with :shelter_id
# @return [Hash] Result hash with :success flag and :response data
def self.call(params)

Input validation

def initialize(token:, host:, warehouse_id:)
  raise Error, MISSING_CONFIGURATION_ERROR if [token, host, warehouse_id].any?(&:blank?)
end

Transaction wrapping

def call
  animal = ActiveRecord::Base.transaction do
    animal = create_animal_from_holding_pen
    HoldingPen::AnimalActivator.call(animal:, holding_pen:)
    animal
  end
  Events::Animal.on_create(animal:)
  animal
end

Error logging with context

def log_error(context, error, include_backtrace: false)
  Rails.logger.error("#{self.class.name} #{context}: #{error.class} - #{error.message}")
  Rails.logger.error(error.backtrace.join("\n")) if include_backtrace
end

SQL sanitization

def self.find(tag_number:)
  query = ActiveRecord::Base.sanitize_sql(['SELECT * FROM table WHERE tag_number = ?;', tag_number])
  fetcher.execute_query(query)
end

Checklist for New Service Objects

  • frozen_string_literal: true pragma
  • Module namespace matching directory structure
  • .call class method as entry point
  • Constants for error messages and defaults
  • YARD docs on every public method
  • Input validation (raise early on invalid input)
  • Standardized { success:, response: } return format
  • Error wrapping with rescue and log_error
  • Transaction wrapping for multi-step DB operations
  • Graceful handling for non-critical failures
  • SQL sanitization for dynamic queries
  • README.md documenting the module

Examples

Bad: Business logic in controller:

# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
  def create
    @order = Order.new(order_params)
    @order.status = 'pending'
    @order.total_amount = calculate_total(@order.line_items)
    if @order.save
      send_order_confirmation_email(@order)
      redirect_to @order, notice: 'Order was successfully created.'
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def calculate_total(items)
    # Complex pricing logic here...
  end

  def send_order_confirmation_email(order)
    # Email sending logic here...
  end
end

Good: Business logic extracted to service object:

# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
  def create
    result = Orders::CreateOrder.call(order_params.to_h.symbolize_keys)
    if result[:success]
      @order = result[:response][:order]
      redirect_to @order, notice: 'Order was successfully created.'
    else
      @order = Order.new(order_params) # Re-initialize for form display
      flash.now[:alert] = result[:response][:error][:message]
      render :new, status: :unprocessable_entity
    end
  end
end

# app/services/orders/create_order.rb
module Orders
  class CreateOrder < RubyServiceObjects::BaseService # Assuming BaseService exists
    def initialize(params)
      @params = params
    end

    def call
      # Complex business logic, transactions, and side effects here
      # ...
      order = Order.new(@params)
      order.status = 'pending'
      order.total_amount = calculate_total(order.line_items)

      if order.save
        OrderMailer.confirmation(order).deliver_later
        success(order: order)
      else
        failure(order.errors.full_messages.to_sentence)
      end
    end

    private

    def calculate_total(items)
      # ... complex pricing logic ...
    end
  end
end

Common Mistakes

MistakeReality
Returning raw exceptions instead of error hashCallers should get { success: false, ... }, not unhandled exceptions
No .call entry pointInconsistent API. Always use .call for the orchestrator pattern
Business logic in the controllerExtract to service. Controller should only handle request/response
Missing frozen_string_literal pragmaInconsistent string behavior. Add to every file
No YARD docs on public methodsOther developers can't understand the contract
Skipping input validationBad input causes cryptic errors deep in the call chain
Transaction wrapping everythingOnly wrap multi-step DB operations that must be atomic

Red Flags

  • Service object with no tests
  • .call method longer than 20 lines (needs sub-service extraction)
  • Service that directly renders HTTP responses (that's controller's job)
  • No error handling — exceptions bubble up to caller unhandled
  • Service that modifies unrelated models (unclear responsibility boundary)
  • Duplicated validation logic across services (extract to shared validator)

Integration

SkillWhen to chain
yard-documentationWhen writing or reviewing inline docs for classes and public methods
ruby-api-client-integrationFor external API integrations (Auth, Client, Fetcher, Builder layers)
strategy-factory-null-calculatorFor variant-based calculators (Factory + Strategy + Null Object)
rspec-service-testingFor testing service objects
rspec-best-practicesFor general RSpec structure
rails-architecture-reviewWhen service extraction is part of an architecture review
Repository
igmarin/rails-agent-skills
Last updated
Created

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.