Handling Error
in Ruby / Rails
The Problem
Handling errors and providing feedback is crucial
But it often leads to
complex code
Does this ring a bell?
def send_money
amount = params[:amount]
receiver = User.find_by(id: params[:id])
if receiver
if current_user.account.withdraw(amount)
if receiver.account.credit(account)
render json: { message: 'transaction complete' }
else
render_errors_from receiver.account
end
else
render_errors_from user.account
end
else
render_errors "Receiver has not been found"
end
end
def render_errors_from(model)
render_errors model.errors.full_messages
end
def render_errors(errors)
render json: { errors: Array(errors) }, status: 422
end
Current Issues
- terrible to read
- terrible to change
- no database transaction
- controller knows too much about business logic
First refactor
Leave the controller alone:
Use a Service Object
=>
- extract business logic
- composable
- easy to test
class ExchangeMoney < Struct.new(:payer, :receiver, :amount)
attr_reader :failures
def call
if receiver
if payer.account.withdraw(amount)
if receiver.account.credit(account)
return true
else
add_failures receiver.account.full_messages
end
else
add_failures payer.account.full_messages
end
else
add_failures "Receiver has not been found"
end
end
def add_failures(array_or_string)
@failures = Array(array_or_string)
false
end
end
Resulting Service
def send_money
receiver = User.find_by(id: params[:id])
service = ExchangeMoney.new(current_user, receiver, params[:amount])
if service.call
render json: { message: 'transaction complete' }
else
render_errors service.failures
end
end
def render_errors(errors)
render json: { errors: Array(errors) }, status: 422
end
Resulting Controller
Second refactor
Use a database transaction
=> guarantee data integrity
class ExchangeMoney < Struct.new(:payer, :receiver, :amount)
attr_reader :failures
class MyRollBackErrorClass < StandardError; end
def call
ActiveRecord::Base.transaction do
add_failures("Receiver has not been found") unless receiver
if payer.account.withdraw(amount)
if receiver.account.credit(account)
true
else
add_failures receiver.account.full_messages
end
else
add_failures payer.account.full_messages
end
end
rescue MyRollBackErrorClass
return false
end
def add_failures(array_or_string)
@failures = Array(array_or_string)
raise MyRollBackErrorClass
end
end
Resulting Service
def send_money
receiver = User.find_by(id: params[:id])
service = ExchangeMoney.new(current_user, receiver, params[:amount])
if service.call
render json: { message: 'transaction complete' }
else
render_errors service.failures
end
end
def render_errors(errors)
render json: { errors: Array(errors) }, status: 422
end
Resulting Controller
Third refactor
Introduce ActiveModel
=> add validations for the arguments
Example: we could check the amount is a positive number
class ExchangeMoney < Struct.new(:payer, :receiver, :amount)
include ActiveModel::Validations
class MyRollBackErrorClass < StandardError; end
attr_reader :failures
validates :receiver, presence: true
def call
ActiveRecord::Base.transaction do
add_failures(self) unless valid?
if payer.account.withdraw(amount)
if receiver.account.credit(account)
true
else
add_failures receiver.account
end
else
add_failures payer.account
end
end
rescue MyRollBackErrorClass
return false
end
def add_failures(object)
@failures = object
raise MyRollBackErrorClass
end
end
Resulting Service
def send_money
receiver = User.find_by(id: params[:id])
service = ExchangeMoney.new(current_user, receiver, params[:amount])
if service.call
render json: { message: 'transaction complete' }
else
render_errors service.failures
end
end
def render_errors(errors)
render json: { errors: errors.full_messages }, status: 422
end
Resulting Controller
Fourth refactor
Because I need to have human friendly
errors go up the stack, I've created Waterfall
=>
explicit api: code flows unless dammed
discover chaining
leverage functional programming
class ExchangeMoney < Struct.new(:payer, :receiver, :amount)
include Waterfall
include ActiveModel::Validations
class MyRollBackErrorClass < StandardError; end
validates :receiver, presence: true
def call
ActiveRecord::Base.transaction do
self
.when_falsy { valid? }
.dam { errors }
.when_falsy { payer.account.withdraw(amount) }
.dam { payer.account.errors }
.when_falsy { receiver.account.credit(account) }
.dam { payer.account.errors }
.on_dam { raise MyRollBackErrorClass }
end
rescue MyRollBackErrorClass
# no need to return anything here
end
end
Resulting Service
def send_money
receiver = User.find_by(id: params[:id])
Wf.new
.chain { ExchangeMoney.new(current_user, receiver, params[:amount]) }
.chain { render json: { message: 'transaction complete' } }
.on_dam {|errors| render_errors errors }
end
def render_errors(errors)
render json: { errors: errors.full_messages }, status: 422
end
Resulting Controller
Wrapping up
Service Objects
- Business logic isolated and reusable
- Short controller
- Clean, maintainable, self explanatory classes
(ex: ExchangeMoney)
Waterfall (github)
- Provides chaining api: code flows unless dammed
- on dam, only on_dam blocks are executed
- lets you have custom errors go up the stack