Skip to main content
Clean Code 60 XP · 6 min

Clean Error Handling

Error handling is a separate concern — keep it isolated so your main algorithm stays readable.

Showing
Ad (728×90)

Exceptions vs. Return Codes

Return codes force the caller to check every result immediately, polluting the call site with conditionals. Error handling logic becomes interleaved with business logic. Exceptions separate the happy path from error cases, letting you write the algorithm once, clearly, and handle failures in one focused place.

Return codes — error handling dominates

result = db.connect()
if result == ERROR_NO_CONNECTION:
    log("connection failed")
    return ERROR_NO_CONNECTION

result = db.query(userId)
if result == ERROR_NOT_FOUND:
    log("user not found")
    return ERROR_NOT_FOUND

result = db.save(result)
if result == ERROR_SAVE_FAILED:
    log("save failed")
    return ERROR_SAVE_FAILED

Exceptions — happy path is readable

try:
    connection = db.connect()
    user       = db.query(userId)
    db.save(user)
catch NoConnectionError:
    log("connection failed")
catch UserNotFoundError:
    log("user not found")
catch SaveFailedError:
    log("save failed")

The Try-Catch-Finally Block

Think of try-catch-finally like a database transaction — it defines a scope. The try block is the transaction body. The catch handles the rollback. finally always runs (connection cleanup, file handles). Extract the body of catch blocks into named functions — a catch block that's more than 2 lines is a function waiting to be born.

Logic buried inside catch block

try:
    processPayment(order)
catch PaymentError as e:
    log("Payment failed: " + e.code)
    notifyFinanceTeam(order, e)
    order.status = "failed"
    db.update(order)
    sendFailureEmail(order.user, e.message)
    metrics.increment("payment.failures")

Extract catch body to named function

try:
    processPayment(order)
catch PaymentError as e:
    handlePaymentFailure(order, e)

function handlePaymentFailure(order, error):
    log("Payment failed: " + error.code)
    order.status = "failed"
    db.update(order)
    notifyFinanceTeam(order, error)
    sendFailureEmail(order.user, error.message)
    metrics.increment("payment.failures")

The Danger of Null

Returning null forces every caller to check for it. One missed null check = runtime crash. Passing null as an argument is equally dangerous — the receiving function must now guard against it. Instead: throw an exception, return an empty collection, or use the Special Case (Null Object) pattern.

// ✗ Returns null — caller must remember to check
function findUser(userId):
    row = db.query(userId)
    if not row:
        return null   // ← every caller must null-check

user = findUser("123")
if user:             // easy to forget
    process(user)

// ✓ Option 1: raise an exception
function findUser(userId):
    row = db.query(userId)
    if not row:
        raise UserNotFoundError(userId)
    return User.fromRow(row)

// ✓ Option 2: Special Case / Null Object
class GuestUser:
    name = "Guest"
    function canCheckout(): return false

function findUser(userId):
    row = db.query(userId)
    if row:
        return User.fromRow(row)
    return new GuestUser()

// ✓ Option 3: empty collection (for list results)
function findOrders(userId):
    return db.queryOrders(userId) or []

Code Challenge

Replace Return Codes With Exceptions — rewrite to separate the happy path from error handling.

Key takeaway

Error handling is a separate concern. Write the happy path first, then deal with failures in one focused place.

Done with this lesson?

Mark it complete to earn XP and track your progress.