Custom Middleware & Hooks: Extending Grit
Learn to extend your Grit API with custom middleware and database hooks. You'll build request timers, API key authentication, maintenance mode, error recovery, and GORM lifecycle hooks — the building blocks for production-grade APIs.
What is Middleware?
Every request to your API passes through a chain of middleware before reaching the handler. Think of middleware as security guards at a building entrance — each one checks something different (ID badge, temperature, visitor log) before letting you in.
The request lifecycle with middleware:
Request arrives
-> Logger middleware (logs method, path, start time)
-> CORS middleware (adds cross-origin headers)
-> Auth middleware (verifies JWT token)
-> RateLimit middleware (checks request count)
-> Your Handler (processes the request)
<- RateLimit middleware (nothing to do after)
<- Auth middleware (nothing to do after)
<- CORS middleware (nothing to do after)
<- Logger middleware (logs response time, status code)
Response sentNotice that middleware runs in order going in, and in reverse order coming out. Code before c.Next() runs on the way in. Code after c.Next() runs on the way out.
Challenge: Name 3 Middleware Examples
Name 3 types of middleware you use every day (even if you didn't realize they were middleware). Hint: think about authentication, cross-origin requests, request logging, compression, rate limiting, and CSRF protection. What does each one check or do?
How Gin Middleware Works
A Gin middleware is a function that returns a gin.HandlerFunc. Inside, you receive *gin.Context and call c.Next() to pass control to the next middleware (or the handler).
// A simple middleware that logs request timing
// func MyMiddleware() gin.HandlerFunc
// return func(c *gin.Context)
//
// // --- BEFORE the handler ---
// fmt.Println("Request started:", c.Request.URL.Path)
//
// c.Next() // Call next middleware or handler
//
// // --- AFTER the handler ---
// fmt.Println("Response status:", c.Writer.Status())*gin.Context, which contains the request, response writer, URL parameters, headers, and methods to read/write data.c.Next() runs before the handler processes the request. Code after c.Next() runs after the handler has written the response. If you don't call c.Next(), the chain stops.To use middleware, register it with r.Use():
// Apply to ALL routes
r := gin.Default()
r.Use(MyMiddleware())
// Apply to a specific group
api := r.Group("/api")
api.Use(MyMiddleware())Challenge: Write a Simple Middleware
Write a middleware function that prints the HTTP method and URL path for every request. Example output: GET /api/users, POST /api/posts. Where would you put the print statement — before or after c.Next()?
Built-in Grit Middleware
Grit scaffolds several middleware out of the box. Each one lives in its own file ininternal/middleware/:
- • Auth — extracts JWT from the Authorization header, validates it, and sets the user in context. Protected routes use this.
- • CORS — adds Access-Control-Allow-Origin headers so the frontend (different port) can call the API.
- • Logger — structured request logging with method, path, status, and duration.
- • Cache — caches GET responses in Redis. Subsequent identical requests return the cached response.
- • RequestID — adds an
X-Request-IDheader to every response for tracing. - • Gzip — compresses responses to reduce bandwidth.
- • RateLimit — limits requests per IP using a sliding window. Prevents abuse.
c.Set(), and call c.Next(). If the token is missing or invalid, it aborts with 401.Challenge: Explore Built-in Middleware
Find each middleware file in internal/middleware/. Read the Auth middleware carefully. How does it extract the token? What header does it look for? What happens when the token is missing? What does it store in the context for handlers to use?
Creating a Request Timer
Your first custom middleware: measure how long each request takes. This is useful for performance monitoring — if a request takes 2 seconds, you know something's wrong.
// RequestTimer measures request duration
// func RequestTimer() gin.HandlerFunc
// return func(c *gin.Context)
// start := time.Now()
//
// c.Next() // Wait for handler to finish
//
// duration := time.Since(start)
// c.Header("X-Response-Time", duration.String())
// log.Printf("%s %s took %v",
// c.Request.Method,
// c.Request.URL.Path,
// duration,
// )This middleware does two things after the handler runs:
- • Adds an
X-Response-Timeheader to the response — clients can read this - • Logs the method, path, and duration — you can see slow requests in your logs
time.Now() is called BEFORE c.Next(), andtime.Since(start) is called AFTER. The difference is how long the handler (and all subsequent middleware) took to run.Challenge: Add Request Timer
Create a middleware/timer.go file with the RequestTimer middleware. Register it in your router. Make a few API requests and check: (1) Does the X-Response-Timeheader appear in responses? (2) Are the timings logged to the console?
Creating an API Key Middleware
JWT auth works great for user-facing apps, but what about external API consumers? Third-party services, webhooks, and server-to-server communication often use API keys instead of JWT tokens. An API key is a simple string passed in a header.
// RequireAPIKey checks the X-API-Key header
// func RequireAPIKey() gin.HandlerFunc
// return func(c *gin.Context)
// key := c.GetHeader("X-API-Key")
//
// if key == "" or key is not valid
// c.AbortWithStatusJSON(401, error response)
// // "code": "INVALID_API_KEY"
// // "message": "Valid API key required"
// return
//
// c.Next()c.AbortWithStatusJSON() to stop the chain AND send an error response in one call. This is how middleware rejects bad requests.The pattern: check the header, validate it, and either continue (c.Next()) or reject (c.Abort()). Every auth-style middleware follows this exact pattern.
API_KEY=sk_live_abc123. For multi-tenant APIs, store keys in a database table with the associated user/org.Challenge: Create API Key Middleware
Create the RequireAPIKey middleware. Test it by making requests with and without theX-API-Key header. Without the header, you should get a 401 response. With a valid key, the request should proceed normally.
Creating a Maintenance Middleware
Sometimes you need to take the API offline for maintenance — database migrations, infrastructure changes, or emergency fixes. The maintenance middleware checks for a.maintenance file and returns 503 if it exists.
// Maintenance checks if the API is in maintenance mode
// func Maintenance() gin.HandlerFunc
// return func(c *gin.Context)
// // Check if .maintenance file exists
// if file ".maintenance" exists
// c.AbortWithStatusJSON(503, error response)
// // "code": "MAINTENANCE"
// // "message": "Service temporarily unavailable"
// return
//
// c.Next()This is how grit down works internally — it creates the .maintenancefile, and the middleware blocks all requests. grit up removes the file and the API resumes instantly.
# Take the API offline
touch .maintenance
# All requests now return 503
# Bring it back online
rm .maintenance
# Requests resume immediatelyChallenge: Test Maintenance Mode
Read the actual maintenance middleware in your project. Compare it to the simplified version above. Create the .maintenance file and make an API request — do you get a 503? Remove the file and try again — does the API respond normally?
Middleware Groups
Not every route needs every middleware. Public routes don't need auth. Admin routes need auth AND role checks. Gin's route groups let you apply different middleware to different sets of routes.
// Public routes - no auth required
public := r.Group("/api/auth")
// POST /api/auth/register
// POST /api/auth/login
// POST /api/auth/forgot-password
// Protected routes - auth required
protected := r.Group("/api")
// Uses Auth middleware to verify JWT
// GET /api/posts, GET /api/users, etc.
// Admin routes - auth + admin role required
admin := protected.Group("/admin")
// Uses RequireRole("ADMIN") middleware
// GET /admin/users, DELETE /admin/posts, etc.The nesting is important: the admin group inherits the auth middleware from the protected group, then adds its own role check. A request to /api/admin/users goes through:
- 1. Global middleware (logger, CORS, gzip)
- 2. Auth middleware (verify JWT, set user in context)
- 3. RequireRole("ADMIN") middleware (check user role)
- 4. Handler (list users)
Challenge: Create a Custom Route Group
Create a new route group at /api/external that uses the API key middleware instead of JWT auth. Add a test endpoint to this group. Verify: JWT-protected routes reject API keys, and API-key-protected routes reject JWT tokens. Different auth for different consumers.
GORM Hooks
Middleware handles HTTP concerns. Hooks handle database concerns. GORM hooks are methods on your models that run automatically during database operations — before a record is created, after it's updated, before it's deleted.
// BeforeCreate runs before inserting a new user
// func (u *User) BeforeCreate(tx *gorm.DB) error
// u.Role = "USER" // Default role for new users
// return nil
// BeforeCreate runs before inserting a new post
// func (p *Post) BeforeCreate(tx *gorm.DB) error
// p.Slug = slugify(p.Title) // Auto-generate slug from title
// return nil
// BeforeUpdate runs before updating a post
// func (p *Post) BeforeUpdate(tx *gorm.DB) error
// if title changed
// p.Slug = slugify(p.Title) // Regenerate slug
// return nilCommon uses for GORM hooks:
- • BeforeCreate — set defaults, generate slugs, hash passwords, validate data
- • AfterCreate — send welcome email, create related records, log audit trail
- • BeforeUpdate — regenerate slugs, validate changes, track modifications
- • BeforeDelete — soft-delete instead of hard-delete, cascade to related records
Challenge: Add a BeforeCreate Hook
Add a BeforeCreate hook to the User model that validates email format. If the email doesn't contain an @ symbol, return an error. Test it: try creating a user with an invalid email via the API. Does the hook reject it?
Error Handling Middleware
What happens when a handler panics? Without error handling middleware, the entire server crashes. With it, the panic is caught, logged, and a clean 500 response is returned. The server stays running.
// ErrorHandler recovers from panics gracefully
// func ErrorHandler() gin.HandlerFunc
// return func(c *gin.Context)
// defer func()
// if err := recover(); err != nil
// log.Printf("Panic recovered: %v", err)
// // Log stack trace for debugging
// c.AbortWithStatusJSON(500, error response)
// // "code": "INTERNAL_ERROR"
// // "message": "Something went wrong"
//
// c.Next()The defer + recover() pattern is Go's way of catching panics. The deferred function runs after c.Next() returns (or panics), andrecover() catches the panic value if one occurred.
This middleware should be registered FIRST in the chain — before any other middleware. That way, it catches panics from any middleware or handler in the entire chain.
gin.Default()) includes a recovery middleware. But it returns HTML error pages, not JSON. For an API, you want JSON error responses — which is why Grit scaffolds a custom error handler.Challenge: Test Error Recovery
Add the ErrorHandler middleware to your router (register it first, before all other middleware). Create a test endpoint that intentionally panics. Hit it with a request. Does the middleware catch the panic? Does the server stay running? Check: is the response a clean JSON error (not an HTML page)?
Summary
Here's everything you learned in this course:
- Middleware runs before (and after) your handlers in a chain
- gin.HandlerFunc takes *gin.Context — call c.Next() to continue, c.Abort() to stop
- Grit scaffolds 7 middleware: Auth, CORS, Logger, Cache, RequestID, Gzip, RateLimit
- Code before c.Next() runs on the way in, code after runs on the way out
- c.Abort() stops the chain and sends the response immediately
- Route groups apply different middleware to different sets of routes
- GORM hooks (BeforeCreate, AfterCreate, etc.) run on database operations
- Hooks can set defaults, validate data, generate slugs, and enforce business rules
- Error handling middleware with recover() keeps the server running after panics
- Register error handling middleware first so it catches panics from the entire chain
Challenge: Build a Request Logger
Build a middleware that writes request logs to a file (not just the console). Each line should include: timestamp, method, path, status code, response time, and client IP. Use Go's os.OpenFile with append mode. After 10 requests, open the log file and verify the entries.
Challenge: Build an IP Whitelist
Build a middleware that only allows requests from specific IP addresses. Store the whitelist in an environment variable: ALLOWED_IPS=127.0.0.1,10.0.0.1. Requests from other IPs get a 403 Forbidden response. Test with your local IP — does it pass?
Challenge: Build a Slow Request Tracker
Build a middleware that logs slow requests as warnings. If a request takes longer than 500ms, log it with a "SLOW" prefix: SLOW: GET /api/posts took 1.2s. Combine this with the request timer from earlier. Create an intentionally slow handler (add a sleep) to test it.
Enjoying the course?
Help us grow — star us on GitHub, subscribe on YouTube, and follow on LinkedIn.