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