Interceptor Best Practices

Guidelines and best practices for implementing Goa interceptors

This guide covers best practices and guidelines for implementing interceptors in your Goa services.

Design Time Best Practices

1. Keep Interceptors Focused

Interceptors should follow the single responsibility principle. Each interceptor should handle one specific cross-cutting concern, such as logging, metrics, or authentication. This makes them:

  • Easier to maintain and update independently
  • Simpler to test in isolation
  • More reusable across different services
  • Clearer in their purpose and behavior
  • Easier to compose together in different combinations

For example, instead of creating one large interceptor that handles both logging and metrics, create two separate focused interceptors that can be used together when needed. This separation of concerns leads to more maintainable and flexible code.

Here’s an example showing the difference between focused and unfocused interceptors:

// Good: Focused interceptors
var Auth = Interceptor("Auth", func() {
    Description("Handles authentication only")
    ReadPayload(func() {
        Attribute("token", String)
    })
})

var Metrics = Interceptor("Metrics", func() {
    Description("Collects metrics only")
    ReadResult(func() {
        Attribute("status", Int)
    })
})

// Bad: Too many responsibilities
var AuthAndMetrics = Interceptor("AuthAndMetrics", func() {
    Description("Handles both auth and metrics")
    // Mixing concerns makes the interceptor harder to maintain
})

The focused interceptors are easier to test, maintain, and can be composed in different combinations as needed.

2. Choose Appropriate Scope

Consider carefully whether an interceptor should apply to an entire service or just specific methods. Service-level interceptors are good for consistent cross-cutting concerns, while method-level interceptors are better for specific requirements.

This example shows how to apply interceptors at different scopes:

var _ = Service("users", func() {
    // Good: Auth applies to all methods
    ServerInterceptor(Auth)
    
    Method("list", func() {
        // Good: Metrics only needed for list
        ServerInterceptor(Metrics)
    })
})

Authentication is applied service-wide because it’s needed everywhere, while metrics collection is only applied to the list method where it’s relevant.

3. Use Descriptive Names and Documentation

Clear naming and documentation help other developers understand the purpose and behavior of your interceptors. The name should indicate what the interceptor does, and the description should explain its purpose and any important details.

Compare these examples:

// Good: Clear name and description
var RequestValidator = Interceptor("RequestValidator", func() {
    Description("Validates incoming requests against business rules")
    ReadPayload(func() {
        Attribute("data")
    })
})

// Bad: Unclear purpose
var Handler = Interceptor("Handler", func() {
    Description("Handles stuff")
    ReadPayload(func() {
        Attribute("data")
    })
})

The well-named interceptor makes its purpose clear and provides useful documentation.

Implementation Best Practices

1. Handle Errors Gracefully

Define your errors at design time using Goa’s error DSL. This ensures type safety and consistent error handling across your service. The error definitions become part of your API contract and generate appropriate helper functions.

Here’s how to define and use errors properly:

// In your design
var _ = Service("users", func() {
    // Define service-specific errors
    Error("unauthorized", ErrorResult, "Authentication failed")
    Error("invalid_token", ErrorResult, "Invalid or malformed token")
    
    // Use errors in interceptor design
    var Auth = Interceptor("Auth", func() {
        Error("unauthorized")
        Error("invalid_token")
        ReadPayload(func() {
            Attribute("token")
        })
    })
    
    ServerInterceptor(Auth)
})

// In your implementation
func (i *ServerInterceptors) Auth(ctx context.Context, info *AuthInfo, next goa.Endpoint) (any, error) {
    p := info.Payload()
    
    // Use design-time errors
    token := p.Token()
    if token == "" {
        return nil, genservice.MakeUnauthorized(fmt.Errorf("authentication token required"))
    }
    
    claims, err := validateToken(token)
    if err != nil {
        return nil, genservice.MakeInvalidToken(err.Error())
    }
    
    return next(ctx, info.RawPayload())
}

The generated Make* functions ensure that your errors match the design and include proper error codes and metadata. This approach provides better error handling than generic errors and helps maintain API consistency.

2. Preserve Context Values

When working with context in interceptors, it’s important to properly manage and preserve context values. Many libraries and tools (like tracers, loggers, or authentication) store information in the context. Your interceptor should:

  • Derive new contexts rather than creating fresh ones
  • Preserve existing values when adding new ones
  • Clean up resources properly using defer
  • Pass the enriched context to the next handler

Here’s an example of proper context handling:

func (i *ServerInterceptors) Tracer(ctx context.Context, info *TracerInfo, next goa.Endpoint) (any, error) {
    // Good: Derive new context, preserve existing values
    ctx, span := tracer.Start(ctx, info.Method())
    defer span.End()
    
    return next(ctx, info.RawPayload())
}

This approach ensures that:

  • Existing context values (like request IDs or auth info) are preserved
  • Resources are properly cleaned up even if errors occur
  • Downstream handlers have access to all necessary context information

Performance Best Practices

Since interceptors run on every request, their performance impact is multiplied across your service. Following these practices helps ensure your interceptors remain efficient at scale.

1. Minimize Allocations

Memory allocations can significantly impact performance, especially under high load. Use object pools, preallocate where possible, and avoid unnecessary allocations in your interceptors. Common techniques include:

  • Using sync.Pool for frequently allocated objects
  • Preallocating slices with known capacity
  • Reusing objects across requests
  • Avoiding unnecessary string concatenations

Here’s an example of efficient object management:

func (i *ServerInterceptors) Metrics(ctx context.Context, info *MetricsInfo, next goa.Endpoint) (any, error) {
    // Good: Reuse objects
    labels := i.getLabelsFromPool()
    defer i.putLabelsToPool(labels)
    
    // Bad: Create new objects each time
    // labels := make(map[string]string)
    
    return next(ctx, info.RawPayload())
}

This approach reduces garbage collection pressure and improves overall service performance, especially during high-traffic periods.

2. Use Appropriate Caching

Caching can dramatically improve performance, but it needs to be implemented carefully. Consider:

  • Cache duration and expiration strategy
  • Cache key design
  • Memory usage and eviction policies
  • Cache invalidation mechanisms
  • Concurrent access patterns

Here’s an example of effective cache usage:

func (i *ClientInterceptors) Cache(ctx context.Context, info *CacheInfo, next goa.Endpoint) (any, error) {
    p := info.Payload()
    
    // Good: Use appropriate cache duration
    if cached := i.cache.Get(p.CacheKey()); cached != nil {
        if !isExpired(cached, p.TTL()) {
            return cached, nil
        }
    }
    
    return next(ctx, info.RawPayload())
}

This pattern ensures efficient cache usage while maintaining data freshness and managing memory effectively.

3. Avoid Blocking Operations

Blocking operations in interceptors can create bottlenecks and reduce service throughput. Best practices include:

  • Moving slow operations to goroutines
  • Using buffered channels
  • Implementing timeouts
  • Handling errors asynchronously
  • Using non-blocking algorithms where possible

Here’s how to handle potentially blocking operations:

func (i *ServerInterceptors) AsyncLogger(ctx context.Context, info *AsyncLoggerInfo, next goa.Endpoint) (any, error) {
    // Good: Non-blocking logging
    go func() {
        if err := i.logAsync(info.Method(), info.Payload()); err != nil {
            i.errorHandler(err)
        }
    }()
    
    return next(ctx, info.RawPayload())
}

This approach prevents logging operations from blocking the request pipeline while ensuring all operations are still performed.

Conclusion

Goa interceptors provide a powerful way to handle cross-cutting concerns in your services while maintaining clean, maintainable code. Their design-first approach, combined with type-safe code generation, helps you build robust services that are easy to evolve over time. Key benefits include:

  • Type safety throughout your interceptor chain
  • Clear separation of concerns in your codebase
  • Compile-time validation of interceptor usage
  • Flexible composition of cross-cutting behaviors
  • High performance through generated code
  • Excellent testing support

By following these best practices and leveraging Goa’s interceptor capabilities, you can build services that are both maintainable and performant, while keeping your business logic clean and focused. Whether you’re implementing authentication, logging, metrics collection, or other cross-cutting concerns, interceptors provide a structured and type-safe way to achieve your goals.