This episode unifies Gin error handling and logging: capturing errors with c.Error, creating custom error types, an error handler middleware returning unified JSON responses, and integrating slog for structured logging in Gin middleware.

An application that runs flawlessly without errors is wishful thinking. What sets a mature application apart is how it handles errors and records events. This episode 11 dissects centralized error handling in Gin: capturing errors with c.Error, defining a custom error type that carries an HTTP status, middleware that turns errors into unified JSON responses, and structured logging with slog from Go's standard library.
With this approach, handlers don't repeat the if err != nil pattern that writes haphazard JSON. A single middleware acts as the last line of defense: translating every error into a consistent format and logging it for debugging.
Handlers often need to pass errors up to a higher layer. Gin provides c.Error(err) to store the error in the context, while writing the response can be delegated to middleware:
func getUserHandler(c *gin.Context) {
user, err := h.svc.GetUser(c.Request.Context(), id)
if err != nil {
c.Error(err)
return
}
c.JSON(200, user)
}c.Error(err) adds the error to the c.Errors slice and marks that the response hasn't been written yet. The handler stops, and the registered middleware later reads c.Errors to determine the response. Note that c.Error doesn't automatically return a status code — that's the middleware's job.
So the middleware knows the status code and the message safe to send to the client, define your own error type:
type AppError struct {
Code string
Message string
HTTPCode int
Err error
}
func (e *AppError) Error() string {
if e.Err != nil {
return e.Err.Error()
}
return e.Message
}
func NotFound(msg string) *AppError {
return &AppError{Code: "not_found", Message: msg, HTTPCode: 404}
}AppError implements the error interface through its Error() method. The HTTPCode field carries the HTTP status, Code carries a stable machine-readable code for clients, and Err wraps the original error for logging. Constructors like NotFound(msg) make creating consistent errors easy.
Go's built-in errors can still be wrapped with fmt.Errorf and inspected with errors.As:
if err := repo.FindByID(ctx, id); err != nil {
return nil, fmt.Errorf("repo: %w", err)
}var appErr *AppError
if errors.As(err, &appErr) {
c.JSON(appErr.HTTPCode, gin.H{
"code": appErr.Code,
"message": appErr.Message,
})
return
}
c.JSON(500, gin.H{"code": "internal", "message": "terjadi kesalahan"})errors.As(err, &appErr) checks whether the error (or its wrappers) is of type *AppError. If so, the response uses the code and status from AppError; otherwise, it falls back to 500 with a generic message so internal details don't leak to the client.
Combine all the pieces into one complete middleware:
func ErrorHandler() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
if len(c.Errors) == 0 {
return
}
err := c.Errors.Last().Err
if c.Writer.Written() {
return
}
var appErr *AppError
if errors.As(err, &appErr) {
c.JSON(appErr.HTTPCode, gin.H{
"code": appErr.Code,
"message": appErr.Message,
})
return
}
slog.Error("unhandled error", "path", c.Request.URL.Path, "error", err)
c.JSON(500, gin.H{"code": "internal", "message": "terjadi kesalahan"})
}
}The guard c.Writer.Written() prevents the middleware from overwriting a response the handler already wrote. Unknown errors are logged with slog.Error before the generic 500 response is sent — this pattern keeps logs informative and responses safe.
Go 1.21+ provides log/slog for structured logging. Create a JSON logger in main:
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
slog.Info("server started", "port", cfg.Port)
slog.Error("database unreachable", "addr", cfg.DatabaseURL)slog.NewJSONHandler(os.Stdout, nil) produces logs in JSON format — one line per event, easily machine-readable by tools like Loki, CloudWatch, or ELK. The level can be raised to slog.LevelDebug during development through the LOG_LEVEL setting from episode 10.
Integrate slog into the request flow:
func slogMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
slog.Info("request",
"method", c.Request.Method,
"path", c.Request.URL.Path,
"status", c.Writer.Status(),
"duration_ms", time.Since(start).Milliseconds(),
)
}
}c.Writer.Status() is read after the handler finishes so the final status is recorded. The duration_ms field is useful for monitoring slow endpoints. Combine it with the error handler middleware above: one log for the request, one log for any unhandled error.
Key takeaways:
c.Error(err) captures errors in the context instead of writing the response directly.c.Errors.Last().Err reads the last error in middleware.AppError carries code, message, and HTTP status.errors.As distinguishes known errors from unknown ones.slog provides production-ready structured JSON logging.In the next episode, episode 12, we'll dissect context, timeout & concurrency — using c.Request.Context, c.Copy for goroutines, context timeouts, simple rate limiting, and synchronizing data access with mutexes.