← Code Like Go

Habits That Travel

part 3 · habits you steal from Go and carry everywhere

⚠ warning: slop currently. Work in progress — not final.

The first two parts made the case: Go got good by removing features and by the principles that fell out of those cuts. This part is for real work. You are not always writing Go. You write Python, TypeScript, Rust, whatever is already there. The habits travel even when the language doesn't. Each one is a Go idiom that makes code more boring. That's the feature.

1. Accept interfaces, return structs

Take the smallest abstraction you need; hand back the concrete thing. A function should ask for the one method it uses, not a whole type.

// asks for io.Reader — anything that reads. returns a concrete *Report.
func Summarize(r io.Reader) (*Report, error) {
    data, err := io.ReadAll(r)
    if err != nil {
        return nil, fmt.Errorf("summarize: %w", err)
    }
    return &Report{Bytes: len(data)}, nil
}

Now Summarize works on a file, a socket, an in-memory buffer, or a fake in a test. You did not plan for that. It fell out of the one-method input. And because it returns a struct, the caller gets the whole thing with no downcast.

Travels as: depend on the narrowest contract, expose the widest result. In any language: take the protocol/interface/duck the caller can satisfy cheaply, return the full object. Wide inputs bind you to the caller; wide outputs free them.

2. Errors are values — wrap them with context

No exceptions, no stack-unwinding fog. An error is a value you return, check, and wrap as it climbs.

func loadUser(id string) (*User, error) {
    row, err := db.Query(id)
    if err != nil {
        // %w wraps: callers can still errors.Is() the original
        return nil, fmt.Errorf("loadUser %s: %w", id, err)
    }
    return row, nil
}
// failure reads like a trace built by hand:
// loadUser 42: query: connection refused

Each layer adds the one fact it knows (which user, which step) and the bottom error stays intact for matching. By the time it hits a log line, it tells you what happened and where, not just connection refused floating in the dark.

The point
An exception is a goto with better PR. An error value is a thing you can hold.

Travels as: even in languages with exceptions, treat failure as data you enrich, not a missile you throw and forget. Catch at the boundary, add context, rethrow or return with the original attached. Result types (Rust's Result, TS Result<T,E>) are this habit with compiler teeth.

3. Table-driven tests

One test body, a slice of cases. A new case is a new row, not another copied function.

func TestSlug(t *testing.T) {
    cases := []struct {
        name, in, want string
    }{
        {"basic", "Hello World", "hello-world"},
        {"trim", "  spaces  ", "spaces"},
        {"empty", "", ""},
    }
    for _, c := range cases {
        t.Run(c.name, func(t *testing.T) {
            if got := Slug(c.in); got != c.want {
                t.Errorf("Slug(%q) = %q, want %q", c.in, got, c.want)
            }
        })
    }
}

The cases become data. You scan coverage at a glance, a new edge case is one line, and each subtest names itself in the output. The test stops pretending to be prose and becomes a table you can inspect.

Travels as: parametrized tests in every framework: pytest @parametrize, Jest test.each, Rust rstest. Put cases in a list; keep one body. Fewer test functions, more cases, less rot.

4. Pass context explicitly

Cancellation, deadlines, and request-scoped values ride in a context.Context as the first argument, threaded by hand all the way down. No hidden thread-local. No ambient global.

func fetch(ctx context.Context, url string) ([]byte, error) {
    req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
    resp, err := http.DefaultClient.Do(req) // cancels when ctx does
    if err != nil {
        return nil, fmt.Errorf("fetch %s: %w", url, err)
    }
    defer resp.Body.Close()
    return io.ReadAll(resp.Body)
}

When the caller's deadline blows, cancellation moves through every function that took ctx and the in-flight HTTP call dies with it. Because it's in the signature, you can see what respects cancellation and what leaks. The plumbing is visible. Good.

Travels as: make cancellation and request scope a parameter, not magic. AbortSignal in JS, cancellation tokens in C#, structured concurrency scopes elsewhere. If a long operation can't be killed by its caller, you built a leak.

5. Make the zero value useful

A freshly declared struct should work with no constructor. Design it so the all-zeros state is valid and sane.

type Buffer struct{ buf []byte } // var b Buffer  -> ready to use

var b bytes.Buffer       // no New(), no init
b.WriteString("hello")   // works on the zero value

var mu sync.Mutex        // zero value is an unlocked mutex
mu.Lock()

No half-initialized objects. No “did you call .init()?” bug class. The empty state is the safe state.

Travels as: pick defaults so the unconfigured object already works. Sensible defaults beat required setup. Every constructor you delete is one missed-initialization bug you can't ship.

6. Standard library first

Reach for stdlib before go get. Go's standard library does HTTP servers, JSON, crypto, templating, and testing with no third party. A dependency is weight you now carry: its CVEs, its breaking changes, its supply chain.

// a real HTTP server, zero dependencies
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "ok")
})
log.Fatal(http.ListenAndServe(":8080", nil))

Travels as: before npm install / pip install, ask if the platform already does it. The left-pad lesson did not expire. The cheapest dependency is the one you never added.

7. Channels vs mutexes — pick the simpler

Go gives you both CSP-style channels and plain mutexes. The proverb is blunt: don't communicate by sharing memory; share memory by communicating, but only when that is actually clearer.

// channel: ownership moves with the value, no lock to forget
results := make(chan int)
go func() { results <- expensive() }()
v := <-results

// mutex: when you just guard one counter, this is simpler. use it.
var (
    mu sync.Mutex
    n  int
)
mu.Lock(); n++; mu.Unlock()

A channel works when work or ownership flows between goroutines. A mutex wins when you guard one piece of shared state in place. Reaching for a channel to protect one integer is cleverness; reaching for a mutex to model a pipeline is pain. Pick the one that reads simpler at the call site.

Travels as: message-passing (actors, queues, Web Workers) vs locks exists in every concurrent runtime. The rule is the same: whichever makes the code obvious to the next reader. Concurrency is where clever goes to make 3am pages. Default to boring.

The checklist I actually code by

This is not theory. It's the Go skill at kronael/tools/skills/go, the rules the agents here lint against on every commit. Short version:

Same shape as everything above: each line is a removal, a dead bug class or an argument in review that never happens.

The throughline

None of these need Go to run. They need the discipline Go got by removing the alternatives. Narrow inputs. Errors you can hold. Tests that are tables. Cancellation you can see. Defaults that just work. Dependencies you did not add. Concurrency that stays legible. Each one is a removal that accelerated: one less way to be wrong, one less review argument, one faster ship.

Where minimalism starts
Minimalism starts, practically, by adopting the Go principles.

Next → Stories & Before/After: where the discipline paid and where ignoring it burned — real codebases, including this one.
Subtraction  ·  Principles