Building Modular and Reusable Middleware for Gin and Chi Routers
Grace Collins
Solutions Engineer · Leapcell

Introduction
In the world of building robust and scalable web applications with Go, middleware plays a crucial role in handling cross-cutting concerns. Imagine scenarios like user authentication, logging requests, input validation, or content type negotiation. Implementing these functionalities directly within every handler function would lead to significant code duplication, tangled logic, and a maintenance nightmare. This is precisely where the power of middleware shines. By abstracting these common functionalities into separate, interchangeable components, we can achieve cleaner, more modular, and highly maintainable codebases. This article focuses on how to effectively write composable and reusable middleware for two of Go's most popular web frameworks: Gin and Chi, empowering developers to build elegant and efficient APIs.
Understanding Middleware: The Building Blocks of Web Applications
Before diving into the specifics of Gin and Chi, let's establish a foundational understanding of what middleware is and the core concepts surrounding it.
What is Middleware?
At its heart, middleware is a function or a set of functions that process HTTP requests and responses before or after the main handler function is executed. They form a "pipeline" through which an HTTP request travels, allowing each middleware to perform its specific task, modify the request or response, and then pass control to the next element in the pipeline, eventually reaching the final handler.
Core Concepts
- Request/Response Interception: Middleware intercepts the flow of an HTTP request, allowing for pre-processing (e.g., authentication) or post-processing (e.g., logging response status).
- Chaining/Pipelining: Multiple middleware functions can be chained together, forming a sequence. Each middleware can decide whether to pass the request to the next in the chain or terminate the request early (e.g., in case of an authentication failure).
- Context: Middleware often leverages the HTTP context to store and retrieve data that can be shared across the middleware chain and with the final handler. This avoids global variables and promotes thread-safe data sharing.
- Reusability: A well-designed middleware should be generic enough to be applied to different routes or even different applications without modification.
- Composability: The ability to combine multiple smaller, single-purpose middleware into more complex functionalities.
Gin and Chi Middleware Signatures
Both Gin and Chi, while having their own internal API, offer very similar patterns for defining middleware.
Gin Middleware Signature:
In Gin, a middleware function typically has the signature func(*gin.Context)
. The gin.Context
object contains the http.ResponseWriter
and *http.Request
along with methods for managing the request lifecycle, such as Next()
, Abort()
, and Set()
.
// Example Gin middleware: Logger func LoggerMiddleware() gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() c.Next() // Process the next middleware/handler duration := time.Since(start) log.Printf("Request - Method: %s, Path: %s, Status: %d, Duration: %v", c.Request.Method, c.Request.URL.Path, c.Writer.Status(), duration) } }
Chi Middleware Signature:
Chi middleware adheres more closely to the standard net/http
interface. A middleware function in Chi typically has the signature func(http.Handler) http.Handler
. This "decorator" pattern is very powerful, allowing middleware to wrap an http.Handler
and return a new one.
// Example Chi middleware: RequestID func RequestIDMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reqID := r.Header.Get("X-Request-ID") if reqID == "" { reqID = uuid.New().String() } ctx := context.WithValue(r.Context(), RequestIDKey, reqID) next.ServeHTTP(w, r.WithContext(ctx)) }) } // Defining a key for the context type ContextKey string const RequestIDKey ContextKey = "requestID"
Notice the difference: Gin's Next()
explicitly moves to the next handler, while Chi's next.ServeHTTP()
invokes the wrapped handler. Both achieve the same goal of continuing the request processing.
Writing Reusable Middleware
The key to reusability lies in making your middleware as generic and configurable as possible.
Parameterized Middleware
Instead of hardcoding values, allow middleware to be configured at initialization.
// Gin: Rate Limiting Middleware func RateLimitMiddleware(maxRequests int, window time.Duration) gin.HandlerFunc { // In a real scenario, this would use a more sophisticated rate limiting algorithm // like a token bucket or Leaky bucket, and potentially a distributed store. // For simplicity, we'll use a basic in-memory counter per IP. ipCounters := make(map[string]int) lastResets := make(map[string]time.Time) mu := sync.Mutex{} return func(c *gin.Context) { ip := c.ClientIP() mu.Lock() defer mu.Unlock() if _, ok := lastResets[ip]; !ok || time.Since(lastResets[ip]) > window { ipCounters[ip] = 0 lastResets[ip] = time.Now() } if ipCounters[ip] >= maxRequests { c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "Too many requests"}) return } ipCounters[ip]++ c.Next() } } // Chi: Authentication Middleware func AuthMiddleware(secretKey string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := r.Header.Get("Authorization") if token == "" || !isValidToken(token, secretKey) { // isValidToken would be a real validation function http.Error(w, "Unauthorized", http.StatusUnauthorized) return } // If token is valid, you might store user info in context ctx := context.WithValue(r.Context(), UserIDKey, "some_user_id") // Assume UserIDKey is defined next.ServeHTTP(w, r.WithContext(ctx)) }) } } // Dummy isValidToken for demonstration func isValidToken(token, secretKey string) bool { // In a real app, validate JWT, API key, etc. return token == "Bearer mysecrettoken" && secretKey == "supersecret" }
Middleware with Options Pattern
For middleware that accepts multiple optional parameters, the "options pattern" (functional options) is a clean way to provide configuration.
// Gin: LogLevel Middleware with options type LogLevel int const ( LogInfo LogLevel = iota LogError ) type LoggerOptions struct { LogLevel LogLevel IncludeHeaders bool } type LoggerOption func(*LoggerOptions) func WithLogLevel(level LogLevel) LoggerOption { return func(o *LoggerOptions) { o.LogLevel = level } } func WithHeaders() LoggerOption { return func(o *LoggerOptions) { o.IncludeHeaders = true } } func ConfigurableLoggerMiddleware(opts ...LoggerOption) gin.HandlerFunc { options := LoggerOptions{ LogLevel: LogInfo, IncludeHeaders: false, } for _, opt := range opts { opt(&options) } return func(c *gin.Context) { start := time.Now() c.Next() duration := time.Since(start) if options.LogLevel == LogInfo { log.Printf("Request Info - Method: %s, Path: %s, Status: %d, Duration: %v", c.Request.Method, c.Request.URL.Path, c.Writer.Status(), duration) if options.IncludeHeaders { log.Println("Headers:", c.Request.Header) } } else if options.LogLevel == LogError && c.Writer.Status() >= 400 { log.Printf("Request Error - Method: %s, Path: %s, Status: %d, Message: %s", c.Request.Method, c.Request.URL.Path, c.Writer.Status(), c.Errors.ByType(gin.ErrorTypePrivate).String()) } } } // Usage: // r.Use(ConfigurableLoggerMiddleware(WithLogLevel(LogError))) // r.Use(ConfigurableLoggerMiddleware(WithLogLevel(LogInfo), WithHeaders()))
Building Composable Middleware
Composability often comes naturally with the way middleware functions are structured. By keeping each middleware focused on a single responsibility, you can easily combine them to create more complex processing pipelines.
// Gin Example: Combining multiple middlewares func setupGinRouter() *gin.Engine { r := gin.New() // Global middleware applied to all routes r.Use(gin.Logger()) // Built-in Gin logger r.Use(gin.Recovery()) // Built-in Gin recovery r.Use(RateLimitMiddleware(10, time.Minute)) // Our custom rate limit // Apply specific middleware to a group of routes adminGroup := r.Group("/admin") adminGroup.Use(AuthMiddleware("supersecret")) // Our custom auth { adminGroup.GET("/dashboard", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Welcome Admin!"}) }) } r.GET("/public", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Public access"}) }) return r } // Chi Example: Combining multiple middlewares func setupChiRouter() http.Handler { r := chi.NewRouter() // Global middleware applied to all routes r.Use(middleware.Logger) // Chi's built-in logger r.Use(middleware.Recoverer) // Chi's built-in recoverer r.Use(RequestIDMiddleware) // Our custom request ID // Apply specific middleware to a group of routes r.Group(func(adminRouter chi.Router) { adminRouter.Use(AuthMiddleware("supersecret")) // Our custom auth adminRouter.Get("/admin/dashboard", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("Welcome Admin!")) }) }) r.Get("/public", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("Public access")) }) return r }
In both examples, notice how Use()
method (Gin) or r.Use()
and r.Group()
(Chi) allow for easy composition. You simply list the middleware functions in the desired order. The order matters significantly, as each middleware processes the request sequentially.
Real-World Application: JWT Authentication Middleware
Let's illustrate a more complete and reusable JWTAuthMiddleware
for both frameworks.
Gin JWT Middleware:
package middleware import ( "net/http" "strings" "time" "github.com/dgrijalva/jwt-go" "github.com/gin-gonic/gin" ) // claims represents the JWT claims structure type Claims struct { UserID string `json:"user_id"` jwt.StandardClaims } // JWTAuthMiddleware creates a Gin middleware for JWT authentication. func JWTAuthMiddleware(secretKey string) gin.HandlerFunc { return func(c *gin.Context) { // Extract the token from the Authorization header authHeader := c.GetHeader("Authorization") if authHeader == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"}) return } tokenParts := strings.Split(authHeader, " ") if len(tokenParts) != 2 || tokenParts[0] != "Bearer" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header format"}) return } tokenString := tokenParts[1] // Parse and validate the token token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { return []byte(secretKey), nil }) if err != nil { if err == jwt.ErrSignatureInvalid { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token signature"}) return } if ve, ok := err.(*jwt.ValidationError); ok { if ve.Errors&jwt.ValidationErrorExpired != 0 { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Token expired"}) return } } c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Could not parse token"}) return } if !token.Valid { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) return } claims, ok := token.Claims.(*Claims) if !ok { c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Failed to get token claims"}) return } // Store user ID in context for subsequent handlers c.Set("userID", claims.UserID) c.Next() } }
Chi JWT Middleware:
package middleware import ( "context" "net/http" "strings" "time" "github.com/dgrijalva/jwt-go" ) // claims represents the JWT claims structure type Claims struct { UserID string `json:"user_id"` jwt.StandardClaims } // Define ContextKey for storing UserID type ContextKey string const UserIDKey ContextKey = "userID" // JWTAuthMiddleware creates a Chi middleware for JWT authentication. func JWTAuthMiddleware(secretKey string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") if authHeader == "" { http.Error(w, "Authorization header required", http.StatusUnauthorized) return } tokenParts := strings.Split(authHeader, " ") if len(tokenParts) != 2 || tokenParts[0] != "Bearer" { http.Error(w, "Invalid Authorization header format", http.StatusUnauthorized) return } tokenString := tokenParts[1] token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { return []byte(secretKey), nil }) if err != nil { if err == jwt.ErrSignatureInvalid { http.Error(w, "Invalid token signature", http.StatusUnauthorized) return } if ve, ok := err.(*jwt.ValidationError); ok { if ve.Errors&jwt.ValidationErrorExpired != 0 { http.Error(w, "Token expired", http.StatusUnauthorized) return } } http.Error(w, "Could not parse token", http.StatusForbidden) return } if !token.Valid { http.Error(w, "Invalid token", http.StatusUnauthorized) return } claims, ok := token.Claims.(*Claims) if !ok { http.Error(w, "Failed to get token claims", http.StatusInternalServerError) return } // Store user ID in context for subsequent handlers ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID) next.ServeHTTP(w, r.WithContext(ctx)) }) } }
These examples demonstrate how similar the logic is, but how the interaction with the framework's context and the way control is passed to the next handler differs. Both are equally powerful in achieving composable and reusable authentication.
Conclusion
Writing effective middleware is fundamental to building scalable and maintainable Go web applications. By understanding the core concepts and leveraging the patterns provided by frameworks like Gin and Chi, developers can craft modular, reusable, and composable middleware that elegantly addresses cross-cutting concerns, resulting in cleaner code and more efficient development workflows. Embrace middleware to streamline your API development and build robust services.