rails-service-object
SKILL.md
Rails Service Object Pattern
Overview
Service objects encapsulate business logic:
- Single responsibility (one public method:
#call) - Easy to test in isolation
- Reusable across controllers, jobs, rake tasks
- Clear input/output contract
- Dependency injection for testability
When to Use Service Objects
| Scenario | Use Service Object? |
|---|---|
| Complex business logic spanning multiple models | Yes |
| Multiple model interactions in one operation | Yes |
| External API calls | Yes |
| Logic shared across controllers/jobs | Yes |
| Operations with side effects (emails, webhooks) | Yes |
| Simple CRUD operations | No (use model) |
| Single model validation | No (use model) |
| Simple query/filter | No (use scope or query object) |
| View formatting | No (use presenter) |
| Form handling with validations | No (use form object) |
When NOT to Use Service Objects
Don't create a service object when:
- A model callback does the job (e.g.,
after_create :send_welcome_email) - The logic is a single ActiveRecord operation
- A concern would share the behavior more naturally
- You're wrapping a single method call (adds indirection for no benefit)
- The "service" just delegates to one model method
Rule of thumb: If your service object's #call method is under 5 lines and calls one model method, you don't need it.
Workflow Checklist
Service Object Progress:
- [ ] Step 1: Define input/output contract
- [ ] Step 2: Create service test (RED)
- [ ] Step 3: Run test (fails - no service)
- [ ] Step 4: Create service file with empty #call
- [ ] Step 5: Run test (fails - wrong return)
- [ ] Step 6: Implement #call method
- [ ] Step 7: Run test (GREEN)
- [ ] Step 8: Add error case tests
- [ ] Step 9: Implement error handling
- [ ] Step 10: Final test run
Step 1: Define Contract
## Service: Orders::CreateService
### Purpose
Creates a new order with inventory validation and payment processing.
### Input
- user: User (required)
- items: Array<Hash> (required) - [{product_id:, quantity:}]
- payment_method_id: Integer (optional)
### Output (Result object)
Success: { success?: true, data: Order }
Failure: { success?: false, error: String, code: Symbol }
### Dependencies
- inventory_service: Checks product availability
- payment_gateway: Processes payment
### Side Effects
- Creates Order and OrderItem records
- Decrements inventory
- Charges payment method
- Sends confirmation email (async)
Step 2: Service Test
Location: test/services/orders/create_service_test.rb
# frozen_string_literal: true
require "test_helper"
class Orders::CreateServiceTest < ActiveSupport::TestCase
setup do
@user = users(:one)
@product = products(:available)
@items = [{ product_id: @product.id, quantity: 2 }]
@service = Orders::CreateService.new
end
test "#call with valid inputs returns success" do
result = @service.call(user: @user, items: @items)
assert result.success?
assert_instance_of Order, result.data
assert_equal @user, result.data.user
end
test "#call with valid inputs creates an order" do
assert_difference("Order.count", 1) do
@service.call(user: @user, items: @items)
end
end
test "#call with empty items returns failure" do
result = @service.call(user: @user, items: [])
assert result.failure?
assert_equal "No items provided", result.error
end
test "#call with insufficient inventory returns failure" do
items = [{ product_id: @product.id, quantity: 999_999 }]
result = @service.call(user: @user, items: items)
assert result.failure?
end
test "#call with insufficient inventory does not create order" do
items = [{ product_id: @product.id, quantity: 999_999 }]
assert_no_difference("Order.count") do
@service.call(user: @user, items: items)
end
end
end
Step 3-6: Implement Service
Location: app/services/orders/create_service.rb
# frozen_string_literal: true
module Orders
class CreateService
def initialize(inventory_service: InventoryService.new,
payment_gateway: PaymentGateway.new)
@inventory_service = inventory_service
@payment_gateway = payment_gateway
end
def call(user:, items:, payment_method_id: nil)
return failure("No items provided", :empty_items) if items.empty?
return failure("Insufficient inventory", :insufficient_inventory) unless inventory_available?(items)
order = create_order(user, items)
process_payment(order, payment_method_id) if payment_method_id
success(order)
rescue ActiveRecord::RecordInvalid => e
failure(e.message, :validation_failed)
end
private
attr_reader :inventory_service, :payment_gateway
def inventory_available?(items)
items.all? do |item|
inventory_service.available?(item[:product_id], item[:quantity])
end
end
def create_order(user, items)
ActiveRecord::Base.transaction do
order = Order.create!(user: user, status: :pending)
items.each do |item|
order.order_items.create!(
product_id: item[:product_id],
quantity: item[:quantity]
)
inventory_service.decrement(item[:product_id], item[:quantity])
end
order
end
end
def process_payment(order, payment_method_id)
payment_gateway.charge(
amount: order.total,
payment_method_id: payment_method_id
)
order.update!(status: :paid)
end
def success(data)
Result.new(success: true, data: data)
end
def failure(error, code = :unknown)
Result.new(success: false, error: error, code: code)
end
end
end
Result Object
# app/services/result.rb
# frozen_string_literal: true
class Result
attr_reader :data, :error, :code
def initialize(success:, data: nil, error: nil, code: nil)
@success = success
@data = data
@error = error
@code = code
end
def success?
@success
end
def failure?
!@success
end
def deconstruct_keys(keys)
{ success: @success, data: @data, error: @error, code: @code }
end
end
Testing with Mocked Dependencies
class Orders::CreateServiceTest < ActiveSupport::TestCase
setup do
@inventory_service = Minitest::Mock.new
@payment_gateway = Minitest::Mock.new
@service = Orders::CreateService.new(
inventory_service: @inventory_service,
payment_gateway: @payment_gateway
)
end
test "calls inventory service to check availability" do
@inventory_service.expect(:available?, true, [Integer, Integer])
@inventory_service.expect(:decrement, true, [Integer, Integer])
@service.call(user: users(:one), items: [{ product_id: 1, quantity: 2 }])
@inventory_service.verify
end
end
Calling Services
From Controllers
class OrdersController < ApplicationController
def create
result = Orders::CreateService.new.call(
user: current_user,
items: order_params[:items],
payment_method_id: order_params[:payment_method_id]
)
if result.success?
redirect_to result.data, notice: "Order created"
else
flash.now[:alert] = result.error
render :new, status: :unprocessable_entity
end
end
end
From Jobs
class ProcessOrderJob < ApplicationJob
def perform(user_id, items)
user = User.find(user_id)
result = Orders::CreateService.new.call(user: user, items: items)
unless result.success?
Rails.logger.error("Order failed: #{result.error}")
end
end
end
Directory Structure
app/services/
result.rb
orders/
create_service.rb
cancel_service.rb
users/
register_service.rb
payments/
charge_service.rb
Conventions
- Naming:
Namespace::VerbNounService(e.g.,Orders::CreateService) - Location:
app/services/[namespace]/[name]_service.rb - Interface: Single public method
#call - Return: Always return Result object
- Dependencies: Inject via constructor
- Errors: Catch and wrap in Result, don't raise
Anti-Patterns to Avoid
- God service: Too many responsibilities - split it
- Hidden dependencies: Using globals instead of injection
- No return contract: Returning different types
- Raising exceptions: Use Result objects instead
- Service wrapping one method: Just call the method directly
- Service with multiple public methods: Use separate services
Checklist
- Contract defined (input/output/side effects)
- Test written first (RED)
- Single public method
#call - Returns Result object consistently
- Dependencies injected via constructor
- Error cases tested
- Transaction wraps multi-model operations
- All tests GREEN
Weekly Installs
2
Repository
dchuk/rails_ai_agentsFirst Seen
7 days ago
Security Audits
Installed on
opencode2
gemini-cli2
antigravity2
claude-code2
windsurf2
codex2