This guide covers best practices and guidelines for implementing interceptors in your Goa services.
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:
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.
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.
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.
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.
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:
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:
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.
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:
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.
Caching can dramatically improve performance, but it needs to be implemented carefully. Consider:
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.
Blocking operations in interceptors can create bottlenecks and reduce service throughput. Best practices include:
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.
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:
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.