DEV Community

Cover image for Stop Fighting Go Errors: A Modern Approach to Handling Them
zakaria chahboun
zakaria chahboun

Posted on

2

Stop Fighting Go Errors: A Modern Approach to Handling Them

Error handling is the unsung hero of robust software development. In this guide, we'll dive into a comprehensive error handling strategy that keeps your Go applications clean, informative, and maintainable.

Table of Contents

  1. Error Handling Strategy Overview
  2. The Error Handling Pyramid: Where and How to Handle Errors
  3. When to Define Custom Error Types?
  4. πŸ† Conclusion & Rules

Error Handling Strategy Overview

Where? What to Do? Why?
Low-level functions (utils, DB calls, API calls, etc.) Return errors as-is (don't log) Callers higher up should decide how to handle the error
Business logic (services, middle-layer functions) Wrap errors using %w Adds context while preserving original error details
HTTP Handlers, CLI commands, or main() Log errors, return user-friendly messages Avoid exposing internal details to users

The Error Handling Pyramid: Where and How to Handle Errors

Let's break down error handling across different layers of your application:

1. Utility Layer: Keep It Simple πŸ› οΈ

//utils/user.go
package utils

import (
    "errors"
    "fmt"
)

var ErrUserNotFound = errors.New("user not found")

func FetchUserFromDB(userID int) (string, error) {
    if userID <= 0 {
        return "", ErrUserNotFound
    }
    return "John Doe", nil
}
Enter fullscreen mode Exit fullscreen mode

Why not wrap here?

  • This is a utility function; wrapping it would add unnecessary details.
  • If every function wraps errors, unwrapping becomes a nightmare.
  • Return errors as-is. Let higher layers decide how to handle them.

Note πŸ‘‡:

If your utility function calls another function (e.g., JSON parsing, SQL query execution) that returns an error, you generally still want to return the error as-is in the utility layer.

2. Service Layer: Add Context πŸ”

// service/user.go
func GetUserProfile(userID int) (string, error) {
    user, err := utils.FetchUserFromDB(userID)
    if err != nil {
        // Wrap the error with context while preserving the original error πŸ˜‰
        return "", fmt.Errorf("failed to get user profile: %w", err)
    }
    return user, nil
}
Enter fullscreen mode Exit fullscreen mode

Why wrap here?

  • Adds context (e.g., "failed to get user profile").
  • Maintains stack trace for better debugging.
  • If multiple functions call FetchUserFromDB, each can add its own context.

3. HTTP Handler: Log and Protect πŸ›‘οΈ

// handlers/user.go
var logger = slog.New(slog.NewJSONHandler(os.Stdout, nil))

func UserHandler(w http.ResponseWriter, r *http.Request) {
    user, err := service.GetUserProfile(userID)
    if err != nil {
        // Catch our specific error because we already know it 😎
        if errors.Is(err, utils.ErrUserNotFound) {
            http.Error(w, "User not found", http.StatusNotFound)
            return
        }

        // Log unexpected errors with additional context for debugging
        logger.Error("Failed to process user request",
                     "error", err, "userID", userID)

        // Return a generic internal server error for unknown issues
        http.Error(w, "Internal Server Error",
                   http.StatusInternalServerError)
    }
}
Enter fullscreen mode Exit fullscreen mode

Why log here instead of inside GetUserProfile?

  • The handler is responsible for logging.
  • We unwrap and check errors for specific cases (ErrUserNotFound).
  • Users see a friendly message instead of raw error details.

4. When to Define Custom Error Types?

  • Use sentinel errors (var ErrX = errors.New()) when errors have clear meaning (e.g., "user not found").
  • Use custom error types when errors have extra fields (e.g., HTTP status codes, detailed error codes).
type HTTPError struct {
    Code    int
    Message string
}

func (e *HTTPError) Error() string {
    return fmt.Sprintf("%d: %s", e.Code, e.Message)
}

var ErrUnauthorized = &HTTPError{
    Code: 401,
    Message: "Unauthorized"
}
Enter fullscreen mode Exit fullscreen mode

Now will use it in our handler:

// Simulation for authentication
func auth() error {
    return ErrUnauthorized // Simulated error
}

func handler(w http.ResponseWriter, r *http.Request) {
    err := auth()

    // Magic here 🀠 πŸ‘‡
    var httpErr *HTTPError
    if errors.As(err, &httpErr) {
        // If err is of type *HTTPError, extract it and use its status code and message
        http.Error(w, httpErr.Message, httpErr.Code)
        return
    }

    // If error is unknown, return a generic 500 Internal Server Error
    http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
Enter fullscreen mode Exit fullscreen mode

Hint πŸ’‘:

Using errors.As:
Inside the handler function, the errors.As(err, &httpErr) is used to check if the error err (in this case, ErrUnauthorized) is of type *HTTPError. The errors.As function performs a type assertion and, if the error matches the target type (*HTTPError), it assigns the value to the httpErr variable.

πŸ† Conclusion & Rules

  1. 🚫 Don't log in low-level functions
  2. 🧩 Wrap errors in business logic for context
  3. 🌟 Log at HTTP/CLI level
  4. πŸ•΅οΈ Use errors.Is() for known errors
  5. 🧸 Use errors.As() for custom error types

Happy coding! πŸ‰

Sentry blog image

How I fixed 20 seconds of lag for every user in just 20 minutes.

Our AI agent was running 10-20 seconds slower than it should, impacting both our own developers and our early adopters. See how I used Sentry Profiling to fix it in record time.

Read more

Top comments (1)

Collapse
 
bashery profile image
bashery β€’

Useful article, thank you.

Sentry image

See why 4M developers consider Sentry, β€œnot bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more