Disclaimer: This is my personal opinion. You don’t have to agree or disagree with it. I’d be happy if you learned something from my rant in the end.
All Go programmers learn early on what the shape of the standard HTTP handler function looks like:
func hello(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello, World!"))
}
It is simple and easy to remember. At the same time, it is low-level enough not to limit or obscure the developer in any possible way—typical Go.
The Problem #
Let’s build a slightly more complex example where the flow has to go through a couple of potentially error-prone operations before returning to the client.
func aMoreAdvancedHandler(w http.ResponseWriter, r *http.Request) {
resOne, err := potentiallyDangerousOpOne()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
resTwo, err := potentiallyDangerousOpTwo(resOne)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
resThree, err := potentiallyDangerousOpThree(resTwo)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write([]byte("Success: " + resThree))
}
And here comes the big problem in my view. You aren’t actually returning anything. Regardless of the outcome, you simply write it to the response writer. This can lead to unnoticeable and difficult to trace bugs like in the example above:
resTwo, err := potentiallyDangerousOpTwo(resOne)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
// Did we forget to return here?
}
While we acknowledged the error, we didn’t return it immediately after it. By definition, we aren’t forced to, so the compiler wouldn’t complain either. It might lead to a situation in which, despite the error, we still tell the client that everything went alright—either that or some other form of inconsistent behavior.
The missing empty return is sometimes referred to as a “naked” return. It appears in all C- Style languages, so it is not a problem with Go’s syntax, but it is hard for me to justify using naked returns. Whenever I can, I will make the junction always return a value, or if it is high enough in the stack (e. g. main), cause it to exit the entire application instead.
Unfortunately, this isn’t how everyone else thinks, which is why I am raising this point. Numerous HTTP frameworks for Go exist, but one of the most popular ones - Gin seems to be following the same style.
func (h *Handlers) getAlbumGin(c gin.Context) {
id, ok := c.Params.Get("id")
if !ok {
c.Error(fmt.Errorf("get album: missing id"))
return
}
album, err := h.db.FetchAlbum(id)
if err != nil {
c.Error(fmt.Errorf("fetch album with id %s: %w", id, err))
// Same thing here. Oh, man 🤦♂️
}
cover, err := h.db.FetchAlbumCover(id)
if err != nil {
c.Error(fmt.Errorf("fetch cover for album with id %s: %w", id, err))
return
}
c.JSON(http.StatusOK, gin.H{"album": album, "cover": cover})
}
The Solution #
For comparison, here is how my favorite one - Echo, approaches the same subject.
func (h *Handlers) getAlbum(c echo.Context) error {
// NOTE the explicit err return value here ⬆
id := c.QueryParam("id")
if id == "" {
return fmt.Errorf("get album: missing id")
}
album, err := h.db.FetchAlbum(id)
if err != nil {
return fmt.Errorf("fetch album with id %s: %w", id, err)
}
cover, err := h.db.FetchAlbumCover(id)
if err != nil {
return fmt.Errorf("fetch cover for album with id %s: %w", id, err)
}
return c.JSON(http.StatusOK, echo.Map{"album": album, "cover": cover})
}
By enforcing the explicit return of an error, Echo minimizes the chance of accidentally forgetting to return it. Unless developers explicitly ignore the error, they will have to do something with it; otherwise, the compiler won’t compile because of an unused variable. The easiest thing is returning it, which will signal Echo to write the appropriate message and status code to the HTTP response writer.
Can we do the same without a framework? Absolutely, it is pretty easy to do, but as you can see in the Echo example, it will require a slight change of the standard HTTP handler function. It will require us to abstract the writer away and instead return values the way a regular function would.
type MyResponseWriter struct {
w http.ResponseWriter
}
func (rw *MyResponseWriter) WriteString(str string) error {
_, err := rw.w.Write([]byte(str))
return err
}
// You can potentiall extend MyResponseWriter with indefinite helper methods
type MyHandlerFunc func(w *MyResponseWriter, r *http.Request) error
// This is how a new handler would look like
func someHandler(w *MyResponseWriter, r *http.Request) error {
res, err := potentiallyDangerousOp()
if err != nil {
return fmt.Errorf("someHandler: %w", err)
}
return w.WriteString(res)
}
// And this is what we will use to bridge the gap with the existing http package
func MakeHTTPHandler(fn MyHandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rw := MyResponseWriter{w: w}
err := fn(&rw, r)
if err != nil {
// Note that this is the only place where we actually use
// the original writer's error reporting capabilities
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
Related Reading #
Have something to say? Join the discussion below 👇
Want to explore instead? Fly with the time capsule 🛸
You may also find these interesting
Epic Rap Battles of Programming: Java vs. Go
Two programming language giants appear on stage for a massive rap battle. Who will win?
Consistent > Idiomatic
As a software engineer, I’ve learned that consistency in code is crucial for the long-term success of a project, even when it means deviating from idiomatic principles.
Matt Mueller: Building Modern Web Applications Faster With Bud
Bud is a brand-new Web framework. It takes the best of Go and JavaScript to help developers focus on solving actual problems without worrying about type safety, performance, or deployment.