← Back to list

service-object
by GangWooLee
⭐ 0🍴 0📅 Jan 25, 2026
SKILL.md
name: service-object description: Generate service objects for complex business logic. Use when controller/model is too fat, multiple models interaction, external API calls, or says "extract logic", "create service", "business logic", "refactor controller", "complex operation".
Service Object Generator
Extract complex business logic into service objects for better organization and testability.
Quick Start
Task Progress (copy and check off):
- [ ] 1. Identify complex logic to extract
- [ ] 2. Create service class
- [ ] 3. Move business logic to service
- [ ] 4. Add error handling
- [ ] 5. Call from controller/model
- [ ] 6. Add tests
When to Use Service Objects
✅ Good candidates:
- Complex multi-step operations
- Multiple model interactions
- External API integration
- Business logic with many conditionals
- Operations requiring transactions
- Reusable business processes
❌ Don't use for:
- Simple CRUD operations
- Single model validations
- View helpers
Service Structure
app/services/
├── application_service.rb # Base class
├── users/
│ ├── create_user_service.rb
│ ├── update_profile_service.rb
│ └── delete_account_service.rb
├── posts/
│ ├── publish_post_service.rb
│ └── archive_post_service.rb
└── payments/
├── process_payment_service.rb
└── refund_service.rb
Base Service Class
# app/services/application_service.rb
class ApplicationService
def self.call(*args, **kwargs, &block)
new(*args, **kwargs).call(&block)
end
private
attr_reader :errors
def initialize
@errors = []
end
def success?
errors.empty?
end
def failure?
!success?
end
def add_error(message)
@errors << message
end
end
Basic Service Template
# app/services/example_service.rb
class ExampleService < ApplicationService
def initialize(param1, param2)
super()
@param1 = param1
@param2 = param2
end
def call
validate_inputs
return self if failure?
perform_operation
self
end
private
attr_reader :param1, :param2
def validate_inputs
add_error("Param1 is required") if param1.blank?
add_error("Param2 must be positive") if param2 <= 0
end
def perform_operation
# Business logic here
end
end
Usage:
# In controller
service = ExampleService.call(value1, value2)
if service.success?
redirect_to root_path, notice: "Success"
else
flash[:alert] = service.errors.join(", ")
render :new
end
Common Patterns
1. User Registration Service
# app/services/users/register_user_service.rb
module Users
class RegisterUserService < ApplicationService
def initialize(user_params, ip_address)
super()
@user_params = user_params
@ip_address = ip_address
@user = nil
end
def call
ActiveRecord::Base.transaction do
create_user
return self if failure?
setup_defaults
send_welcome_email
log_registration
end
self
rescue StandardError => e
add_error("Registration failed: #{e.message}")
self
end
attr_reader :user
private
def create_user
@user = User.new(@user_params)
unless @user.save
@user.errors.full_messages.each { |msg| add_error(msg) }
end
end
def setup_defaults
@user.create_profile!
@user.update!(last_login_ip: @ip_address)
end
def send_welcome_email
UserMailer.welcome_email(@user).deliver_later
end
def log_registration
Rails.logger.info "New user registered: #{@user.id} from #{@ip_address}"
end
end
end
Controller:
class RegistrationsController < ApplicationController
def create
service = Users::RegisterUserService.call(
user_params,
request.remote_ip
)
if service.success?
session[:user_id] = service.user.id
redirect_to root_path, notice: "Welcome!"
else
flash.now[:alert] = service.errors.join(", ")
render :new
end
end
end
2. Post Publishing Service
# app/services/posts/publish_post_service.rb
module Posts
class PublishPostService < ApplicationService
def initialize(post, user)
super()
@post = post
@user = user
end
def call
check_permissions
return self if failure?
validate_post_content
return self if failure?
publish_post
notify_followers
update_user_stats
self
end
private
def check_permissions
unless @post.user == @user
add_error("You don't have permission to publish this post")
end
end
def validate_post_content
add_error("Title is required") if @post.title.blank?
add_error("Content is too short") if @post.content.length < 10
end
def publish_post
@post.update!(
status: :published,
published_at: Time.current
)
end
def notify_followers
@user.followers.find_each do |follower|
Notification.create!(
user: follower,
notifiable: @post,
content: "#{@user.name} published a new post"
)
end
end
def update_user_stats
@user.increment!(:posts_count)
end
end
end
3. Payment Processing Service
# app/services/payments/process_payment_service.rb
module Payments
class ProcessPaymentService < ApplicationService
def initialize(order, payment_params)
super()
@order = order
@payment_params = payment_params
@payment = nil
end
def call
validate_order
return self if failure?
ActiveRecord::Base.transaction do
create_payment_record
charge_payment_gateway
return self if failure?
fulfill_order
send_receipt
end
self
rescue StandardError => e
add_error("Payment processing failed: #{e.message}")
@payment&.update(status: :failed)
self
end
attr_reader :payment
private
def validate_order
add_error("Order already paid") if @order.paid?
add_error("Order amount is invalid") if @order.amount <= 0
end
def create_payment_record
@payment = @order.payments.create!(
amount: @order.amount,
payment_method: @payment_params[:method],
status: :pending
)
end
def charge_payment_gateway
# External API call
result = StripeService.charge(
amount: @order.amount,
token: @payment_params[:token]
)
if result.success?
@payment.update!(
status: :completed,
transaction_id: result.transaction_id
)
else
add_error(result.error_message)
@payment.update!(status: :failed)
end
end
def fulfill_order
@order.update!(
status: :paid,
paid_at: Time.current
)
end
def send_receipt
OrderMailer.receipt_email(@order).deliver_later
end
end
end
4. Data Import Service
# app/services/imports/import_users_service.rb
module Imports
class ImportUsersService < ApplicationService
def initialize(file)
super()
@file = file
@success_count = 0
@failed_count = 0
@failed_rows = []
end
def call
validate_file
return self if failure?
process_file
log_results
self
end
attr_reader :success_count, :failed_count, :failed_rows
private
def validate_file
add_error("File is required") unless @file.present?
add_error("Invalid file format") unless valid_csv?
end
def valid_csv?
@file.path.end_with?(".csv")
end
def process_file
CSV.foreach(@file.path, headers: true).with_index(1) do |row, line_number|
process_row(row, line_number)
end
end
def process_row(row, line_number)
User.create!(
email: row["email"],
name: row["name"],
role_title: row["role"]
)
@success_count += 1
rescue StandardError => e
@failed_count += 1
@failed_rows << { line: line_number, error: e.message }
end
def log_results
Rails.logger.info "Import completed: #{@success_count} success, #{@failed_count} failed"
end
end
end
Result Object Pattern
# app/services/result.rb
class ServiceResult
attr_reader :success, :data, :errors
def initialize(success:, data: nil, errors: [])
@success = success
@data = data
@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(errors)
new(success: false, errors: Array(errors))
end
end
Service with Result:
class ModernService
def self.call(*args)
new(*args).call
end
def call
# Logic here
if condition
ServiceResult.success(data: @result)
else
ServiceResult.failure("Error message")
end
end
end
Usage:
result = ModernService.call(params)
if result.success?
@data = result.data
render :success
else
@errors = result.errors
render :error
end
Testing
# test/services/users/register_user_service_test.rb
require "test_helper"
module Users
class RegisterUserServiceTest < ActiveSupport::TestCase
test "successfully registers user with valid params" do
params = {
email: "test@example.com",
name: "Test User",
password: "password"
}
service = RegisterUserService.call(params, "127.0.0.1")
assert service.success?
assert_not_nil service.user
assert_equal "test@example.com", service.user.email
end
test "fails with invalid email" do
params = {
email: "invalid",
name: "Test",
password: "password"
}
service = RegisterUserService.call(params, "127.0.0.1")
assert service.failure?
assert_includes service.errors.join, "Email"
end
test "sends welcome email" do
params = {
email: "test@example.com",
name: "Test",
password: "password"
}
assert_enqueued_with(job: ActionMailer::MailDeliveryJob) do
RegisterUserService.call(params, "127.0.0.1")
end
end
test "rolls back on error" do
# Stub to raise error
UserMailer.stub :welcome_email, -> { raise "Email error" } do
assert_no_difference "User.count" do
RegisterUserService.call(valid_params, "127.0.0.1")
end
end
end
end
end
Best Practices
- Single Responsibility: One service = one business operation
- Immutable Inputs: Don't modify input parameters
- Return Self: Always return self or result object
- Error Collection: Collect all errors, don't raise
- Transaction Safety: Use transactions for multi-model operations
- Logging: Log important business events
- Testing: Test happy path and all error scenarios
Integration Examples
From Controller
class PostsController < ApplicationController
def publish
@post = Post.find(params[:id])
service = Posts::PublishPostService.call(@post, current_user)
if service.success?
redirect_to @post, notice: "Post published successfully"
else
redirect_to @post, alert: service.errors.join(", ")
end
end
end
From Model Callback
class Order < ApplicationRecord
after_commit :process_payment, on: :create
private
def process_payment
Payments::ProcessPaymentService.call(self, payment_info)
end
end
From Background Job
class ProcessOrderJob < ApplicationJob
def perform(order_id)
order = Order.find(order_id)
service = Orders::ProcessOrderService.call(order)
unless service.success?
Rails.logger.error "Order processing failed: #{service.errors.join(', ')}"
end
end
end
Checklist
- Service class created in app/services/
- Inherits from ApplicationService
- Single responsibility
- Returns self or result object
- Error handling implemented
- Transactions for multi-model ops
- Tested thoroughly
- Logged important events
- Called from appropriate location
Score
Total Score
50/100
Based on repository quality metrics
✓SKILL.md
SKILL.mdファイルが含まれている
+20
○LICENSE
ライセンスが設定されている
0/10
○説明文
100文字以上の説明がある
0/10
○人気
GitHub Stars 100以上
0/15
○最近の活動
3ヶ月以内に更新がある
0/10
○フォーク
10回以上フォークされている
0/5
✓Issue管理
オープンIssueが50未満
+5
✓言語
プログラミング言語が設定されている
+5
○タグ
1つ以上のタグが設定されている
0/5
Reviews
💬
Reviews coming soon