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.
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
}
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
}
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)
}
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)
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
}
Using the chain:
user, err := repository.GetUser(id)
if err != nil {
chain := NewErrorChain()
repositoryError := errHandler.Handle(err)
return repositoryError
}
return c.JSON(user)
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.
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"}
}
}
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,
})
}
}
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)
}
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})
}
}
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)
}
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,
})
}
}
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)
}
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)