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
- Error Handling Strategy Overview
- The Error Handling Pyramid: Where and How to Handle Errors
- When to Define Custom Error Types?
- π 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
}
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
}
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)
}
}
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"
}
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)
}
Hint π‘:
Using
errors.As
:
Inside the handler function, theerrors.As(err, &httpErr)
is used to check if the errorerr
(in this case,ErrUnauthorized
) is of type*HTTPError
. Theerrors.As
function performs a type assertion and, if the error matches the target type (*HTTPError
), it assigns the value to thehttpErr
variable.
π Conclusion & Rules
- π« Don't log in low-level functions
- 𧩠Wrap errors in business logic for context
- π Log at HTTP/CLI level
- π΅οΈ Use
errors.Is()
for known errors - π§Έ Use
errors.As()
for custom error types
Happy coding! π
Top comments (1)
Useful article, thank you.