Exception Filters: Custom Error Responses in Go APIs
Exception filters catch errors returned by handlers (or anything in the chain below them) and format the HTTP error response. They are NestGo’s take on NestJS exception filters — the Golang way to give one route, group, or the whole API its own error shape without touching handler code.
Handlers just return err; filters decide what the client sees.
The ExceptionFilter interface
// ExceptionFilter handles errors for specific routes or controllers.
// CanHandle returns true if this filter should handle the error.
// Handle formats and sends the error response.
type ExceptionFilter interface {
CanHandle(err error) bool
Handle(c Context, err error)
}
Two methods, two jobs:
| Method | Role |
|---|---|
CanHandle(err) bool | Claim the error — return true if this filter formats this kind of error |
Handle(c, err) | Write the response (e.g. c.JSON(...)) — no return value; the error is considered handled |
This mirrors NestJS’s @Catch(SomeException): CanHandle is the catch condition, Handle is the catch body.
UseFilters: attach filters to routes
UseFilters converts filters into a MiddlewareFunc:
func UseFilters(filters ...ExceptionFilter) MiddlewareFunc
When the wrapped chain returns an error, the first filter whose CanHandle returns true handles it. If no filter matches, the error propagates up — ultimately to the global Config.ErrorHandler (by default core.DefaultErrorHandler, which renders *HTTPError as JSON).
Apply at any level:
// Global — every route:
config.GlobalFilters = []core.ExceptionFilter{apiFilter}
// Group / controller level:
r.Group("/api", core.UseFilters(apiFilter))
// Single route:
r.POST("/payments", handler, core.UseFilters(paymentErrorFilter))
Writing a custom exception filter
A filter that catches *core.HTTPError and emits a flat error shape:
type APIErrorFilter struct{}
func (f *APIErrorFilter) CanHandle(err error) bool {
_, ok := err.(*core.HTTPError)
return ok
}
func (f *APIErrorFilter) Handle(c core.Context, err error) {
httpErr := err.(*core.HTTPError)
_ = c.JSON(httpErr.Code, map[string]any{"error": httpErr.Message})
}
r.Group("/api", core.UseFilters(&APIErrorFilter{}))
Filters can also target domain errors, translating them at the boundary so services stay HTTP-free:
type NotFoundFilter struct{}
func (f *NotFoundFilter) CanHandle(err error) bool {
return errors.Is(err, ErrRecordNotFound)
}
func (f *NotFoundFilter) Handle(c core.Context, err error) {
_ = c.JSON(404, map[string]any{
"error": "not_found",
"message": "the requested resource does not exist",
})
}
List specific filters before general ones — matching stops at the first CanHandle that returns true:
r.Group("/api", core.UseFilters(&NotFoundFilter{}, &APIErrorFilter{}))
HTTPErrorFilter: quick custom formatting for HTTPError
For the common case — “reformat all *core.HTTPError responses” — NestGo ships HTTPErrorFilter, which needs only a formatter function:
type HTTPErrorFilter struct {
Formatter func(c Context, httpErr *HTTPError)
}
filter := &core.HTTPErrorFilter{
Formatter: func(c core.Context, httpErr *core.HTTPError) {
_ = c.JSON(httpErr.Code, map[string]any{
"status": "error",
"code": httpErr.Code,
"message": httpErr.Message,
})
},
}
r.Group("/api", core.UseFilters(filter))
*core.HTTPError carries Code, Message, optional Details, and structured Errors []FieldError for validation failures — everything a formatter needs. Errors created with core.ErrBadRequest, core.ErrNotFound, core.NewValidationError, etc. all flow through it.
For app-wide formats you can skip filters entirely and replace the global handler — e.g. RFC 7807 output with config.ErrorHandler = core.ProblemDetailErrorHandler. Use filters when different routes need different error shapes.
Execution order: where filters sit in the chain
NestGo enforces the NestJS execution order. Filters are outermost, so they catch errors from every layer below — guards, pipes, interceptors, and the handler itself:
Request
→ Filters (outermost — catch everything below)
→ Guards (allow/deny)
→ Pipes (transform/validate)
→ Interceptors (before/after logic)
→ Handler
That is why a guard returning core.ErrUnauthorized(...) or a pipe returning core.ErrBadRequest(...) is formatted by the same filters as a handler error.
RouteOptions and ApplyRouteOptions: correct order automatically
Rather than hand-ordering middleware, bundle everything in RouteOptions and let ApplyRouteOptions produce the middleware slice in the correct order:
// RouteOptions configures guards, interceptors, pipes, and filters
// for a route or group.
type RouteOptions struct {
Guards []core.Guard
Pipes []core.MiddlewareFunc // transform/validate before interceptors
Interceptors []core.Interceptor
Filters []core.ExceptionFilter
}
func ApplyRouteOptions(opts RouteOptions) []core.MiddlewareFunc
opts := core.ApplyRouteOptions(core.RouteOptions{
Guards: []core.Guard{authGuard},
Interceptors: []core.Interceptor{logInterceptor},
Filters: []core.ExceptionFilter{&APIErrorFilter{}},
})
r.Group("/users", opts...)
Internally, ApplyRouteOptions appends UseFilters(...) first, then UseGuards(...), then the pipe middleware, then UseInterceptors(...) — producing exactly the Filters → Guards → Pipes → Interceptors → Handler chain above. The same order applies to the global config fields (GlobalFilters, GlobalGuards, GlobalPipes, GlobalInterceptors).
Filters vs the global ErrorHandler
| Exception filters | Config.ErrorHandler | |
|---|---|---|
| Scope | Per route, group, or via GlobalFilters | Whole application (single fallback) |
| Selection | First filter with CanHandle(err) == true | Receives every error no filter handled |
| Use for | Different error shapes per API area, domain-error translation | The app-wide default format (JSON or RFC 7807) |
Source: github.com/ashrafAli23/nestgo (core/filter.go, core/errors.go, core/route.go).
Next steps
- Guards — authentication and route authorization in the chain filters wrap
- Pipes — validation errors that filters format
- Interceptors — before/after logic closest to the handler