Reduce Boilerplate in Go Http Handlers with Go Generics
Generics are Good
As a Go developer, I am no stranger to boilerplate code. if err != nil
has become some what of a comforting pattern when working with applications. However, I don’t share this same feeling when working with web API’s and rewriting seemingly the same set of basic boilerplate code for every endpoint.
In this article, I’m going to walk through how I’ve been able to reduce this boilerplate code and ensure that my handlers have consistent behavior across my application.
The Problem
Before we dive into a solution, I first want to outline and define the kind of code that I’m writing over and over again and that I think you may be writing as well.
When a web request comes into an application, it enters a chain of calls that eventually ends up in a final handler function that is responsible for the action of the request. Nearly every single handler function has the same basic behavior.
The follow diagram shows the basic flow of a request through a web application:
As a request travels through the various layers of middleware, there are two types of functions that are called relating to errors:
- Error Producers
- Error Handlers
As noted in the diagram, in a traditional HTTP handler, chain errors are handled in every instance they are produced. This means that every middleware layer is responsible for handling errors1. Once we’ve reached our handler, there remains a few more steps before we can perform business logic:
- Extracting path parameters and validating them
- Extracting query parameters and validating them
- Decoding JSON request body and validating them
You may not need all of these on all endpoint, but you will likely need some combination of them. Any of these are capable of returning an error and should be handled.
That usually looks something like this
func HandlerFunc() {
// Extract path parameters and validates it's a UUID (or whatever)
userID, err := extractUserID(r)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
// OPTIONAL: LOG ERROR
return
}
// Decode request body
var update UpdateUser
err = json.NewDecoder(r.Body).Decode(&update)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
// OPTIONAL: LOG ERROR
return
}
// Validate request body
err = validate.Struct(update)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
// OPTIONAL: LOG ERROR
return
}
//
// ACTUAL BUSINESS LOGIC
//
// Write response
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(struct {
ID int
Name string
}{
ID: userID,
Name: update.Name,
})
}
That’s around 20-30 lines of code for a single handler function shared across all of your endpoints in addition to the error handling in each middleware.
Requiring developers to write this code over and over again can result in a multitude of errors and inconsistencies across your application. Some of the most common issues I’ve seen are:
- Inconsistent HTTP responses across endpoints, especially as a project grows
- Inconsistent Error handling, do we log errors in handlers, who logs errors? what status code do we return?
- Inconsistent data formatting, do we wrap list responses in an object?, do we return a single object or an array?
- Inconsistent data validation
The biggest key here is consistency. Every API endpoint in your application should have consistent and predictable behavior. This is essential for ensuring that your API is easy to use and maintain.
In the next section, we’ll look at two strategies for reducing boilerplate code:
- Modify the
http.Handler
interface to return errors - Using
HttpAdapterFunctions
to reduce boilerplate code
The Solutions
Modifying the http.Handler
Interface
The http.Handler
interface is a standard interface for defining HTTP handlers in Go. It only requires that the ServeHTTP method be implemented.
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
While the http.Handler
interface is simple and effective, it does not allow for centralized error handling. To address this, we can introduce a custom HttpErrorHandler interface that extends the http.Handler
interface but returns errors. This allows us to centralize error handling and reduces the amount of error handling code needed in individual handlers.
type HttpErrorHandler interface {
ServeHTTP(ResponseWriter, *Request) error
}
To enable compatibility with existing HTTP handlers, middlewares, and routers, we can create an error middleware that adapts the HttpErrorHandler interface to the http.Handler interface. This error middleware can handle errors in a centralized place, allowing us to maintain a consistent approach to error handling across our application.
The following is an adapted version of the ErrorsMiddleware that I use in a few of my projects, it uses a few external packages so you can look at this as more of ‘sudo code’ than a complete example. The main idea is that by using a custom HttpErrorHandler interface, we can centralize error handling.
func ErrorsMiddleware(log zerolog.Logger) customMiddleware {
return func(h HttpErrorHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Call the handler function
err := h.ServeHTTP(w, r)
// Check For Errors
if err != nil {
var resp server.ErrorResponse
code := http.StatusInternalServerError
// Log the error
log.Err(err).
Str("trace_id", getTraceID(r.Context())).
Msg("ERROR occurred")
// Check against known error types for a more specific error message
switch {
case validate.IsUnauthorizedError(err):
// handle UnauthorizedError
case validate.IsInvalidRouteKeyError(err):
// handle InvalidRouteKeyError
case validate.IsFieldError(err):
// handle FieldError
case validate.IsRequestError(err):
// handle FieldError
default:
// in the case of a default error we return a generic error message
// optionally you may want to do some deeper logging to help debug the issue
resp.Error = "Unknown Error"
code = http.StatusInternalServerError
}
// respondFunc is a function that writes the response to the ResponseWriter
if err := respondFunc(w, code, resp); err != nil {
// handle write error
}
}
}
}
}
By using the HttpErrorHandler interface and the error middleware, we can eliminate code in our error producers to return errors up the stack until it is handled by the error middleware. This significantly reduces the amount of boilerplate code needed in our handlers.
Opting Out of Defined Errors
You may find that for one or two endpoints you may need a more fine tuned control over your error handling and response. In this case, you can use a custom error type that contains detailed response information and use that in your middleware to bypass the default error handling.
type CustomError struct {
Code int
Message string
Data interface{}
}
func (e *CustomError) Error() string {
return e.Message
}
And then adjust your switch statement to account for this custom error type:
customErr := &CustomError{}
switch {
case errors.As(err, &customErr):
err := respondFunc(w, customErr.Code, customErr.Data)
if err != nil {
// handle write error
}
return
default:
// in the case of a default error we return a generic error message
// optionally you may want to do some deeper logging to help debug the issue
resp.Error = "Unknown Error"
code = http.StatusInternalServerError
}
Using Http Adapter Functions
Http Adapter Functions are a way to encapsulate boilerplate code and eliminate the need for repetitive code in our HTTP handlers. Http Adapters are functions that take a portion of boilerplate code and pass the result to the next adapter. The specific type of adapter you’ll need are going to be very dependent on your application, but we’ll look at a few examples that are designed to be reused for common CRUD operations.
The adapters package we’re going to write has 3 main parts.
- Utility functions (decode, validate, extract params, etc)
- Adapter Function Types
- Adapters
Utility Functions
We’re going to use three main functions. We won’t show their implementations, but, if you’ve written any HTTP handlers, you’ll be familiar with them.
decode
- decodes the request body into a struct and performs validationdecodeParams
- decodes the URL params into a struct and performs validationrouteUUID
- extracts a UUID from the URL params and returns it or an error if it’s not a valid UUID
Adapter Function Types
To start, we’ll define 2 generic function types that outline the type of functions we want to to write.
type AdapterFunc[T any, Y any] func(context.Context, T) (Y, error)
The first type is an adapter function for a simple requests and a generic type T
where T
will represent either the JSON body or the URL params depending on the Adapter used. Y
is the type that will be returned by the adapter function and subsequently passed to the responder.
type IDFunc[T any, Y any] func(context.Context, uuid.UUID, T) (Y, error)
The second type is an adapter function for requests that require a UUID. This is useful for endpoints that require a UUID in the URL params. The IDFunc
is similar to the AdapterFunc
except that it takes a UUID as the first argument. T
and Y
are the same as the AdapterFunc
.
Now that we have our function types defined, we can start writing our adapters.
Query Adapters
The query adapter is designed to be used for endpoints that only accept URL parameters and return a JSON response. The query adapter will
- decode the URL params
- call the provided generic function,
- then respond with the result or return the error.
All while checking for errors and returning the appropriate HTTP status code.
func Query[T any, Y any](f AdapterFunc[T, Y], ok int) server.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
q, err := decodeQuery[T](r) // extract and validate query params
if err != nil {
return err
}
res, err := f(r.Context(), q) // call the provided function
if err != nil {
return err
}
return server.Respond(w, ok, res) // respond with the result
}
}
func QueryID[T any, Y any](param string, f IDFunc[T, Y], ok int) server.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
ID, err := routeUUID(r, param) // extract and validate the UUID
if err != nil {
return err
}
q, err := decodeQuery[T](r) // extract and validate query params
if err != nil {
return err
}
res, err := f(r.Context(), ID, q) // call the provided function
if err != nil {
return err
}
return server.Respond(w, ok, res) // respond with the result
}
}
Action Adapters
The action adapter is designed to be used for endpoints that accept a JSON body and return a JSON response. The action adapter will:
- decode the request body
- call the provided function
- and respond with the result or return the error.
All while checking and returning any errors that occur.
func Action[T any, Y any](f AdapterFunc[T, Y], ok int) server.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
v, err := decode[T](r) // extract and validate the request body
if err != nil {
return err
}
res, err := f(r.Context(), v) // call the provided function
if err != nil {
return err
}
return server.Respond(w, ok, res) // respond with the result
}
}
func ActionID[T any, Y any](param string, f IDFunc[T, Y], ok int) server.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
ID, err := routeUUID(r, param) // extract and validate the UUID
if err != nil {
return err
}
v, err := decode[T](r) // extract and validate the request body
if err != nil {
return err
}
res, err := f(r.Context(), ID, v) // call the provided function
if err != nil {
return err
}
return server.Respond(w, ok, res) // respond with the result
}
}
Usage
Now that we have our adapters defined, we can use them to write our HTTP handlers.
In some cases you may be able to directly use the adapter function from your Service/Repository layer
func (h *Handler) GetUsers(w http.ResponseWriter, r *http.Request) error {
return adapters.Query(h.svc.GetUsers)(w, r)
}
In other cases you may need to wrap the function call in an adapter and do some additional work before calling the Service/Repository layer
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) error {
f := func(ctx context.Context, ID uuid.UUID, q *query.User) (*model.User, error) {
group := auth.GroupFromContext(ctx)
return h.svc.GetUser(ctx, group, ID, q)
}
return adapters.QueryID("id", f, http.StatusOK)(w, r)
}
What we’ve done is created a data validation middleware-like function that provides type-safe validation for common HTTP applications. Paired with the central error handling middleware pattern, we can significantly streamline our HTTP handlers and reduce the amount of code we need to write in our application.
Conclusion
In this article, we looked at the two main areas of boilerplate code in Go HTTP Handlers
- Data validation
- Error handling
We then examined two approaches for eliminating this boilerplate code:
- Using a custom HTTP handler interface and middleware to centralize error handling
- Using adapters to encapsulate boilerplate code and centralize data validation
These approaches, paired together, can significantly reduce the amount of code we need to write in our HTTP handlers. This allows us to focus on the business logic of our application and not the boilerplate code.
In this context, error handling means writing a response to the client, logging to error, and stopping the handler chain. ↩︎