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
panicin business logic – let it propagate as anerrorinstead