I began learning Go in 2019, and it swiftly became my most productive and preferred backend programming language. In this article, I’ll share some common patterns I’ve observed in Go applications and explain why I favor them over other options.
Errors
Instead of checking for nil
values or comparing empty structs, it’s preferable to return both a value and an error, then check the error. This approach is familiar to others and simplifies error handling throughout the codebase. It’s essential to always wrap errors, ensuring a clean and unambiguous error stack that traces back to the source of the error. Additionally, I prefer to explicitly return val, nil
even when the error is nil
for clarity.
// this is just OK
func parseInt(val string) (int, error) {
return strconv.Atoi(val)
}
// this is better - clean error stack
func parseInt(val string) (int, error) {
i, err := strconv.Atoi(val)
if err != nil {
return 0, fmt.Errorf("convert string to int: %w", err)
}
return i, nil
}
Some like to return an OK boolean instead of an error and I think that is just OK
at times but I’d almost always reccomend returning an error. I do not think you should use an OK boolean over an error if the code path has multiple failure cases.
// this is just OK
func lookup(val string) (int, boolean) {
return i, ok := map1[val]
}
// an error is better
func lookup(val string) (int, error) {
i, ok := map1[val]
if !ok {
return 0, errors.New("value does not exist in map1")
}
return i, nil
}
// errors are required to know the failure case
func lookup(val string) (int, error) {
i, ok := map1[val]
if !ok {
return 0, errors.New("value does not exist in map1")
}
i, ok = map2[val]
if !ok {
return 0, errors.New("value does not exist in map2")
}
return i, nil
}
If you are returning the same error in multiple places, or perhaps the same kind of error, you should consider declaring the error as a package variable. These are exported and usually the name of the variable take the shape of Err*
so it’s clear to consumers what it’s for. The advantage of this is so consumers of the package can use errors.Is
to check if the error matches a declared error they are antisipating.
var (
ErrNoKey = errors.New("key missing")
ErrNotValid = errors.New("value is not valid")
)
func lookup(val string) (int, error) {
if val == "" {
return 0, fmt.Errorf("length is 0: %w", ErrNotValid)
}
i, ok := map1[val]
if !ok {
return 0, fmt.Errorf("map1 lookup: %w", ErrNoKey)
}
return i, nil
}
func useLookup(val string) {
i, err := lookup(val)
if err != nil {
if errors.Is(err, ErrNoKey) {
fmt.Println("404 - %w", err)
return
}
fmt.Println("400 - %w", err)
return
}
fmt.Println("200 - %d", i)
}
JSON Parsing
I prefer using a combination of pointers and the omitempty
JSON tag for detecting optional field values when parsing JSON. This approach aligns well with frontends consuming the APIs I build, which often distinguish between undefined
and 0
for number fields on a payload. It helps bridge the gap between frontend and backend communication by ensuring consistency in JSON representation.
type MyStruct struct {
Name string `json:"name"`
MyOptionalValue *int `json:"myValue,omitempty"`
}
func printValue(data []byte) error {
var mystruct MyStruct
json.Unmarshal(data, &mystruct)
// first check if it was even sent
if mystruct.MyOptionalValue == nil {
return fmt.Errorf("value is missing")
}
// this means we have a value, but it's zero
// safe to dereference value since we checked it's not nil
if *mystruct.MyOptionalValue == 0 {
fmt.Println("value is zero")
return nil
}
// we have a non-zero value
fmt.Printf("value is %d\n", *&mystruct.MyOptionalValue)
return nil
}
When using omitempty
and returing JSON responses over HTTP/S you do not send the field if it’s nil
or another zero value. You can then create the respective interface in TypeScript. At my current company Coder we’ve created a tool that generates TypeScript interfaces from Go structs based on this behavior and it works great.
interface MyStruct {
name: string;
myValue?: number;
}
Main
The main
function should primarily handle signals and start the application, ultimately returning an error. It’s essential to confine exit functions like os.Exit()
or log.Fatal()
to the main
function only. All other functions should return errors, which not only delineates the application’s start and end points but also facilitates easier testing.
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sig
cancel()
}()
if err := run(ctx); err != nil {
log.Fatalf("error: %v", err)
}
}
func run(ctx context.Context) error {
// setup logging
// setup database
// setup api
// setup http server
<-ctx.Done()
return nil
}
Interfaces
As Rob Pike aptly stated, “The bigger the interface, the weaker the abstraction.” Interfaces should have a narrow scope and usually end in er
to denote their behavior. This pattern is widely used in the standard library for interfaces like io.Reader
, http.Handler
, http.RoundTripper
, and http.ResponseWriter
. Following the principle of “accept interfaces, return structs” ensures your codebase is easy to test and gives the consumer of your package the concrete type if needed.
// keep the interface as narrow as possible
type Reader interface {
Read(p []byte) (int, error)
}
type myReader struct {
r io.Reader
}
// implement the interface
func (m *myReader) Read(p []byte) (int, error) {
n, err := m.r.Read(p)
if err != nil {
return 0, fmt.Errorf("read from reader: %w", err)
}
return n, nil
}
// return a struct that implements the interface
func NewMyReader(r io.Reader) *myReader {
return &myReader{r: r}
}
// accept the interface
func useReader(r Reader, p []byte) error {
// use the reader
n, err := r.Read(p)
if err != nil {
fmt.Println("reading reader: %v", err)
}
fmt.Println("read %d bytes", n)
return nil
}
// use the struct that implements the interface
func doTheRead() {
r := NewMyReader(strings.NewReader("hello"))
p := make([]byte, 5)
useReader(r, p)
}
You can also validate if a struct implements an interface by using an interface assertion. This check is typically placed above the declaration of the struct and results in a compile-time error if the struct does not implement the interface.
var _ Reader = myReader{}
Directory Structure
Organizing commands in a cmd
directory is a common pattern I prefer, especially for main
packages. This practice makes it clear to consumers of the package where the entry points are located. I also favor keeping packages flat rather than nested or placed under a top-level pkg
directory. This preference simplifies codebase navigation and comprehension.
myapp
│
└───cmd
│ │
│ └───myapp
│ │ main.go
│
└───api
│ │ api.go
│
└───database
│ │ database.go
│
Conclusion
These patterns have served me well in my Go journey, and I hope they help you write cleaner, more maintainable code. I encourage you to experiment with these patterns and adapt them to your needs. Remember, the best code is the one that works for you and your team.