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
88%
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Passed
No known issues
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.| Convention | Rule |
|---|---|
| Entry point | .call class method delegating to new.call |
| Response format | { success: true/false, response: { ... } } |
| File location | app/services/module_name/service_name.rb |
| Pragma | frozen_string_literal: true in every file |
| Docs | YARD on every public method (see yard-documentation) |
| Validation | Raise early on invalid input |
| Errors | Rescue, log, return error hash — don't leak exceptions |
| Transactions | Wrap multi-step DB operations |
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.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# 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'] }
}
}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)
endWhen 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
endclass 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# frozen_string_literal: true
module ModuleName
class ServiceName
end
endMISSING_CONFIGURATION_ERROR = 'Missing required configuration'
DEFAULT_TIMEOUT = 30self.defaultdef self.default
token = Auth.default.token
host = Rails.configuration.secrets[:service_host]
new(token:, host:)
end# @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)def initialize(token:, host:, warehouse_id:)
raise Error, MISSING_CONFIGURATION_ERROR if [token, host, warehouse_id].any?(&:blank?)
enddef 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
enddef 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
enddef self.find(tag_number:)
query = ActiveRecord::Base.sanitize_sql(['SELECT * FROM table WHERE tag_number = ?;', tag_number])
fetcher.execute_query(query)
endfrozen_string_literal: true pragma.call class method as entry point{ success:, response: } return formatrescue and log_errorREADME.md documenting the moduleBad: 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
endGood: 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| Mistake | Reality |
|---|---|
| Returning raw exceptions instead of error hash | Callers should get { success: false, ... }, not unhandled exceptions |
No .call entry point | Inconsistent API. Always use .call for the orchestrator pattern |
| Business logic in the controller | Extract to service. Controller should only handle request/response |
Missing frozen_string_literal pragma | Inconsistent string behavior. Add to every file |
| No YARD docs on public methods | Other developers can't understand the contract |
| Skipping input validation | Bad input causes cryptic errors deep in the call chain |
| Transaction wrapping everything | Only wrap multi-step DB operations that must be atomic |
.call method longer than 20 lines (needs sub-service extraction)| Skill | When to chain |
|---|---|
| yard-documentation | When writing or reviewing inline docs for classes and public methods |
| ruby-api-client-integration | For external API integrations (Auth, Client, Fetcher, Builder layers) |
| strategy-factory-null-calculator | For variant-based calculators (Factory + Strategy + Null Object) |
| rspec-service-testing | For testing service objects |
| rspec-best-practices | For general RSpec structure |
| rails-architecture-review | When service extraction is part of an architecture review |
ae8ea63
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.