I'll Be Backend
Principles · Kotlin

Error handling: replace error codes with exceptions

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

13 steps5 chapters~6 min at 1.25×#error handling#exceptions#error codes#sealed classes
Language
The lesson plays right here. Pause anywhere, step back, or jump to a step below. Watch the Kotlin video on YouTube Code on GitHub

Watch the narrated video

Every step

Problem

Steps 1–4

Refactor

Steps 5–7

Payoff

Steps 8–9

Kotlin bonus

Steps 10–11

Recap

Steps 12–13

The code: before and after

Before

class Wallet {
    companion object {
        const val OK = 0
        const val NO_ACCOUNT = -1
        const val LOW_BALANCE = -2
    }

    val balances = mutableMapOf<String, Int>()

    fun withdraw(id: String, amount: Int): Int {
        val balance = balances[id] ?: return NO_ACCOUNT
        if (balance < amount) return LOW_BALANCE
        balances[id] = balance - amount
        return OK
    }

    fun transfer(from: String, to: String, amount: Int): Int {
        if (to !in balances) return NO_ACCOUNT
        val code = withdraw(from, amount)
        if (code != OK) return code
        balances[to] = balances.getValue(to) + amount
        return OK
    }
}

class TransferHandler(private val wallet: Wallet) {
    fun handle(from: String, to: String, amount: Int): String {
        val code = wallet.transfer(from, to, amount)
        return when (code) {
            Wallet.NO_ACCOUNT -> "404 no such account"
            Wallet.LOW_BALANCE -> "409 balance too low"
            else -> "200 ok"
        }
    }
}

After

sealed class WalletException(id: String) : RuntimeException(id)

class AccountNotFoundException(id: String) : WalletException(id)

class InsufficientFundsException(id: String) : WalletException(id)

class AccountFrozenException(id: String) : WalletException(id)

class Wallet {
    val balances = mutableMapOf<String, Int>()
    val frozen = mutableSetOf<String>()

    fun freeze(id: String) { frozen.add(id) }

    fun withdraw(id: String, amount: Int) {
        val balance = balances[id] ?: throw AccountNotFoundException(id)
        if (id in frozen) throw AccountFrozenException(id)
        if (balance < amount) throw InsufficientFundsException(id)
        balances[id] = balance - amount
    }

    fun transfer(from: String, to: String, amount: Int) {
        if (to !in balances) throw AccountNotFoundException(to)
        withdraw(from, amount)
        balances[to] = balances.getValue(to) + amount
    }
}

class TransferHandler(private val wallet: Wallet) {
    fun handle(from: String, to: String, amount: Int): String {
        return try {
            wallet.transfer(from, to, amount)
            "200 ok"
        } catch (e: WalletException) {
            when (e) {
                is AccountNotFoundException -> "404 no such account"
                is InsufficientFundsException -> "409 balance too low"
                is AccountFrozenException -> "423 account frozen"
            }
        }
    }
}

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. The handler is the one caller, and its when 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 null check, 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's when matches each code it knows here, and anything else falls into else 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 nothing forces it to, while the else branch treats any number the handler 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 throws 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 Exception. It extends Runtime Exception, and since Kotlin has no checked exceptions, the methods in between never have to declare it. Withdraw throws it here and transfer throws it here, each with the account id, where they used to return minus one. The handler wraps its call in a try, and the catch gives back the same four oh four status text as before, so the no account constant is gone.

Step 6: Low balance throws Replace Error Code with Exception

Low balance gets the same move, with Insufficient Funds Exception. Withdraw throws it when the balance is too small, and the handler gets a second catch that gives back the same four oh nine status text. That leaves withdraw returning OK and nothing else, so the check in transfer can never fire.

Step 7: withdraw and transfer return Unit Change Function Declaration

Change Function Declaration drops the Int return type from withdraw and transfer, since there's no number left worth returning. The OK constant, every return OK, and the line that passed the error code up all go, so transfer is down to three lines. It checks the receiver, takes the money from the sender, and pays the receiver. In the handler, the try holds the transfer call and then two hundred OK, which is the answer only if transfer came back normally.

Payoff

Step 8: The freeze is one throw, one catch Payoff

Now the freeze goes in again, and it starts with a third exception class, Account Frozen Exception. The wallet gets the frozen set and the freeze method, and withdraw throws the new exception here, after the null check and before the balance check. The handler gets one more catch, 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 throws here, and transfer has no line here that passes a failure up, so no caller in between can forget one. The handler has one catch here for each failure it reports, and two hundred OK is the answer here only when transfer returned normally. A forgotten branch used to answer two hundred OK in silence, but forget a catch now and the exception leaves handle 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.

Kotlin bonus

Step 10: Three exceptions, nothing checks the list Kotlin bonus

The handler keeps one catch per exception class, and nothing tells you when a fourth wallet exception shows up without one. Kotlin's sealed classes keep every subclass in the same module, so a when over them has to cover each one, and the compiler checks it.

Step 11: A sealed WalletException Kotlin bonus

A sealed Wallet Exception sits above the three, and each exception now extends it. The handler catches Wallet Exception once, and a when picks the status text for each type. Add a fourth wallet exception and forget its branch, and the when stops compiling, because there's no else left to fall into. It only covers the wallet's own closed set, so an exception from a library outside it still needs a catch of its own.

Recap

Step 12: 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 when ends in an else that answers two hundred OK, so any code it doesn't know counts as success. On the right, withdraw and transfer throw, and transfer is three lines with nothing to relay. And the handler's try reaches two hundred OK only when the transfer worked, with a catch for each failure.

Step 13: 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 nullable result and an elvis do the same job. You'll spot it by constants like minus one, by a line that passes a code up the chain, or by an else branch that treats unknown codes as success. Kotlin's own to Int on a string works this way, because it throws a Number Format Exception for text that isn't a number.