Back to blog
Golang 2025-02-20

Go Error Handling: Writing Robust Blockchain Services

Error handling is one of the most debated aspects of Go. Coming from languages that use exceptions, the pattern of returning errors as values feels strange at first. After building production blockchain backends as MrBns, I have come to appreciate it deeply.

When your code touches people’s money – tokens, NFTs, on-chain transactions – you cannot afford silent failures. Go forces the issue.

The Basics: Error as a Value

In Go, a function that can fail returns an error as its last return value:

import (
    "errors"
    "fmt"
)

func getAccountBalance(accountID string) (float64, error) {
    if accountID == "" {
        return 0, errors.New("account ID cannot be empty")
    }
    // ... real logic
    return 1000.0, nil
}

balance, err := getAccountBalance("0.0.12345")
if err != nil {
    fmt.Println("failed to get balance:", err)
    return
}
fmt.Printf("Balance: %.2f HBAR\n", balance)

The if err != nil pattern is everywhere in Go code. It is intentional: you are forced to handle or explicitly ignore every error.

Wrapping Errors with Context

When errors propagate up the call stack, always add context using fmt.Errorf with the %w verb:

func transferTokens(from, to string, amount int64) error {
    tx, err := buildTransaction(from, to, amount)
    if err != nil {
        return fmt.Errorf("transferTokens: build failed: %w", err)
    }

    receipt, err := submitTransaction(tx)
    if err != nil {
        return fmt.Errorf("transferTokens: submit failed: %w", err)
    }

    if receipt.Status != "SUCCESS" {
        return fmt.Errorf("transferTokens: unexpected status %s", receipt.Status)
    }

    return nil
}

This produces error messages like:

transferTokens: submit failed: connection refused

Immediately you know where it failed and why.

Sentinel Errors

For errors that callers need to check for specifically, declare them as package-level variables:

var (
    ErrInsufficientBalance = errors.New("insufficient balance")
    ErrAccountNotFound     = errors.New("account not found")
    ErrTransactionExpired  = errors.New("transaction expired")
)

func transfer(from string, amount float64) error {
    balance, err := getBalance(from)
    if err != nil {
        return fmt.Errorf("transfer: %w", err)
    }
    if balance < amount {
        return fmt.Errorf("transfer: %w", ErrInsufficientBalance)
    }
    // ...
    return nil
}

// Caller can use errors.Is to check:
if errors.Is(err, ErrInsufficientBalance) {
    // show user a friendly message
}

Custom Error Types

For richer error information (useful for API responses), define custom types:

type HederaError struct {
    Code    string
    Message string
    TxID    string
}

func (e *HederaError) Error() string {
    return fmt.Sprintf("hedera error %s: %s (tx: %s)", e.Code, e.Message, e.TxID)
}

// Caller can use errors.As:
var hErr *HederaError
if errors.As(err, &hErr) {
    log.Printf("Transaction %s failed with code %s", hErr.TxID, hErr.Code)
}

Avoiding Panic in Production

panic in Go is roughly equivalent to an unhandled exception. In production blockchain services, you almost never want to panic. Validate all inputs early and return errors:

// Bad
func mustParseAccountID(s string) AccountID {
    id, err := parseAccountID(s)
    if err != nil {
        panic(err) // crashes the whole server!
    }
    return id
}

// Good
func parseAccountIDSafe(s string) (AccountID, error) {
    if s == "" {
        return AccountID{}, errors.New("account ID is empty")
    }
    // ... parse logic
    return AccountID{}, nil
}

The exception: use panic + recover at HTTP handler boundaries to turn unexpected panics into 500 responses rather than crashed processes.

The errors Package is Your Friend

Go 1.13+ brought errors.Is, errors.As, and errors.Unwrap. Use them:

// errors.Is checks the entire chain
if errors.Is(err, ErrAccountNotFound) { ... }

// errors.As extracts a specific type from the chain
var netErr *net.OpError
if errors.As(err, &netErr) { ... }

Practical Pattern: Result Type with Generics (Go 1.18+)

For code-heavy projects, a generic Result type reduces repetition:

type Result[T any] struct {
    Value T
    Err   error
}

func OK[T any](value T) Result[T]  { return Result[T]{Value: value} }
func Fail[T any](err error) Result[T] { return Result[T]{Err: err} }

func (r Result[T]) Unwrap() (T, error) { return r.Value, r.Err }

Summary

Explicit error handling in Go is not boilerplate – it is documentation. Every if err != nil is a reminder that this operation can fail, and here is what happens when it does. For a Blockchain Full Stack Developer building systems where a silent failure could mean lost tokens or a stuck transaction, that explicitness is priceless.

Key takeaways:

  • Always wrap errors with context using %w
  • Use sentinel errors (var Err... = errors.New(...)) for checkable conditions
  • Use custom error types for structured error data
  • Avoid panic in business logic – let it propagate as an error instead
Go Golang error handling blockchain MrBns Mr Binary Sniper