Interceptors

Complete guide to interceptors and middleware in Goa - type-safe Goa interceptors, HTTP middleware, and gRPC interceptors.

Goa provides a comprehensive solution for request processing that combines type-safe interceptors with traditional middleware patterns. This guide covers all three approaches.

Overview

When processing requests in a Goa service, you have three complementary tools:

  1. Goa Interceptors: Type-safe, compile-time checked access to your service’s domain types
  2. HTTP Middleware: Standard http.Handler pattern for HTTP-specific concerns
  3. gRPC Interceptors: Standard gRPC patterns for RPC-specific needs

When to Use Each

ConcernTool
Business logic validationGoa Interceptors
Data transformationGoa Interceptors
Request/response enrichmentGoa Interceptors
Logging, tracingHTTP/gRPC Middleware
Compression, CORSHTTP Middleware
Metadata handlinggRPC Interceptors
Rate limitingHTTP/gRPC Middleware

Goa Interceptors

Goa interceptors provide type-safe access to your service’s domain types, with compile-time checks and generated helper methods.

Runtime Model (Generated Code)

Interceptors are not “magic hooks” in the runtime. In Goa they are generated endpoint wrappers. The DSL tells Goa what fields an interceptor may read/write, and code generation produces:

  • Service-side contract in gen/<service>/service_interceptors.go
    • ServerInterceptors interface: one method per interceptor
    • *<Interceptor>Info structs: service/method metadata + accessors
    • *Payload / *Result accessor interfaces: only the fields you declared as readable/writable
  • Client-side contract in gen/<service>/client_interceptors.go
    • ClientInterceptors interface and its *Info + accessor types
  • Wrapper chain in gen/<service>/interceptor_wrappers.go
    • Per-method Wrap<Method>Endpoint and Wrap<Method>ClientEndpoint
    • For streaming methods, stream wrappers that intercept SendWithContext / RecvWithContext
  • Wiring in gen/<service>/endpoints.go and gen/<service>/client.go
    • NewEndpoints applies server wrappers around service endpoints
    • NewClient applies client wrappers around transport endpoints

The important consequence is: server interceptors execute after transport decoding and before your service method, and client interceptors execute before transport encoding and after transport decoding (because they wrap the same typed endpoint abstraction your client code calls).

The Server Interceptor Contract

Goa generates a per-service interface. Each interceptor method must call next exactly once to proceed (or return an error/response early):

type ServerInterceptors interface {
    RequestAudit(ctx context.Context, info *RequestAuditInfo, next goa.Endpoint) (any, error)
}

At runtime you’ll typically:

  1. Use info.Payload() / info.Result(res) for type-safe access (preferred).
  2. Use info.Service(), info.Method(), and info.CallType() to tag logs/metrics with stable identifiers.
  3. Call next(ctx, info.RawPayload()) to continue the chain.
  4. Optionally mutate the payload before calling next, or mutate the result after it returns.

Example (result enrichment + timing):

type Interceptors struct{}

func (i *Interceptors) RequestAudit(ctx context.Context, info *RequestAuditInfo, next goa.Endpoint) (any, error) {
    start := time.Now()

    res, err := next(ctx, info.RawPayload())
    if err != nil {
        return nil, err
    }

    r := info.Result(res)
    r.SetProcessedAt(time.Now().UTC().Format(time.RFC3339Nano))
    r.SetDuration(int(time.Since(start).Milliseconds()))

    return res, nil
}

Why the accessor interfaces matter:

  • If you declare ReadPayload(Attribute("recordID")), Goa generates RecordID() <type>.
  • If you declare WriteResult(Attribute("cachedAt")), Goa generates SetCachedAt(<type>).
  • Your interceptor can’t accidentally reach for fields you didn’t declare; that’s the compile-time contract.

The Client Interceptor Contract

Client interceptors are the same idea on the client side: they wrap the transport endpoint you pass into gen/<service>.NewClient(...).

In practice that means:

  • info.RawPayload() is the typed method payload (e.g. *GetPayload), not an *http.Request.
  • If you “write” to the payload in the interceptor, the transport endpoint will encode those changes (headers/body/etc.) according to your transport mappings.
  • You can use info.Result(res) to read/write result fields after the transport decodes the response.

Ordering (What Actually Runs First)

Interceptors are applied by generating a wrapper chain. The generated Wrap<Method>Endpoint is the source of truth for ordering.

Conceptually, codegen does this:

func WrapGetEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint {
    endpoint = wrapGetCache(endpoint, i)
    endpoint = wrapGetJWTAuth(endpoint, i)
    endpoint = wrapGetRequestAudit(endpoint, i)
    endpoint = wrapGetSetDeadline(endpoint, i)
    endpoint = wrapGetTraceRequest(endpoint, i)
    return endpoint
}

Each wrap... returns a new endpoint that calls the interceptor with next pointing at the previous endpoint. As a result:

  • On the way in (request path): the last wrapper runs first.
  • On the way out (response path): the first wrapper runs first.

If ordering matters, rely on this mental model rather than a generic “service then method” rule: read the generated wrap function for the method you care about.

Streaming Interceptors (Send/Recv)

For bidirectional streaming, codegen wraps the stream implementation so that each message send/receive is intercepted. A single interceptor method may be invoked for multiple call types:

  • goa.InterceptorUnary: one-time interception of the stream endpoint call
  • goa.InterceptorStreamingSend: interception of each SendWithContext
  • goa.InterceptorStreamingRecv: interception of each RecvWithContext

Use info.CallType() to branch when needed. For send interceptions, info.RawPayload() is the message being sent. For recv interceptions, the “payload” is produced by next (your interceptor sees it as the returned value).

Defining Interceptors

var RequestLogger = Interceptor("RequestLogger", func() {
    Description("Logs incoming requests and their timing")
    
    // Read status from result
    ReadResult(func() {
        Attribute("status")
    })
    
    // Add timing information to result
    WriteResult(func() {
        Attribute("processedAt")
        Attribute("duration")
    })
})

Applying Interceptors

Apply at service or method level:

var _ = Service("calculator", func() {
    // Apply to all methods
    ServerInterceptor(RequestLogger)
    
    Method("add", func() {
        // Method-specific interceptor
        ServerInterceptor(ValidateNumbers)
        
        Payload(func() {
            Attribute("a", Int)
            Attribute("b", Int)
        })
        Result(func() {
            Attribute("sum", Int)
            Attribute("status", Int)
            Attribute("processedAt", String)
            Attribute("duration", Int)
        })
    })
})

Implementing Interceptors

func (i *Interceptors) RequestLogger(ctx context.Context, info *RequestLoggerInfo, next goa.Endpoint) (any, error) {
    start := time.Now()
    
    // Call next interceptor or endpoint
    res, err := next(ctx, info.RawPayload())
    if err != nil {
        return nil, err
    }
    
    // Access result through type-safe interface
    r := info.Result(res)
    
    // Add timing information
    r.SetProcessedAt(time.Now().Format(time.RFC3339))
    r.SetDuration(int(time.Since(start).Milliseconds()))
    
    return res, nil
}

Access Patterns

Read-Only Access

var Monitor = Interceptor("Monitor", func() {
    Description("Collects metrics without modifying data")
    
    ReadPayload(func() {
        Attribute("size")
    })
    
    ReadResult(func() {
        Attribute("status")
    })
})

Write Access

var Enricher = Interceptor("Enricher", func() {
    Description("Adds context information")
    
    WritePayload(func() {
        Attribute("requestID")
    })
    
    WriteResult(func() {
        Attribute("processedAt")
    })
})

Combined Access

var DataProcessor = Interceptor("DataProcessor", func() {
    Description("Processes both requests and responses")
    
    ReadPayload(func() {
        Attribute("rawData")
    })
    WritePayload(func() {
        Attribute("processed")
    })
    
    ReadResult(func() {
        Attribute("status")
    })
    WriteResult(func() {
        Attribute("enriched")
    })
})

Client-Side Interceptors

var ClientContext = Interceptor("ClientContext", func() {
    Description("Enriches requests with client context")
    
    WritePayload(func() {
        Attribute("clientVersion")
        Attribute("clientID")
    })
    
    ReadResult(func() {
        Attribute("rateLimit")
        Attribute("rateLimitRemaining")
    })
})

var _ = Service("inventory", func() {
    ClientInterceptor(ClientContext)
    // ...
})

Streaming Interceptors

For streaming methods, use streaming variants:

var ServerProgressTracker = Interceptor("ServerProgressTracker", func() {
    Description("Adds progress to server stream responses")
    
    WriteStreamingResult(func() {
        Attribute("percentComplete")
        Attribute("itemsProcessed")
    })
})

var ClientMetadataEnricher = Interceptor("ClientMetadataEnricher", func() {
    Description("Enriches outgoing client stream messages")
    
    WriteStreamingPayload(func() {
        Attribute("clientTimestamp")
    })
})

Execution Order

Goa applies interceptors by building a wrapper chain around each method endpoint. The easiest way to understand the exact ordering (especially once you mix service-level + method-level interceptors) is to look at the generated Wrap<Method>Endpoint and remember:

  • Last wrapper executes first on the request path.
  • First wrapper executes first on the response path.

If you need a stable contract in your own code, treat the generated wrap function as the canonical ordering specification for that method.


HTTP Middleware

HTTP middleware handles protocol-level concerns using the standard http.Handler pattern.

Common Middleware Stack

mux := goahttp.NewMuxer()

// Add middleware (outermost to innermost)
mux.Use(debug.HTTP())                               // Debug logging
mux.Use(otelhttp.NewMiddleware("service"))          // OpenTelemetry
mux.Use(log.HTTP(ctx))                              // Request logging
mux.Use(goahttpmiddleware.RequestID())              // Request ID
mux.Use(goahttpmiddleware.PopulateRequestContext()) // Goa context

Creating Custom Middleware

func ExampleMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Pre-processing
        start := time.Now()
        
        next.ServeHTTP(w, r)
        
        // Post-processing
        log.Printf("Request took %v", time.Since(start))
    })
}

Security Headers Middleware

func SecurityHeadersMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("X-Frame-Options", "DENY")
        w.Header().Set("X-Content-Type-Options", "nosniff")
        w.Header().Set("X-XSS-Protection", "1; mode=block")
        
        next.ServeHTTP(w, r)
    })
}

Context Enrichment Middleware

func ContextEnrichmentMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        ctx = context.WithValue(ctx, "request.start", time.Now())
        ctx = context.WithValue(ctx, "request.id", r.Header.Get("X-Request-ID"))
        
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Error Handling Middleware

func ErrorHandlingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("panic recovered: %v", err)
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
            }
        }()
        
        next.ServeHTTP(w, r)
    })
}

gRPC Interceptors

gRPC interceptors handle protocol-level concerns for RPC calls.

Unary Interceptors

func LoggingInterceptor() grpc.UnaryServerInterceptor {
    return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
        start := time.Now()
        
        resp, err := handler(ctx, req)
        
        log.Printf("Method: %s, Duration: %v, Error: %v",
            info.FullMethod, time.Since(start), err)
        
        return resp, err
    }
}

Stream Interceptors

func StreamLoggingInterceptor() grpc.StreamServerInterceptor {
    return func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
        start := time.Now()
        
        err := handler(srv, ss)
        
        log.Printf("Stream: %s, Duration: %v, Error: %v",
            info.FullMethod, time.Since(start), err)
        
        return err
    }
}

Integration with Goa

func main() {
    srv := grpc.NewServer(
        grpc.UnaryInterceptor(grpc_middleware.ChainUnaryServer(
            MetadataInterceptor(),
            LoggingInterceptor(),
            MonitoringInterceptor(),
        )),
        grpc.StreamInterceptor(grpc_middleware.ChainStreamServer(
            StreamMetadataInterceptor(),
            StreamLoggingInterceptor(),
        )),
    )

    pb.RegisterServiceServer(srv, server)
}

Combining All Three

Here’s how all three approaches work together:

func main() {
    // 1. Create service with Goa interceptors
    svc := NewService()
    interceptors := NewInterceptors(log.Default())
    endpoints := NewEndpoints(svc, interceptors)
    
    // 2. Set up HTTP with middleware
    mux := goahttp.NewMuxer()
    mux.Use(otelhttp.NewMiddleware("payment-svc"))
    mux.Use(debug.HTTP())
    mux.Use(log.HTTP(ctx))
    
    httpServer := genhttp.New(endpoints, mux, dec, enc, eh, eh)
    genhttp.Mount(mux, httpServer)
    
    // 3. Set up gRPC with interceptors
    grpcServer := grpc.NewServer(
        grpc.UnaryInterceptor(grpc_middleware.ChainUnaryServer(
            grpc_recovery.UnaryServerInterceptor(),
            grpc_prometheus.UnaryServerInterceptor,
        )),
    )
    
    grpcSvr := gengrpc.New(endpoints, nil)
    genpb.RegisterPaymentServer(grpcServer, grpcSvr)
}

Execution Flow

Request Processing:
─────────────────────────────────────────────────────────────────>
HTTP/gRPC Middleware → Goa Interceptors → Service Method

Response Processing:
<─────────────────────────────────────────────────────────────────
Service Method → Goa Interceptors → HTTP/gRPC Middleware

Best Practices

Goa Interceptors

  • Use for business logic validation and data transformation
  • Keep interceptors focused on single responsibilities
  • Use type-safe access patterns

HTTP Middleware

  • Order middleware carefully (panic recovery first, then logging, etc.)
  • Pre-compile expensive objects (regex, etc.)
  • Use sync.Pool for frequently allocated objects

gRPC Interceptors

  • Focus on protocol-level concerns
  • Handle context cancellation properly
  • Use appropriate status codes

General

  • Test interceptors/middleware in isolation
  • Consider performance impact
  • Document the purpose of each interceptor

See Also