Service Object Patterns
SKILL.md
Service Object Patterns Skill
This skill provides comprehensive guidance for implementing Service Objects in Rails applications following consistent patterns and conventions.
When to Use This Skill
- Creating new service objects for business logic
- Refactoring fat models or controllers
- Designing service interfaces
- Implementing result objects for service responses
- Organizing services into namespaces
When to Use Service Objects
Use Service Objects When:
- Business logic spans multiple models
- Operation has multiple steps/side effects
- Logic doesn't naturally belong to one model
- Need to orchestrate external services
- Complex validation or business rules
- Operation needs transaction management
Don't Use Service Objects When:
- Simple CRUD operations
- Logic clearly belongs to one model
- Single-line delegation
- No side effects beyond model updates
Directory Structure
app/services/
├── application_service.rb # Base class
├── tasks_manager/
│ ├── create_task.rb
│ ├── assign_carrier.rb
│ ├── complete_task.rb
│ └── bundling/
│ ├── bundle_tasks.rb
│ └── optimize_routes.rb
├── billing_manager/
│ ├── generate_invoice.rb
│ ├── process_payment.rb
│ └── calculate_fees.rb
├── notifications_manager/
│ ├── send_sms.rb
│ └── send_push_notification.rb
└── integrations/
├── salla/
│ └── sync_orders.rb
└── shipping/
└── create_label.rb
Naming Convention
# Pattern: {Domain}Manager::{Action} or {Domain}Manager::{SubDomain}::{Action}
# Examples:
TasksManager::CreateTask
TasksManager::Bundling::BundleTasks
BillingManager::GenerateInvoice
IntegrationsManager::Salla::SyncOrders
Base Service Class
# app/services/application_service.rb
class ApplicationService
def self.call(...)
new(...).call
end
private
attr_reader :params
def initialize(**params)
@params = params
end
end
Basic Service Pattern
# app/services/tasks_manager/create_task.rb
module TasksManager
class CreateTask < ApplicationService
def initialize(account:, merchant:, params:)
@account = account
@merchant = merchant
@params = params
end
def call
validate_params!
ActiveRecord::Base.transaction do
task = build_task
assign_zone(task)
task.save!
schedule_notifications(task)
task
end
end
private
attr_reader :account, :merchant, :params
def validate_params!
raise ArgumentError, "Recipient required" unless params[:recipient_id]
raise ArgumentError, "Address required" unless params[:address]
end
def build_task
account.tasks.build(
merchant: merchant,
recipient_id: params[:recipient_id],
description: params[:description],
amount: params[:amount],
status: 'pending'
)
end
def assign_zone(task)
zone = ZoneFinder.new(account, params[:address]).find
task.zone = zone
end
def schedule_notifications(task)
TaskNotificationJob.perform_later(task.id)
end
end
end
# Usage:
task = TasksManager::CreateTask.call(
account: current_account,
merchant: merchant,
params: task_params
)
Result Object Pattern
For services that need structured success/failure responses:
# app/services/service_result.rb
class ServiceResult
attr_reader :data, :error, :errors
def initialize(success:, data: nil, error: nil, errors: [])
@success = success
@data = data
@error = error
@errors = errors
end
def success?
@success
end
def failure?
!@success
end
def self.success(data = nil)
new(success: true, data: data)
end
def self.failure(error = nil, errors: [])
new(success: false, error: error, errors: errors)
end
end
# app/services/tasks_manager/assign_carrier.rb
module TasksManager
class AssignCarrier < ApplicationService
def initialize(task:, carrier:)
@task = task
@carrier = carrier
end
def call
return ServiceResult.failure("Task already assigned") if task.carrier.present?
return ServiceResult.failure("Carrier not available") unless carrier_available?
return ServiceResult.failure("Carrier not in zone") unless carrier_in_zone?
ActiveRecord::Base.transaction do
task.update!(carrier: carrier, assigned_at: Time.current)
notify_carrier
notify_recipient
end
ServiceResult.success(task.reload)
rescue ActiveRecord::RecordInvalid => e
ServiceResult.failure(e.message, errors: task.errors.full_messages)
end
private
attr_reader :task, :carrier
def carrier_available?
carrier.active? && carrier.available?
end
def carrier_in_zone?
return true unless task.zone
carrier.zones.include?(task.zone)
end
def notify_carrier
CarrierNotificationJob.perform_later(carrier.id, task.id)
end
def notify_recipient
RecipientNotificationJob.perform_later(task.id, :carrier_assigned)
end
end
end
# Usage in controller:
result = TasksManager::AssignCarrier.call(task: @task, carrier: @carrier)
if result.success?
render json: result.data, status: :ok
else
render json: { error: result.error, errors: result.errors }, status: :unprocessable_entity
end
Dry-Monads Pattern (Alternative)
If using the dry-monads gem:
# Gemfile
gem 'dry-monads'
# app/services/tasks_manager/complete_task.rb
module TasksManager
class CompleteTask
include Dry::Monads[:result, :do]
def initialize(task:, otp:, photos: [])
@task = task
@otp = otp
@photos = photos
end
def call
yield validate_otp
yield validate_photos
yield complete_task
yield process_payment
yield notify_parties
Success(task.reload)
end
private
attr_reader :task, :otp, :photos
def validate_otp
return Failure(:invalid_otp) unless task.otp == otp
Success()
end
def validate_photos
return Failure(:photos_required) if task.requires_photos? && photos.empty?
Success()
end
def complete_task
task.update!(
status: 'completed',
completed_at: Time.current
)
Success()
rescue ActiveRecord::RecordInvalid => e
Failure(e.message)
end
def process_payment
# Payment processing logic
Success()
end
def notify_parties
TaskCompletionNotificationJob.perform_later(task.id)
Success()
end
end
end
# Usage:
result = TasksManager::CompleteTask.new(task: @task, otp: params[:otp]).call
case result
in Success(task)
render json: task
in Failure(:invalid_otp)
render json: { error: "Invalid OTP" }, status: :unprocessable_entity
in Failure(error)
render json: { error: error }, status: :unprocessable_entity
end
Service Composition
For complex operations that coordinate multiple services:
# app/services/tasks_manager/process_delivery.rb
module TasksManager
class ProcessDelivery < ApplicationService
def initialize(task:, carrier:, params:)
@task = task
@carrier = carrier
@params = params
end
def call
ActiveRecord::Base.transaction do
validate_delivery!
complete_task!
process_cod! if task.cod?
generate_invoice!
notify_all_parties!
end
ServiceResult.success(task.reload)
rescue StandardError => e
ServiceResult.failure(e.message)
end
private
attr_reader :task, :carrier, :params
def validate_delivery!
result = DeliveryValidator.call(task: task, params: params)
raise result.error unless result.success?
end
def complete_task!
result = CompleteTask.call(
task: task,
otp: params[:otp],
photos: params[:photos]
)
raise result.error unless result.success?
end
def process_cod!
result = BillingManager::ProcessCod.call(
task: task,
carrier: carrier,
amount: task.cod_amount
)
raise result.error unless result.success?
end
def generate_invoice!
BillingManager::GenerateInvoice.call(task: task)
end
def notify_all_parties!
NotificationsManager::DeliveryComplete.call(task: task)
end
end
end
Service with External API
# app/services/integrations/shipping/create_label.rb
module Integrations
module Shipping
class CreateLabel < ApplicationService
TIMEOUT = 30.seconds
def initialize(task:, shipping_company:)
@task = task
@shipping_company = shipping_company
end
def call
response = make_api_request
if response.success?
label = create_label_record(response.body)
ServiceResult.success(label)
else
handle_error(response)
end
rescue Faraday::TimeoutError
ServiceResult.failure("Shipping API timeout")
rescue Faraday::ConnectionFailed
ServiceResult.failure("Unable to connect to shipping API")
end
private
attr_reader :task, :shipping_company
def make_api_request
client.post('/labels', label_payload)
end
def client
@client ||= Faraday.new(url: shipping_company.api_url) do |f|
f.request :json
f.response :json
f.options.timeout = TIMEOUT
f.headers['Authorization'] = "Bearer #{shipping_company.api_key}"
end
end
def label_payload
{
sender: sender_details,
recipient: recipient_details,
package: package_details
}
end
def create_label_record(response_body)
task.create_shipping_label!(
tracking_number: response_body['tracking_number'],
label_url: response_body['label_url'],
shipping_company: shipping_company
)
end
def handle_error(response)
error_message = response.body['error'] || "API Error: #{response.status}"
Rails.logger.error("Shipping API Error: #{error_message}")
ServiceResult.failure(error_message)
end
end
end
end
Service with Background Jobs
# app/services/tasks_manager/bulk_import.rb
module TasksManager
class BulkImport < ApplicationService
def initialize(account:, file:, user:)
@account = account
@file = file
@user = user
end
def call
import = create_import_record
schedule_processing(import)
ServiceResult.success(import)
end
private
attr_reader :account, :file, :user
def create_import_record
account.task_imports.create!(
file: file,
user: user,
status: 'pending',
total_rows: count_rows
)
end
def schedule_processing(import)
BulkImportJob.perform_later(import.id)
end
def count_rows
# Count rows in uploaded file
CSV.read(file.path).count - 1 # Minus header
end
end
end
Testing Services
# spec/services/tasks_manager/create_task_spec.rb
require 'rails_helper'
RSpec.describe TasksManager::CreateTask do
let(:account) { create(:account) }
let(:merchant) { create(:merchant, account: account) }
let(:recipient) { create(:recipient, account: account) }
let(:valid_params) do
{
recipient_id: recipient.id,
description: "Test delivery",
amount: 100,
address: "123 Test St"
}
end
describe '.call' do
context 'with valid params' do
it 'creates a task' do
expect {
described_class.call(
account: account,
merchant: merchant,
params: valid_params
)
}.to change(Task, :count).by(1)
end
it 'assigns the zone' do
task = described_class.call(
account: account,
merchant: merchant,
params: valid_params
)
expect(task.zone).to be_present
end
it 'schedules notification' do
expect {
described_class.call(
account: account,
merchant: merchant,
params: valid_params
)
}.to have_enqueued_job(TaskNotificationJob)
end
end
context 'with invalid params' do
it 'raises error without recipient' do
invalid_params = valid_params.except(:recipient_id)
expect {
described_class.call(
account: account,
merchant: merchant,
params: invalid_params
)
}.to raise_error(ArgumentError, "Recipient required")
end
end
end
end
Service Interface Guidelines
Method Visibility
class MyService
# PUBLIC: Only .call is public (entry point)
def self.call(...)
new(...).call
end
def call
# Main logic
end
private
# PRIVATE: All other methods are private
attr_reader :params
def validate!
# validation
end
def process
# processing
end
end
Input Validation
def initialize(user:, params:)
@user = user
@params = params
validate_input!
end
private
def validate_input!
raise ArgumentError, "User required" unless @user
raise ArgumentError, "Params required" unless @params
end
Transaction Management
def call
ActiveRecord::Base.transaction do
step_one
step_two
step_three
end
rescue ActiveRecord::RecordInvalid => e
# Handle validation errors
ServiceResult.failure(e.message)
rescue StandardError => e
# Handle other errors
Rails.logger.error("Service error: #{e.message}")
ServiceResult.failure("An error occurred")
end
Comprehensive Error Handling
Custom Error Classes
# app/services/errors.rb
module Services
module Errors
class ServiceError < StandardError
attr_reader :context
def initialize(message = nil, context: {})
@context = context
super(message)
end
end
class ValidationError < ServiceError; end
class AuthorizationError < ServiceError; end
class ExternalServiceError < ServiceError; end
class TimeoutError < ServiceError; end
class RateLimitError < ServiceError; end
class ResourceNotFoundError < ServiceError; end
end
end
# Usage in service
module TasksManager
class CreateTask < ApplicationService
def call
validate_authorization!
validate_params!
task = build_and_save_task
ServiceResult.success(task)
rescue Services::Errors::ValidationError => e
ServiceResult.failure(e.message, errors: e.context[:errors])
rescue Services::Errors::AuthorizationError => e
ServiceResult.failure("Not authorized", context: e.context)
end
private
def validate_authorization!
unless @user.can?(:create_task, @account)
raise Services::Errors::AuthorizationError.new(
"User not authorized to create tasks",
context: { user_id: @user.id, account_id: @account.id }
)
end
end
def validate_params!
errors = []
errors << "Recipient required" unless @params[:recipient_id]
errors << "Address required" unless @params[:address]
if errors.any?
raise Services::Errors::ValidationError.new(
"Validation failed",
context: { errors: errors }
)
end
end
end
end
Error Handler Concern
# app/services/concerns/error_handling.rb
module Services
module Concerns
module ErrorHandling
extend ActiveSupport::Concern
included do
rescue_from StandardError, with: :handle_standard_error
rescue_from ActiveRecord::RecordInvalid, with: :handle_record_invalid
rescue_from Services::Errors::ServiceError, with: :handle_service_error
end
private
def handle_standard_error(exception)
log_error(exception)
track_error(exception)
ServiceResult.failure("An unexpected error occurred")
end
def handle_record_invalid(exception)
log_error(exception)
ServiceResult.failure(
"Validation failed",
errors: exception.record.errors.full_messages
)
end
def handle_service_error(exception)
log_error(exception, context: exception.context)
ServiceResult.failure(exception.message, context: exception.context)
end
def log_error(exception, context: {})
Rails.logger.error({
error_class: exception.class.name,
error_message: exception.message,
backtrace: exception.backtrace.first(5),
context: context,
service: self.class.name
}.to_json)
end
def track_error(exception)
# Integrate with error tracking service (Sentry, Rollbar, etc.)
if defined?(Sentry)
Sentry.capture_exception(exception, extra: {
service: self.class.name,
params: sanitized_params
})
end
end
def sanitized_params
# Remove sensitive data before logging
@params.except(:password, :token, :api_key)
end
end
end
end
# Usage
module TasksManager
class CreateTask < ApplicationService
include Services::Concerns::ErrorHandling
def call
# Implementation
end
end
end
Retry Mechanisms
# app/services/concerns/retriable.rb
module Services
module Concerns
module Retriable
extend ActiveSupport::Concern
RETRYABLE_ERRORS = [
Faraday::TimeoutError,
Faraday::ConnectionFailed,
Services::Errors::TimeoutError,
ActiveRecord::Deadlocked
].freeze
def with_retry(max_attempts: 3, backoff: 2, &block)
attempt = 1
begin
yield
rescue *RETRYABLE_ERRORS => e
if attempt < max_attempts
sleep_duration = backoff**attempt
Rails.logger.warn(
"Retrying after error (attempt #{attempt}/#{max_attempts}): #{e.message}. " \
"Sleeping #{sleep_duration}s"
)
sleep(sleep_duration)
attempt += 1
retry
else
Rails.logger.error("Max retry attempts (#{max_attempts}) exceeded: #{e.message}")
raise
end
end
end
end
end
end
# Usage
module Integrations
module Shipping
class CreateLabel < ApplicationService
include Services::Concerns::Retriable
def call
with_retry(max_attempts: 3, backoff: 2) do
response = make_api_request
process_response(response)
end
rescue Faraday::TimeoutError => e
ServiceResult.failure("Shipping API timeout after retries")
end
end
end
end
Circuit Breaker Pattern
# app/services/concerns/circuit_breaker.rb
module Services
module Concerns
module CircuitBreaker
extend ActiveSupport::Concern
class_methods do
def circuit_breaker(service_name, failure_threshold: 5, timeout: 60)
@circuit_state ||= {}
@circuit_state[service_name] ||= {
failures: 0,
last_failure_time: nil,
state: :closed # :closed, :open, :half_open
}
end
def circuit_open?(service_name)
circuit = @circuit_state[service_name]
return false unless circuit
if circuit[:state] == :open
# Check if timeout period has passed
if Time.current - circuit[:last_failure_time] > circuit[:timeout]
circuit[:state] = :half_open
false
else
true
end
else
false
end
end
def record_success(service_name)
circuit = @circuit_state[service_name]
return unless circuit
circuit[:failures] = 0
circuit[:state] = :closed
end
def record_failure(service_name)
circuit = @circuit_state[service_name]
return unless circuit
circuit[:failures] += 1
circuit[:last_failure_time] = Time.current
if circuit[:failures] >= circuit[:failure_threshold]
circuit[:state] = :open
Rails.logger.warn("Circuit breaker opened for #{service_name}")
end
end
end
def with_circuit_breaker(service_name, &block)
if self.class.circuit_open?(service_name)
raise Services::Errors::ExternalServiceError.new(
"Circuit breaker open for #{service_name}",
context: { service: service_name }
)
end
result = yield
self.class.record_success(service_name)
result
rescue StandardError => e
self.class.record_failure(service_name)
raise
end
end
end
end
# Usage
module Integrations
module Shipping
class CreateLabel < ApplicationService
include Services::Concerns::CircuitBreaker
circuit_breaker :shipping_api, failure_threshold: 5, timeout: 60
def call
with_circuit_breaker(:shipping_api) do
response = make_api_request
process_response(response)
end
rescue Services::Errors::ExternalServiceError => e
ServiceResult.failure(e.message)
end
end
end
end
Instrumentation & Monitoring
Performance Instrumentation
# app/services/concerns/instrumentation.rb
module Services
module Concerns
module Instrumentation
extend ActiveSupport::Concern
included do
around_action :instrument_service_call, only: :call
end
private
def instrument_service_call
start_time = Time.current
service_name = self.class.name.underscore.tr('/', '.')
ActiveSupport::Notifications.instrument(
"service.call",
service: service_name,
params: sanitized_params
) do
result = yield
duration = (Time.current - start_time) * 1000 # Convert to ms
log_performance(service_name, duration, result)
track_metrics(service_name, duration, result)
result
end
end
def log_performance(service_name, duration, result)
Rails.logger.info({
service: service_name,
duration_ms: duration.round(2),
success: result.success?,
timestamp: Time.current.iso8601
}.to_json)
end
def track_metrics(service_name, duration, result)
# StatsD integration
if defined?(StatsD)
StatsD.increment("service.calls", tags: [
"service:#{service_name}",
"status:#{result.success? ? 'success' : 'failure'}"
])
StatsD.timing("service.duration", duration, tags: [
"service:#{service_name}"
])
end
# Prometheus integration
if defined?(PrometheusExporter)
PrometheusExporter::Client.default.send_json(
type: "service_call",
service: service_name,
duration: duration,
success: result.success?
)
end
end
end
end
end
Structured Logging
# app/services/concerns/loggable.rb
module Services
module Concerns
module Loggable
extend ActiveSupport::Concern
private
def log_info(message, context = {})
log(:info, message, context)
end
def log_warn(message, context = {})
log(:warn, message, context)
end
def log_error(message, context = {})
log(:error, message, context)
end
def log_debug(message, context = {})
log(:debug, message, context)
end
def log(level, message, context = {})
Rails.logger.public_send(level, {
service: self.class.name,
message: message,
timestamp: Time.current.iso8601,
**context
}.to_json)
end
def log_service_start(context = {})
log_info("Service started", {
params: sanitized_params,
**context
})
end
def log_service_complete(result, context = {})
log_info("Service completed", {
success: result.success?,
duration_ms: context[:duration_ms],
**context
})
end
def log_external_api_call(api_name, endpoint, context = {})
log_info("External API call", {
api: api_name,
endpoint: endpoint,
**context
})
end
end
end
end
# Usage
module Integrations
module Shipping
class CreateLabel < ApplicationService
include Services::Concerns::Loggable
def call
log_service_start(task_id: @task.id)
start_time = Time.current
log_external_api_call("Shipping API", "/labels", {
task_id: @task.id,
shipping_company: @shipping_company.name
})
response = make_api_request
result = process_response(response)
duration = (Time.current - start_time) * 1000
log_service_complete(result, duration_ms: duration)
result
end
end
end
end
Metrics Collection
# app/services/concerns/metrics.rb
module Services
module Concerns
module Metrics
extend ActiveSupport::Concern
def track_counter(metric_name, value = 1, tags: {})
if defined?(StatsD)
StatsD.increment(
"service.#{metric_name}",
value,
tags: format_tags(tags)
)
end
end
def track_gauge(metric_name, value, tags: {})
if defined?(StatsD)
StatsD.gauge(
"service.#{metric_name}",
value,
tags: format_tags(tags)
)
end
end
def track_timing(metric_name, duration_ms, tags: {})
if defined?(StatsD)
StatsD.timing(
"service.#{metric_name}",
duration_ms,
tags: format_tags(tags)
)
end
end
def track_histogram(metric_name, value, tags: {})
if defined?(StatsD)
StatsD.histogram(
"service.#{metric_name}",
value,
tags: format_tags(tags)
)
end
end
private
def format_tags(tags)
default_tags.merge(tags).map { |k, v| "#{k}:#{v}" }
end
def default_tags
{
service: self.class.name.underscore.tr('/', '.'),
environment: Rails.env
}
end
end
end
end
# Usage
module TasksManager
class CreateTask < ApplicationService
include Services::Concerns::Metrics
def call
start_time = Time.current
task = build_and_save_task
track_counter("task.created", tags: { merchant_id: @merchant.id })
duration = (Time.current - start_time) * 1000
track_timing("task.creation_time", duration)
ServiceResult.success(task)
end
end
end
ActiveSupport Notifications
# app/services/tasks_manager/create_task.rb
module TasksManager
class CreateTask < ApplicationService
def call
ActiveSupport::Notifications.instrument(
"task.create",
account_id: @account.id,
merchant_id: @merchant.id
) do |payload|
task = build_and_save_task
payload[:task_id] = task.id
payload[:zone_id] = task.zone_id
ServiceResult.success(task)
end
end
end
end
# config/initializers/service_notifications.rb
ActiveSupport::Notifications.subscribe("task.create") do |name, start, finish, id, payload|
duration = (finish - start) * 1000
Rails.logger.info({
event: name,
duration_ms: duration.round(2),
account_id: payload[:account_id],
task_id: payload[:task_id]
}.to_json)
# Send metrics
StatsD.increment("task.created", tags: [
"account:#{payload[:account_id]}",
"merchant:#{payload[:merchant_id]}"
]) if defined?(StatsD)
StatsD.timing("task.creation_duration", duration, tags: [
"account:#{payload[:account_id]}"
]) if defined?(StatsD)
end
Error Tracking Integration
# app/services/concerns/error_tracking.rb
module Services
module Concerns
module ErrorTracking
extend ActiveSupport::Concern
private
def capture_exception(exception, context: {})
# Sentry integration
if defined?(Sentry)
Sentry.capture_exception(exception, extra: {
service: self.class.name,
context: context,
params: sanitized_params
})
end
# Rollbar integration
if defined?(Rollbar)
Rollbar.error(exception, {
service: self.class.name,
context: context,
params: sanitized_params
})
end
# Airbrake integration
if defined?(Airbrake)
Airbrake.notify(exception, {
service: self.class.name,
context: context,
params: sanitized_params
})
end
end
def capture_message(message, level: :info, context: {})
if defined?(Sentry)
Sentry.capture_message(message, level: level, extra: {
service: self.class.name,
context: context
})
end
end
end
end
end
# Usage
module TasksManager
class CreateTask < ApplicationService
include Services::Concerns::ErrorTracking
def call
task = build_and_save_task
ServiceResult.success(task)
rescue StandardError => e
capture_exception(e, context: {
account_id: @account.id,
merchant_id: @merchant.id,
params: @params
})
ServiceResult.failure("Failed to create task")
end
end
end
Comprehensive Service Template
# app/services/tasks_manager/create_task.rb
module TasksManager
class CreateTask < ApplicationService
include Services::Concerns::ErrorHandling
include Services::Concerns::Retriable
include Services::Concerns::Instrumentation
include Services::Concerns::Loggable
include Services::Concerns::Metrics
include Services::Concerns::ErrorTracking
def initialize(account:, merchant:, params:, user:)
@account = account
@merchant = merchant
@params = params
@user = user
end
def call
log_service_start(account_id: @account.id, merchant_id: @merchant.id)
start_time = Time.current
validate_authorization!
validate_params!
task = with_retry(max_attempts: 3) do
build_and_save_task
end
track_counter("task.created", tags: { merchant_id: @merchant.id })
duration = (Time.current - start_time) * 1000
track_timing("task.creation_time", duration)
log_service_complete(ServiceResult.success(task), duration_ms: duration)
ServiceResult.success(task)
rescue Services::Errors::ServiceError => e
capture_exception(e, context: { account_id: @account.id })
ServiceResult.failure(e.message, context: e.context)
rescue StandardError => e
log_error("Unexpected error", error: e.message, backtrace: e.backtrace.first(5))
capture_exception(e, context: { account_id: @account.id })
ServiceResult.failure("An unexpected error occurred")
end
private
attr_reader :account, :merchant, :params, :user
def validate_authorization!
unless user.can?(:create_task, account)
raise Services::Errors::AuthorizationError.new(
"User not authorized",
context: { user_id: user.id, account_id: account.id }
)
end
end
def validate_params!
errors = []
errors << "Recipient required" unless params[:recipient_id]
errors << "Address required" unless params[:address]
if errors.any?
raise Services::Errors::ValidationError.new(
"Validation failed",
context: { errors: errors }
)
end
end
def build_and_save_task
ActiveRecord::Base.transaction do
task = account.tasks.build(
merchant: merchant,
recipient_id: params[:recipient_id],
description: params[:description],
amount: params[:amount],
status: 'pending'
)
assign_zone(task)
task.save!
log_info("Task created", task_id: task.id)
schedule_notifications(task)
task
end
end
def assign_zone(task)
zone = ZoneFinder.new(account, params[:address]).find
task.zone = zone
log_debug("Zone assigned", zone_id: zone&.id)
end
def schedule_notifications(task)
TaskNotificationJob.perform_later(task.id)
log_debug("Notifications scheduled", task_id: task.id)
end
end
end
Pre-Creation Checklist
Before creating a service:
# 1. Check existing service structure
ls app/services/
ls app/services/*/ 2>/dev/null
# 2. Review existing service patterns
head -50 $(find app/services -name '*.rb' | head -1)
# 3. Check naming conventions
grep -r 'class.*Manager' app/services/ --include='*.rb' | head -10
# 4. Verify namespace exists
ls app/services/{namespace}/ 2>/dev/null
# 5. Check for existing concerns
ls app/services/concerns/ 2>/dev/null
# 6. Review error handling patterns
grep -r 'ServiceError' app/services/ --include='*.rb' | head -5