Go Modular Routers

Years ago when I started using Go, I immediately experienced a design issue that was particularly frustrating - no means by which a Go package could be extended outside of its defined scope. In other languages this would be called "monkey patching". In many early Go projects I wrote, I ended up with large "kitchen sink" packages that were unweildy.

A domain where this issue is common is web application design. Web handlers have the same scoping rules as any Go function or method, but because they have a fixed signature, accessing state is typically done by making the handlers methods of an application instance. This is fine for small applications but as applications grow, the lines of code in a single package can get out of control quickly.

I've established a pattern I find particularly useful for achieving the modularity I want. Let's walk through a simplified example which uses Chi.

Our example has a reference to a database which is the core state we want to share among handlers:

package conf

import "database/sql"
    
type State struct {
        DB *sql.DB
}
  

Now let's establish a Server type and some interfaces that will have access to the State:

package handlers

import (
        "github.com/go-chi/chi"
        "conf" // Just pretend this magically resolves.
        "errors"
)

// The contract for modularity is the WithRouter function,
// which will intergrate a new router with rtr.
type API interface {
	WithRouter(rtr chi.Router) (func(chi.Router), error)
}

type Server struct {
	conf.State
}

func NewServer(c conf.State) (*Server, error) {

        // ...
    
        return &Server{State: c}, nil
}
  

Now let's create a new package docs as an example individual routing module. This is a self-contained router that assumes the root routing path "/". Routes are connected to the router rtr provided as a parameter.

package docs

import (
        "github.com/go-chi/chi"
        "conf"
        "errors"
        "handlers"
        "net/http"
)

type Server handlers.Server

func New(c conf.State) (*Server, error) {

	hs, err := handlers.NewServer(c)
	if err != nil {
		return nil, err
	}

	srv := Server(*hs)

	return &srv, nil
}

// WithRouter implements the handlers.API interface.
func (s *Server) WithRouter(rtr chi.Router) (func(chi.Router), error) {

	if rtr == nil {
		return nil, errors.New("nil rtr")
	}

	return func(rtr chi.Router) {
		rtr.Get("/", s.GetHello)
	}, nil
}

func (s *Server) GetHello(w http.ResponseWriter, req *http.Request) {

        // Maybe here we read something from s.DB...
    
	w.Header().Set("Content-type", "text/plain")
	_, err := w.Write([]byte("Hello"))
	if err != nil {
		// ...
	}
}

  

This router is unit-testable on its own, so any callers that wish to integrate it in to another application can assume it is internally consistent.

Now we can integrate this into a larger application that might use many such modular routers:

package app

import (
        "conf"
        "docs"
        "errors"
        "handlers"
        "net/http"
)

type App handlers.Server

func New(c conf.State) (*App, error) {

	hs, err := handlers.NewServer(c)
	if err != nil {
		return nil, err
	}

	app := App(*hs)

	return &app, nil
}

// WithRouter implements the handlers.API interface.
func (a *App) WithRouter(rtr chi.Router) (func(chi.Router), error) {

	if rtr == nil {
		return nil, errors.New("nil rtr")
	}

	docsSrv, err := docs.New(a.State)
	if err != nil {
		return nil, err
	}
        _ = handlers.API(docsSrv) // Checks interface satisfaction.
    
	docsRtrFunc, err := docsSrv.WithRouter(rtr)
	if err != nil {
		return nil, err
	}
    
	return func(rtr chi.Router) {

                // Make docs now bind to route /users/docs.

                rtr.Route("/users", func(rtr chi.Router) {

                        // /users/docs
			rtr.Route(api.DocsRoute, docsRtrFunc)
		})

	}, nil
}

// Run will put it all together and run the app.
func Run() {

        app, err := New(conf.State{DB: someDB})

        if err != nil {
            panic()
        }

        rtr := chi.NewRouter()
        rtrFn, err := app.WithRouter(rtr)

        if err != nil {
            panic()
        }

        rtr.Route("/", rtrFn)

        srv := &http.Server{Addr: ":8080", Handler: rtr}

        go func() {
		if err := srv.ListenAndServe(); err != nil {
			tm.G.Log.Fatal("server", zap.Error(err))
		}
	}()
}
    
  

So how does this all work? It is true that we can't just add new functionality to the app package from outside its scope, but we can call functions to other packages. In Go, a function can return an unevaluated function as a return value. Fortunately the design of Chi utilizes this feature for mounting routers, allowing us to return router-returning functions to app. Specifically, in this case, we instantiate an instance of a type from another package with the state that is shared throughout app, and the function returned as a modular router can therefore access it.

last update 2019-09-22