Write Rails service objects following the project's Base service pattern: module-namespaced classes inheriting from Base, chain-style call methods with early returns, OpenStruct responses, ActiveRecord transactions, I18n message keys, and serialized data output. Use this skill whenever creating service classes for CRUD operations, business logic, or any work in app/services/. Triggers on: 'create service', 'add service', 'service object', 'business logic', 'new service file', 'call method', or any task involving app/services/.
Services encapsulate all business logic in this project. They inherit from a shared Base class that provides error handling, response building, and I18n integration. Every controller action delegates to a service -- controllers never contain business logic.
Services are organized as module-namespaced classes:
app/services/
base.rb # Shared superclass
widget_service/
index.rb # WidgetService::Index
create.rb # WidgetService::Create
update.rb # WidgetService::Update
destroy.rb # WidgetService::Destroy
show.rb # WidgetService::Show
bulk_create.rb # WidgetService::BulkCreate (if XL import)
Each operation is its own class. This keeps files small and responsibilities clear.
All services inherit from Base, which provides:
@errors — instance for collecting errorsActiveModel::Errors@data — hash for response payload@message — string for success messagesadd_error(key, params) — adds a translated error from I18nset_response — builds the final OpenStruct (checks errors, returns success or failure)success_response(data, message) — shorthand for successful responsesfailure_response(data) — shorthand for failure responsestranslate(key, params) — wraps I18n.tsanitize_sql_like(string) — escapes SQL LIKE wildcardsThe response format is always:
# Success
OpenStruct.new(success: true, data: {...}, message: "Translated message")
# Failure
OpenStruct.new(success: false, errors: { base: ["Translated error"] }, ...)
# frozen_string_literal: true
module WidgetService
class Index < Base
attr_accessor :result, :current_user, :widgets, :type
SUCCESS_KEY = 'widget.index.success.message'
def initialize(params, current_user)
super()
@type = params[:type]&.to_s&.downcase == 'f'
@current_user = current_user
end
def call
fetch_widgets &&
(type ? set_data_for_front_end : set_data) &&
set_response
end
private
def fetch_widgets
@widgets = WidgetRepository.fetch_widgets(current_user[:org_uid])
end
def set_data
@data = V1::WidgetSerializer.new(widgets).serializable_hash
@message = translate(SUCCESS_KEY)
end
def set_data_for_front_end
widget_list = widgets&.map { |w| [w.uid, w.name] }.to_h
@result = { widget_list: }
@data = { data: generate_array_of_hash }
@message = translate(SUCCESS_KEY)
end
end
end
# frozen_string_literal: true
module WidgetService
class Create < Base
attr_reader :params, :current_user, :widget
SUCCESS_KEY = 'widget.create.success.message'
def initialize(params, current_user)
super()
@params = params.to_h.symbolize_keys
@current_user = current_user
end
def call
result = ActiveRecord::Base.transaction do
create_widget &&
set_data &&
set_response
end
result || set_response
end
private
def create_widget
@widget = Widget.new(widget_params)
return true if widget.save
add_error('widget.create.failure', message: widget.errors.full_messages.join(', '))
false
end
def widget_params
{
name: params[:name],
code: params[:code],
organisation_uid: current_user[:org_uid],
status: params[:status] || 'active'
}
end
def set_data
@data = V1::WidgetSerializer.new(widget).serializable_hash
@message = translate(SUCCESS_KEY)
end
end
end
# frozen_string_literal: true
module WidgetService
class Update < Base
attr_reader :params, :current_user, :widget
SUCCESS_KEY = 'widget.update.success.message'
def initialize(params, current_user)
super()
@params = params.to_h.symbolize_keys
@current_user = current_user
end
def call
result = ActiveRecord::Base.transaction do
find_widget &&
update_widget &&
set_data &&
set_response
end
result || set_response
end
private
def find_widget
@widget = WidgetRepository.find_by_column(:uid, params[:id])
return true if widget
add_error('widget.not_found')
false
end
def update_widget
return true if widget.update(widget_params)
add_error('widget.update.failure', message: widget.errors.full_messages.join(', '))
false
end
def widget_params
params.slice(:name, :code, :status)
end
def set_data
@data = V1::WidgetSerializer.new(widget.reload).serializable_hash
@message = translate(SUCCESS_KEY)
end
end
end
# frozen_string_literal: true
module WidgetService
class Destroy < Base
attr_reader :params, :current_user, :widget
SUCCESS_KEY = 'widget.destroy.success.message'
def initialize(params, current_user)
super()
@params = params.to_h.symbolize_keys
@current_user = current_user
end
def call
find_widget &&
destroy_widget &&
set_response
end
private
def find_widget
@widget = WidgetRepository.find_by_column(:uid, params[:id])
return true if widget
add_error('widget.not_found')
false
end
def destroy_widget
return true if widget.destroy
add_error('widget.destroy.failure')
false
end
def set_response
return super if errors.present?
@message = translate(SUCCESS_KEY)
super
end
end
end
The call method chains private methods with &&. Each method returns true on success or false on failure (after calling add_error). This creates natural early returns -- if any step fails, the chain stops and set_response picks up the errors:
def call
step_one &&
step_two &&
step_three &&
set_response
end
When using transactions, wrap the chain and fall back to set_response if the transaction returns nil (from a rollback):
def call
result = ActiveRecord::Base.transaction do
step_one &&
step_two &&
set_response
end
result || set_response
end
Every service defines SUCCESS_KEY and references error keys via add_error. The key structure in config/locales/en.yml: