January 18, 2023

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.

A small caveat before getting into this article. The context of this article is tightly coupled to building CRUD applications, specifically RESTful CRUD applications. If you are building other things, you may find some of the solutions I present here useful, but they may not be the best fit for your 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:

error-point-diagram

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:

  1. Modify the http.Handler interface to return errors
  2. 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.

error middleware

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.

  1. Utility functions (decode, validate, extract params, etc)
  2. Adapter Function Types
  3. 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 validation
  • decodeParams - decodes the URL params into a struct and performs validation
  • routeUUID - 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.

data validation middleware

Conclusion

In this article, we looked at the two main areas of boilerplate code in Go HTTP Handlers

  1. Data validation
  2. Error handling

We then examined two approaches for eliminating this boilerplate code:

  1. Using a custom HTTP handler interface and middleware to centralize error handling
  2. 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.


  1. In this context, error handling means writing a response to the client, logging to error, and stopping the handler chain. ↩︎