Common Patterns
Common architectural patterns for using Servus effectively.
Parent-Child Services
When one service orchestrates multiple sub-operations, decide on transaction boundaries and error propagation.
class Orders::Checkout::Service < Servus::Base
def call
ActiveRecord::Base.transaction do
# Create order
order_result = Orders::Create::Service.call(order_params)
return order_result unless order_result.success?
# Charge payment
payment_result = Payments::Charge::Service.call(
user_id: @user_id,
amount: order_result.data[:order].total
)
return payment_result unless payment_result.success?
# Update inventory
inventory_result = Inventory::Reserve::Service.call(
order_id: order_result.data[:order].id
)
return inventory_result unless inventory_result.success?
success(order: order_result.data[:order])
end
end
end
Use parent transaction when: All children must succeed or all roll back (atomic operation)
Use child transactions when: Children can succeed independently (partial success acceptable)
Async with Result Persistence
Store async results in database for later retrieval:
# Controller creates placeholder
report = Report.create!(user_id: user.id, status: 'pending')
GenerateReport::Service.call_async(report_id: report.id)
# Service updates record
class GenerateReport::Service < Servus::Base
def call
report = Report.find(@report_id)
data = generate_report_data
report.update!(data: data, status: 'completed')
success(report: report)
end
end
Idempotent Services
Use database constraints to make services idempotent:
class Users::Create::Service < Servus::Base
def call
# Unique constraint on email prevents duplicates
user = User.create!(email: @email, name: @name)
success(user: user)
rescue ActiveRecord::RecordNotUnique
user = User.find_by!(email: @email)
success(user: user) # Return existing user, not error
end
end
Or check for existing resources explicitly:
def call
existing = User.find_by(email: @email)
return success(user: existing) if existing
user = User.create!(email: @email, name: @name)
success(user: user)
end