DEV Community

Cover image for Error Handling in Go REST APIs: From Boilerplate to Beautiful
Parastesh
Parastesh

Posted on

Error Handling in Go REST APIs: From Boilerplate to Beautiful

When building REST APIs in Go with frameworks like Fiber, Gin, or Echo, error handling looks simple—until your app grows. Mapping database and validation errors to proper HTTP responses quickly descends into a swamp of repetitive code, scattered switch statements, and too many custom error variables. Here’s my journey from manual error plumbing to a clean, centralized solution.


Approach 1: Layered Error Handling — Clear, but Awkward

My first pass looked “proper”—each layer (repository, service, controller) defined errors, wrapped or mapped them up, and finally each endpoint mapped to HTTP codes.

Image description

Repository:

var ErrNotFound = errors.New("not found")

func (r *Repo) FindUser(id int) (*User, error) {
    // ... DB logic
    if notFound {
        return nil, ErrNotFound
    }
    return user, nil
}
Enter fullscreen mode Exit fullscreen mode

Service:

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

func (s *Service) GetUser(id int) (*User, error) {
    user, err := s.repo.FindUser(id)
    if err == ErrNotFound {
        return nil, ErrUserNotFound
    }
    return user, err
}
Enter fullscreen mode Exit fullscreen mode

Controller:

func (h *Handler) GetUser(c *fiber.Ctx) error {
    user, err := h.service.GetUser(id)
    if err == ErrUserNotFound {
        return c.Status(404).JSON(fiber.Map{"error": "User not found"})
    }
    if err != nil {
        return c.Status(500).JSON(fiber.Map{"error": "Internal error"})
    }
    return c.JSON(user)
}
Enter fullscreen mode Exit fullscreen mode

Why this goes wrong:

  • Switches and if blocks repeat everywhere.
  • Adding a new kind of error means updating every layer.
  • App logic gets buried in error handling code.

Approach 2: Chain of Responsibility — DRYer, Still Boilerplate

To fix duplication, I moved error mapping into a chain of handlers—each responsible for their error type. (Using chain of responsibility pattern)

Image description

Example handler:

type ErrorHandler interface {
    Handle(err error) (*AppError, bool)
    SetNext(next ErrorHandler)
}

type NotFoundHandler struct {
    next ErrorHandler
}
func (h *NotFoundHandler) Handle(err error) (*AppError, bool) {
    if errors.Is(err, ErrUserNotFound) {
        return &AppError{Code: 404, Message: "Not found"}, true
    }
    if h.next != nil {
        return h.next.Handle(err)
    }
    return nil, false
}
func (h *NotFoundHandler) SetNext(next ErrorHandler) { h.next = next }
func NewErrorChain() ErrorHandler {
    chain := &NotFoundHandler{}
chain.SetNext(&ValidationHandler{})
chain.SetNext(&DefaultHandler{}) // ... etc

    return chain
}
Enter fullscreen mode Exit fullscreen mode

Using the chain:

user, err := repository.GetUser(id)
if err != nil {
chain := NewErrorChain()
repositoryError := errHandler.Handle(err)
    return repositoryError
}
return c.JSON(user)
Enter fullscreen mode Exit fullscreen mode

This improved things:

  • Error mapping was reusable and composable.
  • Handlers were easy to test.

But:

  • You still set up the chain for every endpoint.
  • Most of the time, everyone used the same error-handler chain.
  • Error responses were handled in the controller, cluttering its main logic.

Approach 3: Central Error Middleware — Simple & Scalable

Why Middleware Wins
Eventually, I realized—error mapping is cross-cutting!
The controller should focus on business logic, not error->HTTP mapping. If all errors can be mapped centrally after the handler returns, the controller is blissfully unaware.
You just write a middleware that looks for errors from handlers and returns a consistent response.
Here’s how to do it in Fiber, Gin, and Echo.

Image description

Shared Error Mapping Function.
Let’s use a shared function in all three examples:

Fallback Controller:

// AppError represents an HTTP error
type AppError struct {
    Code    int
    Message string
}

func mapErrorToAppError(err error) *AppError {
    switch {
    case errors.Is(err, ErrUserNotFound):
        return &AppError{Code: 404, Message: "User not found"}
    case errors.Is(err, ErrValidation):
        return &AppError{Code: 422, Message: "Invalid input"}
    default:
        return &AppError{Code: 500, Message: "Internal server error"}
    }
}
Enter fullscreen mode Exit fullscreen mode

Fiber

func FallbackMiddleware() fiber.Handler {
    return func(c *fiber.Ctx) error {
        err := c.Next()
        if err == nil {
            return nil
        }
        appErr := mapErrorToAppError(err)
        return c.Status(appErr.Code).JSON(fiber.Map{
            "error": appErr.Message,
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage:

api := router.Group("/api")
api.Use(FallbackMiddleware())

Controller:
func (h *Handler) GetUser(c *fiber.Ctx) error {
    user, err := h.service.GetUser(id)
    if err != nil {
        return err
    }
    return c.JSON(user)
}
Enter fullscreen mode Exit fullscreen mode

Gin
Gin makes this elegant with its built-in error context and global middleware system.

func ErrorMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Next() // Process handler

        err := c.Errors.Last()
        if err == nil {
            return
        }
        appErr := mapErrorToAppError(err.Err)
        c.JSON(appErr.Code, gin.H{"error": appErr.Message})
    }
}

Enter fullscreen mode Exit fullscreen mode

Usage:

r := gin.Default()
r.Use(ErrorMiddleware())

Controller:
func (h *Handler) GetUser(c *gin.Context) {
    user, err := h.service.GetUser(id)
    if err != nil {
        c.Error(err) // Just attach error to context
        return
    }
    c.JSON(http.StatusOK, user)
}
Enter fullscreen mode Exit fullscreen mode

Echo

Echo middleware completion is equally clean:
func ErrorMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
    return func(c echo.Context) error {
        err := next(c)
        if err == nil {
            return nil
        }
        appErr := mapErrorToAppError(err)
        return c.JSON(appErr.Code, map[string]string{
            "error": appErr.Message,
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage:

e := echo.New()
e.Use(ErrorMiddleware)

Controller:
func (h *Handler) GetUser(c echo.Context) error {
    user, err := h.service.GetUser(id)
    if err != nil {
        return err // Let the middleware map the error
    }
    return c.JSON(http.StatusOK, user)
}

Enter fullscreen mode Exit fullscreen mode

Conclusion: Less Code, Fewer Bugs, More Focus

After all my experiments, middleware-based centralized error handling is by far the cleanest, most maintainable solution I’ve tried for Go APIs.
Repositories, Services and Controllers: Only return errors.
Middleware: Decides response logic in one place.
Easy to add, test, and extend.

Top comments (0)