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