Ten commandments of Go

Ten commandments of Go

As a full-time Go teacher and writer, I spend a lot of time working with students to help them write clearer, better, and more useful Go programs. I’ve found that a lot of the advice I give them can be reduced to a fairly small set of general principles, and here they are.

1. Thou shalt be boring

The Go community loves consensus. There’s a single canonical format for Go source code, enforced by gofmt, and similarly, whatever problem you’re solving, there’s usually a standard, Go-like way to do it. Sometimes it’s the standard way because it’s the best way, but often it’s simply the best way because it’s the standard way.

Resist the temptation to be creative, stylish, or (worst of all) clever. This is not Go-like. Go-like code is simple, boring, usually fairly verbose, and above all obvious.

Use the standard library testing package, not assertion-style test frameworks.

When in doubt, follow the principle of least surprise. Strive for glanceability. Be straightforward. Be simple. Be obvious. Be boring.

This is not to say there’s no scope for breathtaking elegance and style in software engineering; of course there is. But it’s at the design level, not individual lines of code. The code doesn’t matter; it should all be trivially replaceable. It’s the program that matters.

Another key to obviousness-oriented programming is avoiding magic. Explicit is better than implicit. Don’t use init functions: they’re magical. Don’t write packages that have side effects when they’re imported. Don’t embed struct types so that they magically acquire invisible methods. If people are asking you “How does this work?”, then you’ve done something wrong.

Go has a way of guiding you towards the right way to do things, by making all the other ways difficult or impossible. We may call this the ‘You Don’t Want to Do That’ rule: if something is hard to do in Go, it’s usually a strong signal that you shouldn’t be doing that thing. Instead of reaching for a third-party package to make the thing easier, try just doing it a different way.

2. Thou shalt test first

A common mistake in Go is to write some function (GetDataFromAPI, let’s say), and then to be extremely stuck about how to test it. It makes real API calls over the network, it prints things out to the terminal, it writes disk files. It’s a horrible, festering sump of untestability.

Don’t write that function. Instead, write a test: TestGetDataFromAPI. How can you write such a test? It will have to provide a local TLS test server for the function to call, so you’ll need a way to inject that dependency. It wants to write data to some io.Writer; you’ll need to inject a bytes.Buffer for this purpose. And so on.

Now, when you come to write GetDataFromAPI, everything is easy. All its dependencies are injected, so its business logic is completely decoupled from the way it talks and listens to the outside world.

The same goes for HTTP handlers. An HTTP handler’s only job is to parse data out of the request, pass it to some business logic function to compute the result, and format the result to the ResponseWriter. That hardly needs testing, so the majority of your tests will be on the business logic function itself, not the handler. We know HTTP works.

3: Thou shalt test behaviours, not functions

If you’re wondering how to test this function GetDataFromAPI without actually calling the API, then the answer is easy: “Don’t test that function”.

What you need to test is not some function, but some behaviours. For example, one might be “Given some user inputs, I can correctly assemble the URL to call the API with the required parameters.” Another might be “Given some JSON data returned by the API, I can correctly unmarshal it into some Go struct.”

When you frame the question that way, it’s much easier: you can imagine some functions called things like FormatURL and DecodeResponse, each of which takes some inputs and produces some outputs, and are trivially unit-testable. What they don’t do, for example, is make any HTTP calls.

Similarly, when you’re trying to implement behaviours like “The data can be persistently stored in and retrieved from a database”, you can break it down into smaller, more testable behaviours. For example, “Given a Go struct, I can correctly generate the SQL query which stores its contents into a Postgres table,” or “Given a sql.Rows object, I can correctly parse the results into a slice of Go structs”. No mock database needed; no real database needed!

4. Thou shalt not create paperwork

All programs involve some tedious, irreducible shuffling around of data at one point or another; we can lump all such activity under the heading of paperwork. The only question for the programmer is, which side of the API boundary is the paperwork on?

If it’s on the user side, that means the user has to write a lot of code to prepare the paperwork for your package, and then another lot of code to unpack the results into a useful format.

Instead, write zero-paperwork libraries, that can be invoked in a single line:

game.Run()

Don’t make the user call a constructor to get some object, then call Run() on that. That’s paperwork. Just make everything happen when they call Run() directly. If there are configurable settings, set sensible defaults, so that the user never has to even think about them unless they need to override the defaults for some reason. Functional options are a good pattern.

This is another good reason for writing your tests first, if you needed one: you will have to do all your own paperwork in order to use your package. If this proves to be awkward, verbose, and time-consuming, consider moving that paperwork inside the API boundary.

5. Thou shalt not kill the program

Your package doesn’t have the right to terminate the user’s program. Don’t call functions like os.Exit, log.Fatal or (worst of all) panic in your package (that is, outside of the main function). That’s not your decision to make. Instead, if you hit unrecoverable errors, return them to the caller (ultimately, main).

Why not panic? Because it forces anyone who wants to use your package to write recover code, whether or not the panic is ever actually triggered. For the same reason, you should never use third-party libraries which panic, because then you’ll need to recover them.

So you should never call panic explicitly, but what about implicitly? Any operation you do which could panic in some circumstances (indexing an empty slice, writing to a nil map, a failing type assertion) should check first to make sure that it’s okay, and return an error if it’s not.

6. Thou shalt not leak resources

The requirements for a program which is intended to run forever without crashes or errors are somewhat stricter than for one-shot command line tools. Think space probes, for example: an unexpected guidance system reboot at a critical moment could see a billion-dollar vehicle sailing off into the intergalactic void. For the software engineers responsible, this is likely to lead to a somewhat uncomfortable interview “without coffee”.

We’re not all writing software for space, but we should think like space engineers. Naturally, our programs should never crash (at worst, they should gracefully degrade, in the most informative way possible), but they also need to be sustainable. That means not leaking memory, goroutines, file handles, or any other scarce resource.

Whenever you have some leakable resource, the moment you know you’ve successfully obtained it, you should defer releasing it. The ability to guarantee cleanup, no matter how or when a function exits, is a gift from Go: use it.

Any time you start a goroutine, you should know how it ends. The same function that starts it should be responsible for stopping it. Use waitgroups, or errgroups, and always pass a context to a function that might need to be cancelled.

7: Thou shalt not restrict user choice

How do we write friendly, flexible, powerful, easy-to-use libraries? One way is to avoid unnecessarily restricting what users can do with the package. A common Gopherism is “Accept interfaces, return structs”. But why is that good advice?

Suppose you have a function that takes something like a *os.File, and writes data to it. It probably doesn’t really matter that the thing being written to is a file, specifically; it just needs to be a “thing-you-can-write-to” (this idea is expressed by the standard library interface io.Writer). There are many such things: network connections, HTTP response writers, bytes.Buffers, and so on.

By forcing the user to pass you a file, you’re restricting what they can do with your package. By accepting an interface (like io.Writer or fs.FS) instead, you’re opening up new possibilities, including types that haven’t been invented yet that nevertheless satisfy io.Writer, and can thus work with your code.

Why “return structs”? Well, suppose you return some interface type like Xer; that drastically restricts what users can do with that value (all they can do is call the X method on it). Even if they can in fact do what they need to do with the underlying concrete type, they will have to unwrap it first using a type assertion: paperwork, in other words.

Another way to avoid restricting user choice is not to use features that are only available in the current Go version. Instead, consider supporting at least the last two major Go versions: some people can’t upgrade immediately.

8: Thou shalt set boundaries

Let each software component be complete and competent within itself; don’t allow its internal concerns to leak out and bleed across its boundaries into other components. This goes double for the boundary with other people’s code.

For example, suppose your package calls some API. This API will have its own schema and its own vocabulary, reflecting its own concerns and its own domain language.

The boundary is the point where these make contact with your code: for example, the function that calls the API and parses its response. The job of this adapter is to connect the API’s schema with your own, encapsulating all the code that needs to know about both.

An adapter should do two things: it should transform the API’s data schema into the format that your code needs, and it also should ensure that the data is valid. When your program gets the data from the adapter, it can use it in a straightforward way, without having to worry about whether the data might be wrong, missing, or incomplete.

Another way to enforce good boundaries is to always check errors. If you don’t, invalid data could be leaking through.

9: Thou shalt not use interfaces internally

An interface value says “I don’t know what this thing really is, but maybe I know some things I can do with it.” That’s a super inconvenient kind of value to have in a Go program, because we can’t do anything not specified by the interface.

That goes double for the empty interface (any, also known as interface{}), because we know nothing about it whatsoever. By definition, therefore, if you have an any value, you will need to type-assert it to something concrete in order to use it at all.

It’s quite common to have to use them when dealing with arbitrary data (that is, data whose type or schema is unknown at run time), such as the ubiquitous map[string]interface{}. But we should, as soon as possible, use an adapter to transform this blob of ignorance into a useful Go value of some concrete type.

In particular, don’t use any values to simulate generics (Go has generics). Don’t write a function that takes some any value which can be one of a dozen concrete types, and type-switches it to find the appropriate action for that type. Instead, write a specific function for each concrete type: that’s much clearer, simpler, and easier to maintain.

Don’t create public interfaces specifically so that you can inject mocks in tests; this is a mistake. Creating an interface that real users have to implement before they can call your function violates the no-paperwork principle. Don’t write mocks in general; Go doesn’t lend itself to this style of testing. (When something is difficult in Go, that’s usually a sign that you’re doing the wrong thing.)

Beware of the context.Value trap: don’t store anything in this typeless black hole that could instead be passed as a parameter. Contexts are for cancellation.

10: Thou shalt not blindly follow commandments, but instead think for thyself

“Just tell us what the best practice is,” people say, as though there were a little secret book that held the right answer to any technical or organisational question. (There is, but keep that to yourself. We don’t want everyone becoming a consultant.)

Beware of any advice that seems to be telling you clearly, unambiguously, and simply what to do in a certain situation. It won’t apply to every situation, and everywhere it does apply it will need caveats, and nuances, and clarifications.

What everyone wants is advice they can apply without actually understanding it. But such advice is more dangerous than it is helpful: it’ll get you halfway out across the bridge, and then you’ll find that the bridge is made of paper, and it’s just started raining.


Many thanks to Bill Kennedy and Inanc Gumus for their helpful comments on this piece.

Review: 'Learning Go'

Review: 'Learning Go'

How to really learn Go

How to really learn Go