A Better Way To Write Servers in Go


In Go, the standard way of writing request handlers leads to verbose and repetitive code. In this post, I would like to share my preferred way of writing servers. With this way I can keep my handlers more concise and handle the errors in a central place. Another benefit is to handle dependencies in a more elegant way.

As a disclaimer, this is a combination of ideas from Mat Ryer’s post How I write HTTP services after eight years and the Custom Handler Example from go-chi library.

The Server Struct

The first concept is from Mat Ryer, which is having a server struct that we can attach request handlers.

I also store an instance of http.Server inside this struct, so that I can configure default timeouts.

For convenience, I also add a Start method to the server to make main.go cleaner.

server/server.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//...

type Server struct {
	Db      *sql.DB
	Config  config.Config
	httpSrv *http.Server
}

// New Initiates a new server.
func New(conf config.Config) *Server {
	s := &Server{
		Config: conf,
		httpSrv: &http.Server{
			Addr:           fmt.Sprintf(":%s", conf.Port),
			ReadTimeout:    conf.ReadTimeout,
			WriteTimeout:   conf.WriteTimeout,
			IdleTimeout:    conf.IdleTimeout,
			MaxHeaderBytes: conf.MaxHeaderBytes,
		},
	}
	s.SetupRoutes()
	return s
}

// Start starts the server.
func (s *Server) Start() error {
	log.Printf("Server starting at port %s\n", s.Config.Port)
	if err := s.httpSrv.ListenAndServe(); err != nil {
		return err
	}

	return nil
}

Request Handlers

To define request handlers, I first define a Handler type as the go-chi example suggests.

1
type Handler func(w http.ResponseWriter, r *http.Request) error

This handler type also implements http.Handler interface.

1
2
3
4
5
6
7
8
9
//...

func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if err := h(w, r); err != nil {
		// handle returned error here.
		w.WriteHeader(503)
		w.Write([]byte("bad"))
	}
}

Here is the full example from one of my projects. Note: I usually use a custom error type to simplify error handling in the server which I’m planning to explain in another post. Without that I would have to check for every error to send a meaningful error message to the client.

server/handler.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// ...

type Handler func(w http.ResponseWriter, r *http.Request) error

// Error describes an error that will be sent to the user.
type Error interface {
	Error() string
	Code() int
	Body() []byte
}

func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if err := h(w, r); err != nil {
		e, ok := err.(Error)
		logger := logs.GetLogEntry(r)
		if !ok {
			logger.Errorf("An unexpected error occurred: %v\n", err)
			w.WriteHeader(500)
			w.Write([]byte(`{"message": "Something went wrong."}`))
			return
		}
		logger.Error(e.Error())
		w.WriteHeader(e.Code())
		w.Write(e.Body())
	}
}

Another concept from Mat Ryer is to attach the handlers to the server struct. Here is a request handler example using this method.

server/status_route.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// ...

func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) error {
	if err := s.Db.Ping(); err != nil {
		return err
	}
	
  // check other stuff

	w.WriteHeader(200)
	return nil
}

Combining both ideas, I get two benefits:

  • I can just return an error from the handler without worrying about HTTP responses
  • I can access the dependencies from the server struct which is an elegant way to manage dependencies

Final step is to register these endpoints. I usually use go-chi as a router, but it can be done with others the same way. In the end, I assign the chi router to the Handler property of http.Server, which will be invoked as the default Mux.

server/routes.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
//...

// SetupRoutes sets up the routes and middlewares.
func (s *Server) SetupRoutes() {
	r := chi.NewRouter()

	r.Use(corsMiddleware(s.Config.ClientURLS)) // also register middlewares here
	// ...

  r.Method("GET", "/status", handler(s.handleStatus))

  // Auth routes
  r.Method("POST", "/register", handler(s.handleRegister))
	r.Method("POST", "/login", handler(s.handleLogin))
	r.Method("GET", "/me", handler(s.handleMe))
  // ...

  // Set the default handler to our router
  s.httpSrv.Handler = r
}

Then I call this function inside the server’s constructor like so:

server/server.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// ...

// New Initiates a new server.
func New(conf config.Config) *Server {
	s := &Server{
		Config: conf,
		httpSrv: &http.Server{
			Addr:           fmt.Sprintf(":%s", conf.Port),
			ReadTimeout:    conf.ReadTimeout,
			WriteTimeout:   conf.WriteTimeout,
			IdleTimeout:    conf.IdleTimeout,
			MaxHeaderBytes: conf.MaxHeaderBytes,
		},
	}
	s.SetupRoutes() <--
	return s
}

// ...

In conclusion, I find this approach much more readable than the traditional way of writing servers. The error handling is in a central location, request handlers are more concise, and I can see all the endpoints by looking at server/routes.go.

So, there you have it. I hope this was useful to you. Have fun building software.

You can find the complete example here


Related Pages

Leave a comment