Interceptors: Before and After Handler Logic in Go
An interceptor wraps handler execution — it runs code before the handler, calls the handler, then runs code after it returns. In NestGo this is the Go HTTP interceptor pattern from NestJS: the natural home for timing, logging, caching, and response auditing.
Where guards answer “may this request proceed?”, interceptors answer “what happens around the handler?”.
The Interceptor interface
// Interceptor runs logic before and after a handler.
// Call next(c) to proceed to the handler. The error from next
// indicates whether the handler succeeded.
type Interceptor interface {
Intercept(c Context, next HandlerFunc) error
}
The shape is deliberate: everything before next(c) is your “before” logic, everything after is your “after” logic, and the returned error tells you whether the handler (or anything deeper in the chain) failed.
func (i *MyInterceptor) Intercept(c core.Context, next core.HandlerFunc) error {
// before the handler
err := next(c)
// after the handler (err != nil means it failed)
return err
}
An interceptor can also short-circuit: skip next(c) entirely, write a response itself, and return nil — that is how response caching works below.
InterceptorFunc: write an interceptor as a function
InterceptorFunc adapts a plain function to the Interceptor interface:
logInterceptor := core.InterceptorFunc(func(c core.Context, next core.HandlerFunc) error {
fmt.Printf("→ %s %s\n", c.Method(), c.Path())
err := next(c)
fmt.Printf("← %s %s\n", c.Method(), c.Path())
return err
})
UseInterceptors: turn interceptors into middleware
UseInterceptors converts interceptors into a MiddlewareFunc. The first interceptor listed is the outermost — its “before” code runs first and its “after” code runs last:
func UseInterceptors(interceptors ...Interceptor) MiddlewareFunc
Apply at any level:
// Global — every route:
config.GlobalInterceptors = []core.Interceptor{timing}
// Group / controller level:
r.Group("/api", core.UseInterceptors(timing, logInterceptor))
// Single route:
r.GET("/reports", handler, core.UseInterceptors(cacheInterceptor))
In the execution chain, interceptors are innermost — closest to the handler: Filters → Guards → Pipes → Interceptors → Handler. See Exception Filters for the full order.
Example: request timing interceptor
import "time"
timing := core.InterceptorFunc(func(c core.Context, next core.HandlerFunc) error {
start := time.Now()
err := next(c)
core.Log().Info("request",
core.F("method", c.Method()),
core.F("path", c.Path()),
core.F("status", c.ResponseStatus()),
core.F("duration", time.Since(start)),
)
return err
})
Inspecting the response: ResponseStatus() and ResponseBody()
After next(c) returns, the response has been written — and NestGo’s Context lets an interceptor introspect it:
| Method | Returns | Before a response is written |
|---|---|---|
c.ResponseStatus() | The HTTP status code that was set | 0 |
c.ResponseBody() | A copy of the response body bytes | nil |
This is what makes audit logging and caching possible without touching handlers:
audit := core.InterceptorFunc(func(c core.Context, next core.HandlerFunc) error {
err := next(c)
core.Log().Info("audit",
core.F("path", c.Path()),
core.F("status", c.ResponseStatus()),
core.F("bytes", len(c.ResponseBody())),
)
return err
})
ResponseBody() returns a copy, so reading it never corrupts the response already sent to the client.
Example: response caching interceptor
A caching interceptor short-circuits on a hit (never calls next) and stores the response after a miss:
import "sync"
type cachedResponse struct {
status int
body []byte
}
func CacheInterceptor() core.Interceptor {
var cache sync.Map // path -> cachedResponse
return core.InterceptorFunc(func(c core.Context, next core.HandlerFunc) error {
if c.Method() != "GET" {
return next(c)
}
key := c.FullURL()
// Hit: reply from cache, skip the handler entirely.
if v, ok := cache.Load(key); ok {
cached := v.(cachedResponse)
c.SetHeader("X-Cache", "HIT")
c.SetHeader("Content-Type", "application/json")
return c.SendBytes(cached.status, cached.body)
}
// Miss: run the handler, then capture what it wrote.
err := next(c)
if err == nil && c.ResponseStatus() == 200 {
cache.Store(key, cachedResponse{
status: c.ResponseStatus(),
body: c.ResponseBody(),
})
}
return err
})
}
r.GET("/products", listProducts, core.UseInterceptors(CacheInterceptor()))
(For production, add expiry/invalidation — this example shows the interceptor mechanics.)
Example: error mapping interceptor
Because the handler’s error passes through the interceptor, you can translate errors on the way out:
import "errors"
mapErrors := core.InterceptorFunc(func(c core.Context, next core.HandlerFunc) error {
err := next(c)
if errors.Is(err, ErrRecordNotFound) {
return core.ErrNotFound("resource not found")
}
return err
})
For full control over error formatting, prefer an exception filter — interceptors are better suited to mapping and enrichment.
Interceptors vs middleware vs guards
| Feature | Runs | Best for |
|---|---|---|
Middleware (server.Use) | Outermost, transport level | CORS, compression, recovery, request ID |
| Guards | Before the handler, allow/deny | Authentication, role checks |
| Interceptors | Around the handler | Timing, logging, caching, response auditing |
Source: github.com/ashrafAli23/nestgo (core/interceptor.go, core/context.go).
Next steps
- Guards — allow or deny requests before they reach the handler
- Exception Filters — the full execution order and custom error responses
- Pipes — transform and validate extracted values