Go the right way: the Zen of Go coding

Go the right way: the Zen of Go coding

Illustration courtesy of JetBrains

Ever wondered if there’s a software engineer, somewhere, who actually knows what they’re doing? Well, I finally found the one serene, omnicompetent guru who writes perfect code. I can’t disclose the location of her mountain hermitage, but I can share her ten mantras of Go excellence. Let’s meditate on them together.

Warning: This is not medical advice. Side effects may include higher code quality, reduced stress levels, and increased salary.

1. Write packages, not programs

What if I told you there was a library of over a million Go packages to do just about anything you could ever want? How much would it speed up your development if you could just import the package that solves your problem, instead of painfully re-inventing it from scratch every time?

The standard library is great, but the universal library of free, open-source software is Go’s biggest asset. Return the favour by writing not just programs, but packages that others can use too.

Apparently we can have nice things, so it’s only fair that if you develop some useful Go code yourself, you should contribute it back to the universal library, right? That means writing your code not merely as a one-off program for your own use case, but as a reliable, reusable, importable software component published with an open-source licence.

Writing packages, not programs, has some design implications, too. Keep your main function minimal: its only job is to process flags and arguments, figure out what the user asked for, and call into your “engine” package to do the actual work.

Your package shouldn’t print anything; instead, it should return the data. Leave it up to the consumer of your package to decide what to do with it. Similarly, don’t call panic or os.Exit in your package; return errors instead. Don’t recover panics from your dependencies either: this can mask problems your consumer needs to know about.

Keep your module structure simple: ideally, a single package. Complex trees of sub-packages make it difficult for users to find what they need, and you’ll give yourself import cycle headaches too. Instead, keep the structure flat, and limit your package to just two files: one for the implementation, and one for the tests.

2. Test everything

Speaking of tests, my Go guru assures me that they’re the only true path to saintly software. When I mentor new Go programmers myself, I sometimes sense their hearts sinking a bit at the mention of tests. There’s a perception that they’re like healthy exercise: undoubtedly a good habit, but one that we all struggle to maintain. “Write tests” is like “Go to the gym“, in other words: good advice, but hard to act on.

On the contrary, I think it’s more like saying “Eat chocolate!” That’s the kind of advice we all love to hear, and it doesn’t take much willpower to apply.

Tests are great, and when you approach them the right way they can be fun to write. They’re a useful design tool, because writing a test makes you the first user of your own function. If it’s awkwardly named, or has too many dependencies, or returns the wrong kind of result, you’ll notice right away. If the thing you’re testing is easy to write a test for, it’ll be easy to use in real programs—and if it’s not, fix it so it is.

Writing tests helps you dogfood your packages: awkward names and inconvenient APIs are obvious when you use them yourself.

Make your tests small and granular, focused on one small piece of logic—maybe a single method or function—and use your package’s public API instead of sneaking behind the curtain to look at implementation details. Those might change, whereas the behaviours your users care about shouldn’t. Check your test coverage to make sure you’ve tested all the code that matters (and it all matters).

3. Write code for reading

The best way to make sure you’re writing readable code is to read it. Put yourself in the mindset of someone who doesn’t already know what the code does, and go through it line by line. Is it easy to follow what’s happening? Is the purpose clear? Are the names of functions and variables well-chosen to convey what they represent? How much cognitive load are you asking them to lift?

Read other people’s programs too; as soon as you spot something you don’t understand, ask yourself why not. If the meaning of a certain line is not obvious, ask what change would make it obvious? Don’t rely on comments; these are often wrong, out of date, or merely unhelpful.

In your own code, use comments sparingly, and as a last resort. Focus on explaining why this code is here, not what it does—if that needs explanation, refactor the code to clarify it.

Good names make code read naturally. Design the architecture, name the components, document the details. Simplify wordy functions by moving low-level “paperwork” into smaller functions with informative names (createRequest, parseResponse). Keep each function at roughly the same level of abstraction.

Use consistent naming to maximise glanceability:

  • err for errors
  • data for arbitrary []bytes
  • buf for buffers
  • file for *os.File pointers
  • path for pathnames
  • i for index values
  • req for requests
  • resp for responses
  • ctx for contexts

Don’t be afraid to refactor and re-work your programs ruthlessly until they’re as clear and simple and focused as you can possibly make them. Check this by showing the code to someone else and asking them to talk you through it, line by line. Watching where they stumble will show you the speed-bumps in your code: keep refactoring until you’ve flattened them out.

4. Be safe by default

Use “always valid values” in your programs, and design types so that users can’t accidentally create values that won’t work. Make the zero value useful; this lets users create literals of your type with minimal paperwork. For example, bool fields will default to false, so make that make sense for your type.

Don’t create useless “config” structs; use fields on the object itself to configure its behaviour. If these fields can be invalid, don’t let users write them directly: instead, make them unexported, and provide validating methods to get and set their values.

If your object has sensible defaults, write a constructor method that returns a valid, default object ready to use. Add configuration using “WithX” methods:

widget := NewWidget().WithTimeout(time.Second)

Use named constants instead of magic values. http.StatusOK is self-explanatory; 200 isn’t. Define your own constants so IDEs like GoLand can auto-complete them, preventing typos. Use iota to auto-assign arbitrary values:

const (
    Planet = iota // 0
    Star          // 1
    Comet         // 2
    // ...
)

Prevent security holes by using os.Root instead of os.Open, eliminating path traversal attacks:

root, err := os.OpenRoot("/var/www/assets")
if err != nil {
  return err
}
defer root.Close()
file, err := root.Open("../../../etc/passwd")
// Error: 'openat ../../../etc/passwd: path escapes from parent'

Don’t require your program to run as root or in setuid mode; let users configure the minimal permissions and capabilities they need.

5. Wrap errors, don’t flatten

Don’t type-assert errors or compare error values directly with ==, define named “sentinel” values that users can match errors against:

var ErrOutOfCheese = errors.New("++?????++ Out of Cheese Error. Redo From Start.")

Don’t inspect the string values of errors to find out what they are; this is fragile. Instead, use errors.Is:

if errors.Is(err, ErrOutOfCheese) {

To add run-time information or context to an error, don’t flatten it into a string. Use the %w verb with fmt.Errorf to create a wrapped error:

return fmt.Errorf("GNU Terry Pratchett: %w", ErrOutOfCheese)

This way, errors.Is can still match the wrapped error against your sentinel value, even though it contains extra information.

6. Avoid mutable global state

Even if your package doesn’t create goroutines, your users might use it concurrently. Package-level variables can cause data races: reading a variable from one goroutine while writing it from another can crash your program.

Instead, use a sync.Mutex to prevent concurrent access, or allow access to the data only in a single “guard” goroutine that takes read or write requests via a channel.

Don’t use global objects like http.DefaultServeMux or DefaultClient; packages you import might invisibly change these objects, maliciously or otherwise.

Instead, create a new instance with http.NewServeMux (for example) so that you own it exclusively, and then configure it how you want.

7. Use (structured) concurrency sparingly

Concurrent programming is a minefield: it’s easy to trigger crashes or race conditions. Don’t introduce concurrency to a program unless it’s unavoidable.

When you do use goroutines and channels, keep them strictly confined: once they escape the scope where they’re created, it’s hard to follow the flow of control. “Global” goroutines, like global variables, can lead to hard-to-find bugs.

Make sure any goroutines you create will terminate before the enclosing function exits, using a context or waitgroup:

var wg sync.WaitGroup
wg.Go(task1)
wg.Go(task2)
wg.Wait()

The Wait call ensures that both tasks have completed before we move on, making control flow easy to understand, and preventing resource leaks.

Use errgroups to catch the first error from a number of parallel tasks, and terminate all the others:

var eg errgroup.Group
eg.Go(task1)
eg.Go(task2)
err := eg.Wait()
if err != nil {
    fmt.Printf("error %v: all other tasks cancelled", err)
} else {
    fmt.Println("all tasks completed successfully")
}

When you take a channel as the parameter to a function, take either its send or receive aspect, but not both. This prevents a common kind of deadlock where the function tries to send and receive on the same channel concurrently.

func produce(ch chan<- Event) {
    // can send on `ch` but not receive
}

func consume(ch <-chan Event) {
    // can receive on `ch` but not send
}

8. Decouple code from environment

We all know that good software avoids excessive coupling between packages or components, but many programs are too tightly coupled to the operating system or environment where they run.

Don’t depend on OS or environment-specific details. Don’t use os.Getenv or os.Args deep in your package: only main should access environment variables or command-line arguments.

Instead of taking choices away from users of your package, let them configure it however they want. Be agnostic about how you’re configured. Let users decide whether they want to inject settings via the environment, flags, config files, API calls, or some other way.

Single binaries are easier for users to install, update, and manage; don’t distribute config files. If necessary, create your config file at run time using defaults.

Use go:embed to bundle static data, such as images or certificates, into your binary:

import _ "embed"

//go:embed hello.txt
var s string
fmt.Println(s) // `s` now has the contents of 'hello.txt'

Use xdg instead of hard-coding paths. Don’t assume $HOME exists. Don’t assume any disk storage exists, or is writable.

Go is popular in constrained environments, so be frugal with memory. Don’t read all your data at once; handle one chunk at a time, re-using the same buffer. This will keep your memory footprint small and reduce garbage collection cycles.

9. Design for errors

Always check errors, and handle them if possible, retrying where appropriate. Report run-time errors to the user and exit gracefully, reserving panic for internal program errors. Don’t ignore errors using _: this leads to obscure bugs. Assume that anything that can error will error, and handle it appropriately.

Retry on transient errors if that makes sense. Don’t let the program panic on predictable run-time errors, such as failing to read a file: a stack trace won’t help the user figure out what’s wrong. Reserve panic for unrecoverable internal program bugs only.

Don’t make the user rely on documentation to be able to run your program. Make their first-run experience a pleasant one: instead of nasty error messages, show usage hints and examples. Don’t try to interact with users by prompting them via the console or dialog boxes. Let them automate your program and run it headlessly, using flags or config files to customise its behaviour.

10. Log only actionable information

If you use logging, don’t spam the user with pointless info messages: if nothing needs saying, say nothing. Logorrhea is irritating, so don’t spam the user with trivia.

If you log at all, log only actionable errors that someone needs to fix. Don’t use fancy loggers, just print to the console, and let users redirect that output where they need it. Never log secrets or personal data.

Use slog to generate machine-readable JSON:

logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Error("oh no", "user", os.Getenv("USER"))
// Output:
// {"time":"...","level":"ERROR","msg":"oh no",
// "user":"bitfield"}

Logging is not for request-scoped troubleshooting: use tracing instead. Don’t log performance data or statistics: that’s what metrics are for.

Guru meditation

Let’s be real: no program is perfect, and the same applies to programmers. We won’t always achieve all the goals set out here, or not at first. It’s more important to get the program working first, get it in front of users early, and only once it does what it’s supposed to should we worry about making the code nicer.

My mountain-dwelling guru also says, “Make it work first, then make it right. Draft a quick walking skeleton, using shameless green, and try it out on real users. Solve their problems first, and only then focus on code quality.”

Equally, though, it’s a mistake not to care about code quality. You never know whether your Go package will be used in something like a medical X-ray machine or a spacecraft control system. All software is critical to somebody.

Software takes more time to maintain than it does to write, so invest an extra 10% effort in refactoring, simplifying, and improving code while you still remember how it works.

Rust vs Go: ¿cuál elegir?

Rust vs Go: ¿cuál elegir?

0