Error handling: replace error codes with exceptions
Let failures reach the code that handles them, and never pass as success.
Watch the narrated video
Every step
Problem
Steps 1–4Refactor
Step 5Payoff
Steps 6–7Recap
Steps 8–9The code: before and after
Before
const (
OK = 0
NoAccount = -1
LowBalance = -2
)
type Wallet struct {
balances map[string]int
}
func NewWallet(balances map[string]int) *Wallet {
return &Wallet{balances: balances}
}
func (w *Wallet) Withdraw(id string, amount int) int {
balance, found := w.balances[id]
if !found {
return NoAccount
}
if balance < amount {
return LowBalance
}
w.balances[id] = balance - amount
return OK
}
func (w *Wallet) Transfer(from, to string, amount int) int {
if _, found := w.balances[to]; !found {
return NoAccount
}
if code := w.Withdraw(from, amount); code != OK {
return code
}
w.balances[to] += amount
return OK
}
func HandleTransfer(w *Wallet, from, to string, amount int) string {
code := w.Transfer(from, to, amount)
if code == NoAccount {
return "404 no such account"
}
if code == LowBalance {
return "409 balance too low"
}
return "200 ok"
}After
import "errors"
var (
ErrNoAccount = errors.New("no such account")
ErrLowBalance = errors.New("balance too low")
ErrFrozen = errors.New("account frozen")
)
type Wallet struct {
balances map[string]int
frozen map[string]bool
}
func NewWallet(balances map[string]int) *Wallet {
return &Wallet{balances: balances, frozen: map[string]bool{}}
}
func (w *Wallet) Freeze(id string) { w.frozen[id] = true }
func (w *Wallet) Withdraw(id string, amount int) error {
balance, found := w.balances[id]
if !found {
return ErrNoAccount
}
if w.frozen[id] {
return ErrFrozen
}
if balance < amount {
return ErrLowBalance
}
w.balances[id] = balance - amount
return nil
}
func (w *Wallet) Transfer(from, to string, amount int) error {
if _, found := w.balances[to]; !found {
return ErrNoAccount
}
if err := w.Withdraw(from, amount); err != nil {
return err
}
w.balances[to] += amount
return nil
}
func HandleTransfer(w *Wallet, from, to string, amount int) string {
err := w.Transfer(from, to, amount)
if errors.Is(err, ErrNoAccount) {
return "404 no such account"
}
if errors.Is(err, ErrLowBalance) {
return "409 balance too low"
}
if errors.Is(err, ErrFrozen) {
return "423 account frozen"
}
if err != nil {
return "500 internal error"
}
return "200 ok"
}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 when the money left. Transfer checks the receiver, calls Withdraw, and passes any error code it gets straight back up. Handle Transfer is the one caller, and each code ends up as 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 map of frozen ids, and a check in Withdraw, right after the lookup, that returns it. The handler didn't change, and it compiles fine.
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 line that passes the error code up, and 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 the error codes into Go error values, and then the freeze goes back in.
Refactor
Step 5: Codes become error values Replace Error Code with Exception
Go has no exceptions, so its form of Replace Error Code with Exception returns a value of the built-in error type, and each caller handles it or passes it on. The two codes become sentinel errors, package-level values made with errors New, and the OK constant is gone because nil now means success. A Go function declares one return type, so Change Function Declaration comes along in the same step, and Withdraw and Transfer return error where they returned int. Withdraw returns them here and here, and Transfer passes any error from Withdraw up here, without asking which one it is. The handler asks errors Is about each sentinel, and its last check sends any other error to five hundred, a branch no input reaches today.
Payoff
Step 6: The freeze is one error, one check Payoff
Now the freeze goes in again, and it starts with a third sentinel, Err Frozen. The wallet gets the frozen map and the Freeze method, and Withdraw returns the new error here, after the lookup and before the balance check. The handler gets one more errors Is check, and it answers four twenty three, account frozen. Transfer didn't change at all, because its one relay line passes any error up without knowing which one.
Step 7: Why it was easy Payoff
Back at the same four places, Withdraw returns the new error here, and Transfer passes it up here without naming it, so a new failure needs no edit there. The handler has one check here for each failure it reports, and two hundred OK runs here only when the error is nil. A forgotten branch used to answer two hundred OK in silence, but forget an errors Is check now and the error falls into five hundred, because nil is the only value that means success. This fits here because the failure starts deep in Withdraw and only the handler knows what to say about it, so Transfer only has to pass it along.
Recap
Step 8: Where we started, where we ended Before / after
On the left, Withdraw and Transfer return numbers, and Transfer compares each one with OK before passing it up. The handler's if chain ends in two hundred OK, so any code it doesn't know counts as success. On the right, both functions return Go's error type, and nil is the one way to say the money moved. So the handler's last check catches any error it doesn't name, and two hundred OK runs only after it.
Step 9: 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 handles the miss, like a map lookup that often finds nothing, because with no chain to cross, the comma ok form does the same job. You'll spot it by constants like minus one, by a line that passes a code up the chain, or by a last branch that treats unknown codes as success. Go's own os Open works this way, because it returns an error you can check against the file not exist sentinel with errors Is.