Under-engineer

published on February 5, 2023

The last several years of web development have been going into a direction of needlessly over-engineering even simplest tasks.

Maybe it’s time to get back to the basics, to use simple solutions for simple tasks. Let’s touch on how I think we should be approaching initial web product development.

Start with under-engineering

First, let’s set the requirements for a web solution:

  1. Be able to ship HTML, CSS and various static assets, like images, to the browsers
  2. Provide a decent web performance—for instance: 90+ on Google Lighthouse
  3. Keep developer experience in mind, fast iterations are important and developer experience is likely the strongest contributor to that

In order to achieve these, I am proposing a simple server side application, that is rendering the HTML on the server, has statical asset serving built in and is fairly simple from structure perspective.

In a more detailed manner, following the three dimensions above:

  1. Use a Go server app that uses as little dependencies as possible, none in this case.
  2. Use built in Go templating and its fast server-side rendering and the router’s ability to serve static assets to serve them to the browser. An optimisation opportunity here, if needed, is to adjust the static server headers to improve performance and perhaps do that differently later for development and production environments.
  3. Keep the code structure as simple as possible, no bells and whistles needed for the moment. The simpler it is to add features and ship them, the faster we can iterate on it. Go in itself already provides a lot of this but using features like embedding templates and static files into the binary will improve the simplicity of how this application will be shipped to production (application will easily run enywhere, as it will be a single executable file).

Let’s take a look at an exmple. You can find the entire repository on Github here and below we’ll take a look at the entry point of the server-side application.


      package main

      import (
        "embed"
        "html/template"
        "log"
        "net/http"

        "main/handlers"
        "main/middlewares"
      )

      //go:embed templates
      var embededTemplates embed.FS

      //go:embed public
      var embededPublic embed.FS

      func main() {
        // pre-parse templates, embedded in server binary
        handlers.Tmpl = template.Must(template.ParseFS(embededTemplates, "templates/layouts/*.html", "templates/partials/*.html"))

        // mux/router
        mux := http.NewServeMux()

        // public HTML route middleware stack
        publicHTMLStack := []middlewares.Middleware{
          middlewares.Logger,
        }

        // HTML routes
        mux.HandleFunc("/", middlewares.CompileMiddleware(handlers.Home, publicHTMLStack))

        // static routes, embedded in server binary
        mux.Handle("/public/", handlers.ServeEmbedded(http.FileServer(http.FS(embededPublic))))

        // HTTP server
        log.Fatal(http.ListenAndServe(":8000", mux))
      }
    

Allow me to describe a little what I have done.

Dependencies

I am using no external dependencies, everything is implemented using the Go standard library.

I have moved request handlers, logging middleware into their own files for having a bit simpler server file. Templates and static files are also naturally in their own separate folders and files.

The rest of the import is part of the standard Go release, its standard library.

Embedding static assets into the build binary

embededTemplates and embededPublic are the two variables where I’m holding references to the embedded files, templates and static files respectively.

Why is this needed? I think it’s simpler to ship a single binary to the production environment of your choice and being able to run it from anywhere on a filesystem, as opposed to shipping the entire directory and making sure it works from the folder you shipped it to on your production filesystem.

Main function

Here I pre-parse the Go templates, so that they are ready to use in request handlers.

The router initialisation comes next.

After that, I initialise my single middleware for the moment and later call it automatically in each request handler so that it logs my requests.

I only have two routes set up for the moment, the root route and static file serving one.

Then I start the server on port 8000. That’s it.

What’s next?

While this is simple Go server which I basically built for myself, covering several easy to use features, it has some missing points, like how do I ship it and where? That is coming in a next not post.

Till then, have a look at the repo, let me know what you think. To run it you could either install air and then do air .air.toml or you could just simply do go run server.go. Of these two, air will give you the benefit of watching for file changes and restarting the server for you.

Almost forgot: run go build and then ./main to try out how it runs as a single binary, maybe even move main around your filesystem to see if it works (it should).

Needless to say, for all of these to work, you need to have Go installed. I personally use asdf for that and other CLI tooling.