Courses/Custom Middleware & Hooks
Standalone Course~30 min12 challenges

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.

Middleware: Code that runs BEFORE (and optionally AFTER) your route handler. Middleware can inspect requests, modify responses, short-circuit the chain, or pass control to the next middleware. It's how you add cross-cutting concerns like auth, logging, and rate limiting without repeating code in every handler.

The request lifecycle with middleware:

Middleware Chain
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 sent

Notice 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.

1

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).

middleware/timer.go
// 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.HandlerFunc: The function signature for all Gin middleware and route handlers. It takes a single parameter: *gin.Context, which contains the request, response writer, URL parameters, headers, and methods to read/write data.
c.Next(): Calls the next middleware in the chain. Code before 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():

Registering Middleware
// Apply to ALL routes
r := gin.Default()
r.Use(MyMiddleware())

// Apply to a specific group
api := r.Group("/api")
api.Use(MyMiddleware())
2

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-ID header to every response for tracing.
  • Gzip — compresses responses to reduce bandwidth.
  • RateLimit — limits requests per IP using a sliding window. Prevents abuse.
Read the Auth middleware source code. It's a great example of a real middleware: extract the token from the header, validate it, set the user in context with c.Set(), and call c.Next(). If the token is missing or invalid, it aborts with 401.
3

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.

middleware/timer.go
// 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-Time header to the response — clients can read this
  • Logs the method, path, and duration — you can see slow requests in your logs
The key insight: 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.
4

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.

middleware/apikey.go
// 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.Abort(): Stops the middleware chain immediately. No further middleware or handlers will run. The response is sent as-is. Use 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.

Store valid API keys in the database or environment variables. Never hardcode them. For a simple setup, use an environment variable: API_KEY=sk_live_abc123. For multi-tenant APIs, store keys in a database table with the associated user/org.
5

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.

middleware/maintenance.go
// 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.

Maintenance Mode Commands
# Take the API offline
touch .maintenance
# All requests now return 503

# Bring it back online
rm .maintenance
# Requests resume immediately
No restart required. The middleware checks the file on every request. Create the file and the API is down. Delete the file and it's back. This is far better than stopping the server, because the process stays running and health checks still work.
6

Challenge: 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.

Route Groups with Middleware
// 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)
7

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.

Hook: Code that runs automatically at a specific lifecycle point. GORM hooks attach to database operations: BeforeCreate, AfterCreate, BeforeUpdate, AfterUpdate, BeforeDelete, AfterDelete. They're defined as methods on your model structs.
model/user.go - Hooks
// 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 nil

Common 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
If a hook returns an error, the database operation is cancelled and the error is returned to the caller. This is how you enforce business rules at the data layer — even if the handler forgets to validate, the hook catches it.
8

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.

middleware/recovery.go
// 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's default engine (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.
9

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
10

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.

11

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?

12

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.