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

Handling error in Ruby on Rails

By Benjamin Roth

Handling error in Ruby on Rails

  • 6,106