Backend Development 19 min read

Designing an Elegant Error‑Handling Framework for Go Applications

This article analyses the pitfalls of traditional error‑checking and panic handling in Go order‑processing code, demonstrates how excessive inline checks and scattered defer statements lead to maintenance nightmares, and proposes a reusable try‑catch‑finally library that cleanly separates business logic, unifies panic recovery, and guarantees resource cleanup.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Designing an Elegant Error‑Handling Framework for Go Applications

I am Lee, a veteran with 17 years of IT experience, and I want to share a story about optimizing error and exception handling in daily development and how I gradually built an elegant error‑handling tool from real‑world incidents.

The original order‑processing function is riddled with repetitive if err != nil checks and numerous panic guards, making the code hard to read and maintain.

Example code:

func processOrder(order *Order) error {
    // 1. validate order
    if err := validateOrder(order); err != nil {
        return fmt.Errorf("order validation failed: %w", err)
    }

    // 2. inventory check – may panic
    inventory, err := func() (inv *Inventory, err error) {
        defer func() {
            if r := recover(); r != nil {
                err = fmt.Errorf("panic during inventory check: %v", r)
            }
        }()
        return checkInventory(order)
    }()
    if err != nil {
        return fmt.Errorf("inventory check failed: %w", err)
    }

    // 3. payment processing – retry with panic recovery
    var paymentErr error
    for i := 0; i < 3; i++ {
        paymentErr = func() (err error) {
            defer func() {
                if r := recover(); r != nil {
                    err = fmt.Errorf("panic during payment: %v", r)
                }
            }()
            return processPayment(order)
        }()
        if paymentErr == nil {
            break
        }
        time.Sleep(time.Second * time.Duration(i+1))
    }
    if paymentErr != nil {
        return fmt.Errorf("payment ultimately failed: %w", paymentErr)
    }

    // 4. shipment creation
    if err := createShipment(order); err != nil {
        // rollback payment, which may also panic
        if rollbackErr := func() (err error) {
            defer func() {
                if r := recover(); r != nil {
                    err = fmt.Errorf("panic during payment rollback: %v", r)
                }
            }()
            return rollbackPayment(order)
        }(); rollbackErr != nil {
            return fmt.Errorf("payment rollback failed: %w", rollbackErr)
        }
        return fmt.Errorf("shipment creation failed: %w", err)
    }

    // 5. order confirmation – transaction handling
    tx, err := db.Begin()
    if err != nil {
        if rollbackErr := rollbackShipmentAndPayment(order); rollbackErr != nil {
            return fmt.Errorf("order rollback failed: %w", rollbackErr)
        }
        return fmt.Errorf("transaction begin failed: %w", err)
    }
    defer func() {
        if r := recover(); r != nil {
            tx.Rollback()
            panic(r)
        }
    }()
    if err := confirmOrder(tx, order); err != nil {
        tx.Rollback()
        if rollbackErr := rollbackShipmentAndPayment(order); rollbackErr != nil {
            return fmt.Errorf("order rollback failed: %w", rollbackErr)
        }
        return fmt.Errorf("order confirmation failed: %w", err)
    }
    if err := tx.Commit(); err != nil {
        tx.Rollback()
        if rollbackErr := rollbackShipmentAndPayment(order); rollbackErr != nil {
            return fmt.Errorf("order rollback failed: %w", rollbackErr)
        }
        return fmt.Errorf("transaction commit failed: %w", err)
    }
    return nil
}

The code looks complete but in production it suffers from several serious issues:

Error handling tightly coupled with business logic Each step is surrounded by massive error‑handling code, drowning the core logic. Changes easily miss related error handling.

Resource cleanup scattered Database transactions, payment rollbacks, shipment cancellations are all placed in different locations. Mix of defer statements and explicit cleanup makes ordering hard to control.

Inconsistent panic recovery Every possible panic requires its own recover block. Conversion from panic to error is not uniform. Recovery code is duplicated and easy to forget.

Rollback chain reactions Payment rollback may fail and need retries. Shipment cancellation can trigger new exceptions.

Under high load, boundary cases such as third‑party timeouts, database pool exhaustion, or intermittent service failures cause massive order‑processing failures, and the error‑handling code itself becomes a maintenance nightmare.

Attempts to add more checks or retries only bloat the code further.

func processOrderWithRetry(order *Order) error {
    var lastErr error
    for i := 0; i < 3; i++ {
        err := func() (err error) {
            defer func() {
                if r := recover(); r != nil {
                    err = fmt.Errorf("panic while processing order: %v", r)
                }
            }()
            if err := processOrder(order); err != nil {
                return err
            }
            return nil
        }()
        if err == nil {
            return nil
        }
        lastErr = err
        time.Sleep(time.Second * time.Duration(i+1))
    }
    return fmt.Errorf("order processing finally failed: %w", lastErr)
}

This approach mixes retry logic with business logic, lacks graceful resource cleanup, and duplicates panic handling, resulting in poor readability.

To solve these problems, I designed a unified error‑handling tool with a try‑catch‑finally style API that separates concerns, provides a consistent panic‑to‑error conversion, and guarantees that cleanup code always runs.

Solution Highlights:

Elegant syntax resembling try‑catch‑finally .

Built‑in resource management.

Unified panic handling.

Extensible error chain.

Complete error context with stack traces.

Example usage with the new library:

func processOrder(order *Order) {
    processor := NewOrderProcessor(order)
    NewSafeExecutor().
        Try(func() error { return processor.Process(order) }).
        Catch(func(err error) error { return handleOrderError(err) }).
        Finally(func() { processor.Cleanup() }).
        Do()
}

The library (go‑trycatch) is hosted on GitHub and provides a chainable API:

New() creates a TryCatch block.

Try(func() error) runs the risky code.

Catch(func(error)) handles both returned errors and recovered panics.

Finally(func()) runs cleanup regardless of outcome.

Do() executes the chain.

Reset() clears internal state for reuse.

Design principles focus on simplicity, reliability, compatibility with Go’s error model, lightweight implementation, and extensibility.

Usage recommendations:

Prefer standard if err != nil for simple cases.

Adopt the library for complex flows requiring unified handling and guaranteed cleanup.

Leverage the tool to catch unexpected panics and convert them to errors.

Use the Finally block to ensure resources such as DB connections, files, or network sockets are always released.

Adopting this framework reduced error‑handling code by about 60%, improved maintainability, and increased development speed by roughly 40%.

backendgoresource managementerror handlingtry-catchpanic recovery
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.