I'll Be Backend
Principles · Ruby

Error handling: replace error codes with exceptions

Let failures reach the code that can handle them, without every caller checking.

11 steps4 chapters~5 min at 1.25×#error handling#exceptions#error codes#ruby exceptions
Language
The lesson plays right here. Pause anywhere, step back, or jump to a step below. Watch the Ruby video on YouTube Code on GitHub

Watch the narrated video

Every step

Problem

Steps 1–4

Refactor

Steps 5–7

Payoff

Steps 8–9

Recap

Steps 10–11

The code: before and after

Before

class Wallet
  OK, NO_ACCOUNT, LOW_BALANCE = 0, -1, -2

  def initialize
    @balances = {}
  end

  def withdraw(account_id, amount)
    balance = @balances[account_id]
    return NO_ACCOUNT if balance.nil?
    return LOW_BALANCE if balance < amount

    @balances[account_id] = balance - amount
    OK
  end

  def transfer(sender, receiver, amount)
    return NO_ACCOUNT unless @balances.key?(receiver)

    code = withdraw(sender, amount)
    return code unless code == OK

    @balances[receiver] += amount
    OK
  end
end

class TransferHandler
  def handle(wallet, sender, receiver, amount)
    code = wallet.transfer(sender, receiver, amount)
    case code
    when Wallet::NO_ACCOUNT then "404 no such account"
    when Wallet::LOW_BALANCE then "409 balance too low"
    else "200 ok"
    end
  end
end

After

class AccountNotFoundError < StandardError; end
class InsufficientFundsError < StandardError; end
class AccountFrozenError < StandardError; end

class Wallet
  def initialize
    @balances = {}
    @frozen = Set.new
  end

  def freeze_account(account_id)
    @frozen << account_id
  end

  def withdraw(account_id, amount)
    balance = @balances[account_id]
    raise AccountNotFoundError, account_id if balance.nil?
    raise AccountFrozenError, account_id if @frozen.include?(account_id)
    raise InsufficientFundsError, account_id if balance < amount

    @balances[account_id] = balance - amount
  end

  def transfer(sender, receiver, amount)
    raise AccountNotFoundError, receiver unless @balances.key?(receiver)

    withdraw(sender, amount)
    @balances[receiver] += amount
  end
end

class TransferHandler
  def handle(wallet, sender, receiver, amount)
    wallet.transfer(sender, receiver, amount)
    "200 ok"
  rescue AccountNotFoundError
    "404 no such account"
  rescue InsufficientFundsError
    "409 balance too low"
  rescue AccountFrozenError
    "423 account frozen"
  end
end

Transcript, step by step

Problem

Step 1: A wallet that returns numbers Before

This wallet keeps balances in whole euros, and it reports trouble with three numbers, OK, no account and low balance. Withdraw returns minus one for an unknown account, minus two when the balance is too low, and zero once the money has left. Transfer checks the receiver, calls withdraw, and passes any error code it gets straight back up. Transfer Handler is the one caller, and its case turns each code into the HTTP-style status text the handler returns, so a missing account becomes four oh four.

Step 2: Freeze accounts Code smell

Compliance now freezes accounts, and a frozen account can't send money. So we add a fourth code, minus three, a set of frozen ids, and a check in withdraw, right after the nil check, that returns it. The method is called freeze account because every Ruby object already has a freeze method of its own. The handler didn't change, and Ruby loads it without a single warning.

Step 3: Every caller has to check Code smell

An error code is a failure returned as an ordinary number, so every caller up the chain has to remember to check it, and nothing makes them. Withdraw returns the new code here, transfer checks it and passes it up here, the handler compares it with each code it knows here, and anything else falls through to here. So right now, a transfer from a frozen account moves no money, and the handler answers two hundred OK, because nobody added a branch for minus three. Each caller in the chain has to write the lines that pass the error code up, and nothing forces it to, while the handler treats any number it doesn't know as success, so one missing branch turns a failure into two hundred OK.

Step 4: Back it out, refactor first Plan

Let's back the freeze out. We'll refactor first, turning each error code into an exception one at a time, and then the freeze goes back in.

Refactor

Step 5: Missing account raises Replace Error Code with Exception

We start with the missing account, and Replace Error Code with Exception gives it a class of its own, Account Not Found Error. It subclasses Standard Error, the class a bare rescue catches, and Ruby has no checked exceptions, so transfer in the middle declares nothing. Withdraw raises it here and transfer raises it here, each with the account id, where they used to return minus one. The handle method gets a rescue clause at its end that returns the same four oh four status text as before, so the no account constant is gone.

Step 6: Low balance raises Replace Error Code with Exception

Low balance gets the same move, with Insufficient Funds Error. Withdraw raises it when the balance is too small, and the handler gets a second rescue clause that returns the same four oh nine status text. With no code left to compare, the case goes, so the handler calls transfer and then answers two hundred OK. That leaves withdraw returning OK and nothing else, which means the check in transfer can never fire.

Step 7: withdraw and transfer stop returning codes Change Function Declaration

Ruby has no return types to edit, so Change Function Declaration here means withdraw and transfer stop promising a number, since there's none left worth returning. The OK constant, both OKs at the ends of the methods, and the lines that passed the error code up all go, so transfer is down to three steps. It checks the receiver, takes the money from the sender, and pays the receiver.

Payoff

Step 8: The freeze is one raise, one rescue Payoff

Now the freeze goes in again, and it starts with a third exception class, Account Frozen Error. The wallet gets the frozen set and freeze account, and withdraw raises the new exception here, after the nil check and before the balance check. The handler gets one more rescue clause, and it answers with four twenty three, account frozen. Transfer didn't change at all, because it has no line that carries a failure up.

Step 9: Why it was easy Payoff

Back at the same four places, withdraw raises here, and transfer has no line here that passes a failure up, so no caller in between can forget one. The handler has one rescue clause here for each failure it reports, and two hundred OK runs here only when transfer returned normally. A forgotten when used to answer two hundred OK in silence, but forget a rescue now and the exception leaves the handler loudly, so it never reaches that line. This fits here because the failure starts deep in withdraw and only the handler knows what to say about it, so transfer shouldn't have to carry it.

Recap

Step 10: Where we started, where we ended Before / after

On the left, withdraw and transfer return numbers, and transfer has to pass each one up by hand. The handler's case ends in two hundred OK, so any code it doesn't know counts as success. On the right, withdraw and transfer raise, and transfer is three steps with nothing to relay. And the handler reaches two hundred OK only when the transfer worked, with a rescue clause for each failure.

Step 11: When to reach for it Takeaways

Reach for it when a failure starts deep in a call chain and only a caller near the top knows how to answer it. Skip it when the direct caller answers the failure, like a lookup that often finds nothing, because with no chain to skip, a nil check on the hash does the same job. You'll spot it by constants like minus one, by lines that pass a code up the chain, or by a last branch that treats unknown codes as success. Ruby's own Integer method works this way, because it raises an Argument Error for text that isn't a number.