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:
.Run() game
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.Buffer
s, 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.