Go · Engineering

Everyone writes about building simple things in Go. Fewer write about keeping them simple once real teams get hold of them. The pressure is incremental, reasonable, and relentless.


Simplicity isn't a property of code you write. It's a property of decisions you keep making. Most articles are fine — but they stop at exactly the wrong moment: when the code is clean and the requirements haven't arrived yet. Month one is easy. Month seven is where the discipline lives.

This is what month seven looks like. A second team wants slightly different output. Someone asks if it can read config from a file. A well-meaning engineer opens a PR adding two hundred lines because they thought the abstraction would help. Each request is reasonable. Collectively, they are not.

None of this is specific to Go, or to this decade. I have watched the same arc in every language I have worked in and every industry I have worked for, and it lands hardest on internal tooling — the scripts and small utilities a team writes to make its own life easier. The shape is consistent. One developer builds something small and genuinely useful, and then three constituencies find it. Developers want it to fit their local setup. QA wants it to stand the whole system up. DevOps wants it to deploy, and then to monitor. Every one of those needs is legitimate. Not one of them is the same tool.

We'll work through a concrete example — a small HTTP health checker called chk — and watch six distinct pressures arrive. The question each time isn't whether the request is reasonable. It's whether absorbing it serves the tool or slowly becomes the tool.

Watch for the pattern underneath them, though, because that is the actual subject here. Six reasonable requests do not add up to a reasonable outcome, and the reason has far less to do with anyone's judgement than with the shape the two sides of the argument arrive in. That is where this ends up. There is a fix, and it isn't a matter of holding your nerve.

The Baseline: What Simple Actually Looks Like

chk takes one or more URLs as arguments, makes a GET request to each, and reports whether the response was a 2xx. It exits non-zero if any check fails. That's the entire spec.

package main

import (
    "fmt"
    "net/http"
    "os"
)

func main() {
    if len(os.Args) < 2 {
        fmt.Fprintln(os.Stderr, "usage: chk <url> [url...]")
        os.Exit(1)
    }

    failed := false
    for _, url := range os.Args[1:] {
        ok, status := check(url)
        if ok {
            fmt.Printf("  OK  %s (%s)\n", url, status)
        } else {
            fmt.Printf("FAIL  %s (%s)\n", url, status)
            failed = true
        }
    }

    if failed {
        os.Exit(1)
    }
}

func check(url string) (ok bool, status string) {
    resp, err := http.DefaultClient.Get(url)
    if err != nil {
        return false, err.Error()
    }
    defer resp.Body.Close()
    ok = resp.StatusCode >= 200 && resp.StatusCode < 300
    return ok, resp.Status
}

Forty lines. No dependencies. No config files. No interfaces. Ship it.

A few things are already load-bearing here: exit codes are a first-class decision, not an afterthought. Errors go to stderr. The usage string is in the binary. None of this is clever — it's just correct, and correct things are worth protecting.

Six Pressures

These aren't bad requests. Most are completely legitimate. The discipline is in knowing which ones to absorb and which ones to refuse — and being able to explain the difference.

1 & 2. The Sensible Flags

Pressure

"Some endpoints are slow. Can we add a --timeout flag?" Then, a week later: "We're running this in CI — can we get JSON output so the pipeline can parse results?"

Strategy — Absorb both. This is exactly what flags are for.

Stdlib flag, one addition per request, no framework. The trap isn't the flags themselves — it's the Cobra instinct that arrives alongside them.

var (
    timeout = flag.Duration("timeout", 10*time.Second, "request timeout")
    jsonOut = flag.Bool("json", false, "output results as JSON")
)

func main() {
    flag.Parse()
    // The timeout has to actually reach the request, so the client is
    // built here rather than using http.DefaultClient.
    client := &http.Client{Timeout: *timeout}
    // ...
}

// check takes the client as a parameter now. That is the whole
// cost of the flag: one argument, no indirection.
func check(client *http.Client, url string) (ok bool, status string) {
    resp, err := client.Get(url)
    // ...body unchanged
}

// The shape of --json output. The moment a pipeline parses this,
// it is a contract — so it stays flat and boring on purpose.
type Result struct {
    URL    string `json:"url"`
    OK     bool   `json:"ok"`
    Status string `json:"status"`
}

func printResults(results []Result) {
    if *jsonOut {
        json.NewEncoder(os.Stdout).Encode(results)
        return
    }
    for _, r := range results {
        if r.OK {
            fmt.Printf("  OK  %s (%s)\n", r.URL, r.Status)
        } else {
            fmt.Printf("FAIL  %s (%s)\n", r.URL, r.Status)
        }
    }
}

Two output modes. One if. No Formatter interface, no registry, no factory. The Cobra argument surfaces around now — "it scales better," "you get completions for free." Cobra is a fine library for tools that genuinely have subcommands. chk has one command. Importing a framework presupposes the complexity you're trying to avoid, and frameworks have opinions about what comes next.

If a third output format actually arrives, that's when the interface earns its place — not speculatively, but because you have three real implementations in hand and the abstraction is visible.

Absorbing --json carries a second-order cost worth naming: the moment that output reaches a pipeline it is a contract, and you no longer entirely own its shape. Go 1.27 makes the point for free — encoding/json is now implemented on top of encoding/json/v2, so the engine under chk's output changed without anyone asking, though v1 semantics are pinned and nothing breaks. The sharp edge only appears if you migrate to the v2 API, where maps are no longer sorted by default and the key order genuinely varies run to run; json.Deterministic(true) restores it, and a CI job diffing against a fixture goes flaky rather than red if you forget. None of which can touch the Result above, because struct fields marshal in declaration order — the exposure arrives with the first map field somebody adds to it, which is one more reason to keep that record flat and boring.

3. The Config File

Pressure

"We have 40 endpoints now. Passing them as arguments every time is painful. Can it read from a YAML file?"

Strategy — This is where the line is, and the first question is whether it needs any code at all.

A list of URLs in a file is a storage problem. YAML is a configuration system. They are not the same thing, and conflating them is where tools get away from you.

The request is "stop repeating arguments." That is already solved, and not by chk. xargs chk < urls.txt does it today with no change to the tool whatsoever, and grep -v '^#' urls.txt | xargs chk gets you comment support for free. Before writing a file parser, it is worth checking whether the shell has already parsed the file.

If you want it to feel native, the change worth making still isn't --file. It is reading standard input when there are no arguments — the convention every Unix tool already follows:

// No --file flag. With arguments, use them; with none, read stdin —
// the convention every Unix tool already follows.
func urls() []string {
    if len(os.Args) > 1 {
        return os.Args[1:]
    }
    var out []string
    sc := bufio.NewScanner(os.Stdin)
    for sc.Scan() {
        if line := strings.TrimSpace(sc.Text()); line != "" {
            out = append(out, line)
        }
    }
    return out
}

Thirteen lines, and now chk composes with anything that emits lines. Comment stripping, filtering, deduplication, pulling the list out of a service registry — every one of those is somebody else's already-tested tool, and none of them is your problem. It is about the same amount of code as the file parser it replaces, which is rather the point: the win was never fewer lines, it was not owning the problem.

The YAML ask isn't unreasonable, but YAML files invite schemas, and schemas invite structure, and structure invites per-URL fields: custom headers, expected status codes, auth tokens, environment-specific overrides. Each field is a feature request in waiting. Say it plainly: "A list of URLs on stdin does what you need. If you need per-endpoint configuration, that's a different tool."

The second trap here is automatic discovery. The Viper model — check ~/.chk.yaml, then ./.chkrc, then CHK_CONFIG, merge everything — turns configuration into a debugging problem. You want to know with certainty where your config came from. Explicit beats implicit, and a pipeline is as explicit as it gets: the path is right there in the command, in your shell history and in your CI config, and nothing merged it with anything behind your back.

4. The Concurrency Request

Pressure

"Checking 40 endpoints sequentially takes too long. Can it run them in parallel?"

Strategy — Yes, but with a bounded worker pool, not unbounded goroutines.

This is one of the places Go genuinely shines — concurrency is a language feature, not a library. The trap is reaching for a worker pool library, or building an elaborate pipeline, when a handful of goroutines and a channel handles it cleanly.

func checkAll(client *http.Client, urls []string, workers int) []Result {
    jobs := make(chan string, len(urls))
    results := make(chan Result, len(urls))

    var wg sync.WaitGroup
    for range workers {
        wg.Go(func() {
            for url := range jobs {
                ok, status := check(client, url)
                results <- Result{URL: url, OK: ok, Status: status}
            }
        })
    }

    for _, url := range urls {
        jobs <- url
    }
    close(jobs)

    wg.Wait()
    close(results)

    var out []Result
    for r := range results {
        out = append(out, r)
    }
    return out
}

Bounded, readable, no dependencies. Add a --workers flag defaulting to something sensible — five is usually enough — and you're done.

This is the one pressure where Go makes it genuinely easy to do the right thing. The worker pool above is not a clever trick — it's the obvious implementation. Other languages reach for a library here because the primitives are awkward; Go's goroutines and channels handle it directly. The trap is reaching for a concurrency library out of habit, importing a framework for a problem that three stdlib types solve. sync.WaitGroup is not a concurrency framework. It's a counter.

It has also been getting quietly easier to hold this line. WaitGroup.Go, added in Go 1.25, folds the Add(1) / defer Done() pairing into the call that starts the goroutine, and for range workers replaced the C-style loop in 1.22. The pool above is two lines shorter than the version I would have written two years ago, and the argument for importing a worker-pool library is correspondingly weaker. This is the general pattern: the stdlib keeps absorbing the reasons people reach outside it.

5. The Abstraction Proposal

Pressure

"I was reading the code and thought it would be cleaner if we defined a Checker interface. It would make testing easier and let us swap implementations later. I've started a refactor."

Strategy — This is the subtlest pressure because the instinct is correct, and the conclusion is wrong.

Isolation and testability are real concerns. The mistake is carrying Java's answer to a Go codebase.

In Java, you mock via interface because the language gives you no other clean seam. Go's interfaces are implicit — you don't declare that a type implements an interface, it just does if the methods match. This changes the calculus completely. You don't need to define the interface upfront to get the benefits later; you extract it when you need it, without touching the callers.

More importantly, Go gives you a better testability primitive for single-behaviour dependencies: the function type.

// What the refactor proposes:
type Checker interface {
    Check(url string) (bool, string)
}
type HTTPChecker struct{ client *http.Client }
func (c HTTPChecker) Check(url string) (bool, string) { ... }
type MockChecker struct{ results map[string]bool }
// 80 lines of infrastructure to test a 10-line function

// What you actually need:
type checkFn func(url string) (bool, string)

func run(urls []string, check checkFn) []Result {
    var results []Result
    for _, url := range urls {
        ok, status := check(url)
        results = append(results, Result{URL: url, OK: ok, Status: status})
    }
    return results
}

// In main — the client is captured by the closure, so the seam stays
// one argument wide and run never learns what an http.Client is:
results := run(urls, func(url string) (bool, string) {
    return check(client, url)
})

// In tests — substitute the behaviour without touching run:
stubbed := run(urls, func(url string) (bool, string) {
    return url != "https://bad.example.com", "200 OK"
})

It is worth being careful here, because the obvious version of this argument is wrong and gets repeated a lot. Small interfaces are not a smell in Go — they are the good case. The proverb is the bigger the interface, the weaker the abstraction, and that is an argument for one-method interfaces, not against them. error, io.Reader, fmt.Stringer and http.Handler are one method apiece, and they are among the best abstractions in the language. Nobody should come away from this thinking a single-method interface needs justifying.

So the trouble with the proposed Checker isn't the method count. It is that this particular dependency has none of the properties an interface exists to carry. An interface value has identity — that is what lets errors.Is compare and %w unwrap. It composes: io.ReadWriter is Reader and Writer embedded. And it can be asked what else it can do at runtime — io.Copy type-asserts for WriterTo and ReaderFrom to take a faster path, and http.Flusher exists to be discovered the same way. A function value offers none of that. It is one call, and nothing else.

Which is precisely what chk needs. No identity to preserve, no second capability worth discovering, nobody outside the package implementing the role. One caller, one axis of variation. Go itself uses both, and the direction net/http runs is the instructive one — Handler stays the interface and HandlerFunc is an adapter that lets plain functions satisfy it. The interface remains primary; functions are let in.

The rule that falls out is about what the thing on the other side is, not how many methods it has. Reach for the interface when it has identity, state, or more than one capability — when a caller might reasonably want to ask it something. Reach for the function type when the dependency really is just a function. The refactor's instinct was sound; it reached for the wrong one of the two, and it is the more expensive one to undo.

As of Go 1.27 there is a further step, and it is the one worth reaching for first. httptest.NewTestServer gives you a server on an in-memory network — no real TCP port, no port allocation, cleanup registered through t.Cleanup automatically. For a tool whose entire job is making HTTP requests, that means you can test the real check against a real server and skip the seam altogether:

func TestCheck(t *testing.T) {
    srv := httptest.NewTestServer(t, http.HandlerFunc(
        func(w http.ResponseWriter, r *http.Request) {
            w.WriteHeader(http.StatusTeapot)
        }))

    // Client() is what populates srv.URL — read it after, not before.
    client := srv.Client()

    ok, status := check(client, srv.URL)
    if ok || !strings.Contains(status, "418") {
        t.Errorf("want non-OK 418, got ok=%v status=%q", ok, status)
    }
}

No interface, no function type, no mock — and unlike the mock, this test exercises the status-code logic that is the only thing check actually does. Keep the function type for the places where you genuinely need to substitute behaviour; the point is that the list of such places is shorter than it looks, and it got shorter again in 1.27. The best abstraction is regularly the one you didn't need because the standard library got better at testing.

The harder part of this conversation is that the person has already started. Acknowledge what's right about the instinct — isolation, replaceability — while being specific about why the function-injection approach gives them the same properties with less machinery. Don't just say "we don't do it that way in Go." Explain the implicit interface model and why it changes when you need to make the seam explicit.

6. The Watch Mode

Pressure

"Could it run continuously and re-check every 30 seconds? Like a lightweight uptime monitor."

Strategy — This is a rewrite signal dressed as a feature request.

A poll loop isn't a flag. It's a different operational model.

An assertion tool runs, reports, exits. A daemon runs indefinitely, needs graceful shutdown, manages state across checks, writes to a log rather than stdout, and probably needs alerting. These are different programs. Putting a --watch flag on chk doesn't combine them — it produces something that is neither, designed for both, good for neither.

Before reaching for a second binary, though, ask whether anything needs writing at all. chk already exits non-zero on failure — a deliberate decision back in the baseline, and precisely the thing that makes it schedulable. A systemd timer will run it every thirty seconds, restart it if it dies, and put the output in the journal — and mkunit will generate the unit if you would rather not hand-write it. cron does a cruder version in one line. launchd does the same on a Mac, with mklaunchd for the plist. Kubernetes has CronJob, and your CI already has a scheduler sitting there. None of that is code you write, review, or carry, and every bit of it is better tested than the poll loop you were about to add.

So the ordering is: use the scheduler that already exists. If the real need is alerting rather than checking, point at something that already alerts — which is why the log entry in the next section sends people to the Prometheus blackbox exporter. Only if you genuinely need state across checks, flap detection or escalation history is a daemon the answer, and at that point it is a separate binary with its own scope. What you don't want anywhere on that ladder is daemon behaviour accreting onto an assertion tool until you can no longer see the original shape of either.

Most watch-mode requests are a scheduling problem wearing a feature request's clothes. The instinct you need to hone over time is asking what already solves this before asking what to write — and knowing the surrounding tools well enough that the answer is specific rather than a shrug.

The Decision Asymmetry Problem

Every one of those pressure points arrived with full context, a concrete use case, and a reasonable person behind it. "We need JSON output for the pipeline" is a problem with a name, a deadline, and someone whose day is blocked. "We should keep this simple" is a principle without a stakeholder.

This isn't a failure of team culture or engineering maturity. It's structural. Complexity arguments arrive with urgency. Simplicity arguments arrive in retrospect, when you're debugging something that used to be readable, or trying to explain to a new hire why the binary has eighteen flags.

You can't fix a structural problem with willpower. You fix it with a counter-structure: a DECISIONS.md in the repo. Not a formal ADR process, not a committee — just a running log of what you chose not to add and why.

# chk — decision log

## 2025-11-04: Rejected watch/poll mode
Uptime monitoring is a different operational concern. chk is an assertion
tool, not a daemon. Users asking for this were directed to the Prometheus
blackbox exporter for sustained monitoring.

## 2025-12-01: Rejected YAML config format
Per-URL configuration (custom headers, expected status codes, auth tokens)
would make chk a different product. Kept URL input as arguments and stdin.
The ask was "stop repeating arguments" — a pipe solves that.

## 2026-01-15: Rejected Cobra
No subcommands. stdlib flag is sufficient and keeps the mental model flat.
Cobra presupposes a complexity we don't have and pulls in a framework
model that shapes what gets built next.

The log does two things. First, it makes the shape of the tool visible. You can see the boundary you're defending. Second, it changes the asymmetry: now the simplicity argument has a stakeholder too — whoever wrote the entry. When the fifth person asks about watch mode, the answer isn't a conversation from first principles. It's a pointer to a document that already had the conversation.

The log also catches scope creep at the proposal stage. If someone opens a PR for watch mode and you've already written it down, they have to engage with the reasoning rather than just the implementation. That's a healthier conversation.

What Refusal Costs

Read back as a list — six pressures, four absorbed, two refused, a log to point at — this sounds tidier than it is, and I have left something out.

Refusal is not free, and the cost rarely lands on the person doing the refusing. Tell the second team that watch mode is a monitoring daemon belonging somewhere else, and one of two things happens. Either somebody writes the daemon, which was the right call and the system is better for it. Or nobody does, because there was never budget for a new service, and three weeks later chk is wrapped in a shell script with its own retry loop, its own backoff and a Slack webhook — reimplementing, badly, the supervision the scheduler was already offering for free — in a repository you don't own and won't be shown. The complexity you refused didn't evaporate. It relocated somewhere you can no longer see it, and it is worse there — inside chk it would at least have been code reviewed.

That is the failure mode of everything above, and it demands a discipline people don't expect. Saying no is easy. Saying no and staying responsible for the outcome is the actual work: naming where the need should go, checking later that it got there, and treating a shadow tool in somebody else's repository as your problem rather than as proof you were right. The decision log earns its place here more than it does in the refusing — notice that the watch-mode entry doesn't stop at "rejected." It records where those users were sent. An entry that only says no is half a decision.

None of which is an argument for absorbing everything. It is an argument that the refusals you can defend are the ones where you know what happened next.

Accretion Is Not Growth

After all six pressures, chk has three flags, URLs on stdin, a bounded worker pool, and a function-based test seam. Around 150 lines. No external dependencies. main.go is still readable top to bottom in one sitting. That didn't happen by default — it happened because specific requests were absorbed, several were refused, and the refusals are written down.

There's a point where you stop holding the line and rewrite. The signal isn't a flag count threshold — it's when the flags start interacting, when the tool needs state between invocations, when two teams are pulling it in genuinely different directions, when you're wrapping your own abstractions to manage earlier compromises. That's not a failure. That's the tool reporting that it has run in production long enough to understand the real problem. A rewrite at that point is architecture based on evidence.

The middle path is the one to avoid: neither holding the line nor rewriting, but accreting. A binary with twenty flags is harder to replace than a binary with fifty lines, because you've acquired users for all twenty flags. They've written scripts against them. They've added them to pipelines. Accretion doesn't produce a better-understood problem — it produces a harder-to-change codebase with the same unclear requirements underneath.

There is a second asymmetry underneath the first, and it is the one that does the real damage to internal tooling. A tool like this becomes load-bearing long before anybody notices — it is in three pipelines and a runbook by the time it matters — but it doesn't generate revenue, and what doesn't generate revenue doesn't get a budget line. So it is never worth a week to rewrite and always worth an afternoon to extend. You can spot the end state from across the room: the tool acquires an aura, here be dragons, and a name quietly attached to it. Nobody reads the whole thing any more, because no reason to do so would survive a planning conversation. The utility written to make the team faster is now one of the things slowing it down.

Which is why the interesting question for the next few years isn't whether agents can write your tools. They can. It's whether that makes teams build bigger tools or more of them. The reflexive answer is bigger — if generating two hundred lines is free, the abstraction someone proposed in month seven costs nothing to accept. That is exactly the wrong lesson. Cheap production makes accretion cheaper too, and it removes the last natural brake there was: that somebody had to sit down and type it.

The Unix answer scales here, and it scales precisely because it was never about elegance. A tool that does one thing can be deleted. Someone who has never seen it can read it in a sitting, decide it is wrong, and have a better one working before lunch — and nothing downstream notices, because the contract was only ever one thing wide. That property is the thing accretion destroys, and it is worth more than any individual feature you would trade away to keep it. So if agents make tools cheap to write, the win isn't one tool that does more. It's five tools that each do one thing, any of which you can throw away.

That only holds if they stay small, which puts us back where we started with rather more at stake. The goal isn't code that never changes. It's code where every change is a decision rather than a concession. The difference between those two things is the work — and there is about to be a great deal more of it.