- Keep functions under 50 lines (The Screen Rule) - One function = one responsibility - Use early returns instead of nested if statements - Always wrap errors with context using `fmt.Errorf` and `%w` - Use `defer` for guaranteed resource cleanup - Name functions with verb + noun pattern Jump to the checklist for quick reference.- Keep functions under 50 lines (The Screen Rule) - One function = one responsibility - Use early returns instead of nested if statements - Always wrap errors with context using `fmt.Errorf` and `%w` - Use `defer` for guaranteed resource cleanup - Name functions with verb + noun pattern Jump to the checklist for quick reference.

Clean Code: Functions and Error Handling in Go: From Chaos to Clarity [Part 1]

2025/10/31 14:30

Introduction: Why Go Functions Are Special

I've reviewed over 1000 pull requests in Go over the past 6 years, and the same mistakes keep appearing. Remember your first Go code? It probably had dozens of if err != nil checks and 200-line functions that did everything at once. After analyzing over 50 Go projects, I've identified the main beginner problem: they write Go like Java or Python, ignoring the language's idioms.

Common function problems I've seen:

  • Functions over 100 lines: ~40% of codebases
  • Mixed responsibilities: ~60% of functions
  • Poor error handling: ~30% of bugs
  • Missing defer for cleanup: ~45% of resource leaks

In this article — the first in a Clean Code in Go series — we'll explore how to write functions you won't be ashamed to show in code review. We'll discuss the single responsibility principle, error handling, and why defer is your best friend.

Single Responsibility Principle: One Function — One Job

Here's a typical function from a real project (names changed):

// BAD: monster function does everything func ProcessUserData(userID int) (*User, error) { // Validation if userID <= 0 { log.Printf("Invalid user ID: %d", userID) return nil, errors.New("invalid user ID") } // Database connection db, err := sql.Open("postgres", connString) if err != nil { log.Printf("DB connection failed: %v", err) return nil, err } defer db.Close() var user User err = db.QueryRow("SELECT * FROM users WHERE id = $1", userID).Scan(&user.ID, &user.Name, &user.Email) if err != nil { log.Printf("Query failed: %v", err) return nil, err } // Data enrichment if user.Email != "" { domain := strings.Split(user.Email, "@")[1] user.EmailDomain = domain // Check corporate domain corporateDomains := []string{"google.com", "microsoft.com", "apple.com"} for _, corp := range corporateDomains { if domain == corp { user.IsCorporate = true break } } } // Logging log.Printf("User %d processed successfully", userID) return &user, nil }

This function violates SRP on multiple fronts:

  • Validates input data
  • Manages database connections
  • Executes queries
  • Enriches data
  • Handles logging

The Screen Rule

Quality metric: A function should fit entirely on a developer's screen (roughly 30-50 lines). If you need to scroll — time to refactor.

Let's refactor following Go idioms:

// GOOD: each function has one responsibility func GetUser(ctx context.Context, userID int) (*User, error) { if err := validateUserID(userID); err != nil { return nil, fmt.Errorf("validation failed: %w", err) } user, err := fetchUserFromDB(ctx, userID) if err != nil { return nil, fmt.Errorf("fetch user %d: %w", userID, err) } enrichUserData(user) return user, nil } func validateUserID(id int) error { if id <= 0 { return fmt.Errorf("invalid user ID: %d", id) } return nil } func fetchUserFromDB(ctx context.Context, userID int) (*User, error) { row := db.QueryRowContext(ctx, ` SELECT id, name, email FROM users WHERE id = $1`, userID) var user User if err := row.Scan(&user.ID, &user.Name, &user.Email); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrUserNotFound } return nil, err } return &user, nil } func enrichUserData(user *User) { if user.Email == "" { return } parts := strings.Split(user.Email, "@") if len(parts) != 2 { return } user.EmailDomain = parts[1] user.IsCorporate = isCorporateDomain(user.EmailDomain) }

Now each function:

  • Fits on screen (20 lines max)
  • Has single responsibility
  • Can be tested independently

Error Handling: The Go Way

Problem: Nested Hell

Beginners often create the "pyramid of doom":

// BAD: deep nesting func SendNotification(userID int, message string) error { user, err := GetUser(userID) if err == nil { if user.Email != "" { if user.IsActive { if user.NotificationsEnabled { err := smtp.Send(user.Email, message) if err == nil { log.Printf("Sent to %s", user.Email) return nil } else { log.Printf("Failed to send: %v", err) return err } } else { return errors.New("notifications disabled") } } else { return errors.New("user inactive") } } else { return errors.New("email empty") } } else { return fmt.Errorf("user not found: %v", err) } }

Solution: Early Return (Guard Clauses)

// GOOD: early return on errors func SendNotification(userID int, message string) error { user, err := GetUser(userID) if err != nil { return fmt.Errorf("get user %d: %w", userID, err) } if user.Email == "" { return ErrEmptyEmail } if !user.IsActive { return ErrUserInactive } if !user.NotificationsEnabled { return ErrNotificationsDisabled } if err := smtp.Send(user.Email, message); err != nil { return fmt.Errorf("send to %s: %w", user.Email, err) } log.Printf("Notification sent to %s", user.Email) return nil }

Error Wrapping: Context Matters

Since Go 1.13, fmt.Errorf with the %w verb wraps errors. Always use it:

// Define sentinel errors for business logic var ( ErrUserNotFound = errors.New("user not found") ErrInsufficientFunds = errors.New("insufficient funds") ErrOrderAlreadyProcessed = errors.New("order already processed") ) func ProcessPayment(orderID string) error { order, err := fetchOrder(orderID) if err != nil { // Add context to the error return fmt.Errorf("process payment for order %s: %w", orderID, err) } if order.Status == "processed" { return ErrOrderAlreadyProcessed } if err := chargeCard(order); err != nil { // Wrap technical errors return fmt.Errorf("charge card for order %s: %w", orderID, err) } return nil } // Calling code can check error type if err := ProcessPayment("ORD-123"); err != nil { if errors.Is(err, ErrOrderAlreadyProcessed) { // Business logic for already processed order return nil } if errors.Is(err, ErrInsufficientFunds) { // Notify user about insufficient funds notifyUser(err) } // Log unexpected errors log.Printf("Payment failed: %v", err) return err }

Defer: Guaranteed Resource Cleanup

defer is one of Go's killer features. Use it for guaranteed cleanup:

// BAD: might forget to release resources func ReadConfig(path string) (*Config, error) { file, err := os.Open(path) if err != nil { return nil, err } data, err := io.ReadAll(file) if err != nil { file.Close() // Easy to forget during refactoring return nil, err } var config Config if err := json.Unmarshal(data, &config); err != nil { file.Close() // Duplication return nil, err } file.Close() // And again return &config, nil }

// GOOD: defer guarantees closure func ReadConfig(path string) (*Config, error) { file, err := os.Open(path) if err != nil { return nil, fmt.Errorf("open config %s: %w", path, err) } defer file.Close() // Will execute no matter what data, err := io.ReadAll(file) if err != nil { return nil, fmt.Errorf("read config %s: %w", path, err) } var config Config if err := json.Unmarshal(data, &config); err != nil { return nil, fmt.Errorf("parse config %s: %w", path, err) } return &config, nil }

Pattern: Cleanup Functions

func WithTransaction(ctx context.Context, fn func(*sql.Tx) error) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin transaction: %w", err) } // defer executes in LIFO order defer func() { if p := recover(); p != nil { tx.Rollback() panic(p) // re-throw panic after cleanup } if err != nil { tx.Rollback() } else { err = tx.Commit() } }() err = fn(tx) return err } // Usage err := WithTransaction(ctx, func(tx *sql.Tx) error { // All logic in transaction // Rollback/Commit happens automatically return nil })

Practical Tips

1. Function Naming

// BAD: unclear purpose func Process(data []byte) error func Handle(r Request) Response func Do() error // GOOD: verb + noun func ParseJSON(data []byte) (*Config, error) func ValidateEmail(email string) error func SendNotification(user *User, msg string) error

2. Function Parameters

If more than 3-4 parameters — use a struct:

// BAD: too many parameters func CreateUser(name, email, phone, address string, age int, isActive bool) (*User, error) // GOOD: group into struct type CreateUserRequest struct { Name string Email string Phone string Address string Age int IsActive bool } func CreateUser(req CreateUserRequest) (*User, error)

3. Return Values

// BAD: boolean flags are unclear func CheckPermission(userID int) (bool, bool, error) // what does first bool mean? second? // GOOD: use named returns or struct func CheckPermission(userID int) (canRead, canWrite bool, err error) // BETTER: struct for complex results type Permissions struct { CanRead bool CanWrite bool CanDelete bool } func CheckPermission(userID int) (*Permissions, error)

Clean Function Checklist

  • Fits on screen (30-50 lines max)
  • Does one thing (Single Responsibility)
  • Has clear name (verb + noun)
  • Uses early return for errors
  • Wraps errors with context (%w)
  • Uses defer for cleanup
  • Accepts context if can be cancelled
  • No side effects (or clearly documented)

Conclusion

Clean functions in Go aren't just about following general Clean Code principles. It's about understanding and using language idioms: early return instead of nesting, error wrapping for context, defer for guaranteed cleanup.

In the next article, we'll discuss structs and methods: when to use value vs pointer receivers, how to organize composition properly, and why embedding isn't inheritance.

What's your approach to keeping functions clean? Do you have a maximum line limit for your team? Let me know in the comments!

Disclaimer: The articles reposted on this site are sourced from public platforms and are provided for informational purposes only. They do not necessarily reflect the views of MEXC. All rights remain with the original authors. If you believe any content infringes on third-party rights, please contact service@support.mexc.com for removal. MEXC makes no guarantees regarding the accuracy, completeness, or timeliness of the content and is not responsible for any actions taken based on the information provided. The content does not constitute financial, legal, or other professional advice, nor should it be considered a recommendation or endorsement by MEXC.
Share Insights

You May Also Like

Crypto Market Cap Edges Up 2% as Bitcoin Approaches $118K After Fed Rate Trim

Crypto Market Cap Edges Up 2% as Bitcoin Approaches $118K After Fed Rate Trim

The global crypto market cap rose 2% to $4.2 trillion on Thursday, lifted by Bitcoin’s steady climb toward $118,000 after the Fed delivered its first interest rate cut of the year. Gains were measured, however, as investors weighed the central bank’s cautious tone on future policy moves. Bitcoin last traded 1% higher at $117,426. Ether rose 2.8% to $4,609. XRP also gained, rising 2.9% to $3.10. Fed Chair Jerome Powell described Wednesday’s quarter-point reduction as a risk-management step, stressing that policymakers were in no hurry to speed up the easing cycle. His comments dampened expectations of more aggressive cuts, limiting enthusiasm across risk assets. Traders Anticipated Fed Rate Trim, Leaving Little Room for Surprise Rally The Federal Open Market Committee voted 11-to-1 to lower the benchmark lending rate to a range of 4.00% to 4.25%. The sole dissent came from newly appointed governor Stephen Miran, who pushed for a half-point cut. Traders were largely prepared for the move. Futures markets tracked by the CME FedWatch tool had assigned a 96% probability to a 25 basis point cut, making the decision widely anticipated. That advance positioning meant much of the potential boost was already priced in, creating what analysts described as a “buy the rumour, sell the news” environment. Fed Rate Decision Creates Conditions for Crypto, But Traders Still Hold Back Andrew Forson, president of DeFi Technologies, said lower borrowing costs would eventually steer more money toward digital assets. “A lower cost of capital indicates more capital flows into the digital assets space because the risk hurdle rate for money is lower,” he noted. He added that staking products and blockchain projects could become attractive alternatives to traditional bonds, offering both yield and appreciation. Despite the cut, crypto markets remained calm. Open interest in Bitcoin futures held steady and no major liquidation cascades followed the Fed’s decision. Analysts pointed to Powell’s language and upcoming economic data as the key factors for traders before building larger positions. Powell’s Caution Tempers Immediate Impact of Fed Rate Move on Crypto Markets History also suggests crypto rallies after rate cuts often take time. When the Fed eased in Dec. 2024, Bitcoin briefly surged 5% cent before consolidating, with sustained gains arriving only weeks later. This time, market watchers are bracing for a similar pattern. Powell’s insistence on caution, combined with uncertainty around inflation and growth, has kept short-term volatility muted even as sentiment for risk assets improves. BitMine’s Tom Lee this week predicted that Bitcoin and Ether could deliver “monster gains” in the next three months if the Fed continues on an easing path. His view echoes broader expectations that liquidity-sensitive assets will outperform once the cycle gathers pace. For now, the crypto sector has digested the Fed’s move with restraint. Traders remain focused on signals from the central bank’s October meeting to determine whether Wednesday’s step marks the beginning of a broader policy shift or just a one-off adjustment
Share
CryptoNews2025/09/18 13:14