part 4 — the diffs where boring won
Part 1 was what Go removed. Part 2 was the non-negotiables left after the cut. Part 3 were the habits that travel. This part is the receipts: code that went clever, then got cut back until it shipped. Names sanded off where needed, but if you've touched a Go codebase, you've seen every one.
Before generics landed in Go 1.18, people argued for them for years. Then they arrived, and the old hands said: don't reach for them first. Usual mistake: wrapping a thing that never repeated.
// BEFORE: a "flexible" repository nobody asked for
type Repository[T any, ID comparable] interface {
Find(ctx context.Context, id ID) (T, error)
Save(ctx context.Context, e T) error
Query(ctx context.Context, spec Specification[T]) ([]T, error)
}
type Specification[T any] interface {
IsSatisfiedBy(T) bool
And(Specification[T]) Specification[T]
Or(Specification[T]) Specification[T]
}
// ...300 more lines of And/Or/Not combinators, used by exactly one type.
Two years later it had three call sites, all User. The
Specification tree was there to feel reusable. The rewrite:
// AFTER: the concrete thing, written once
type UserStore struct{ db *sql.DB }
func (s *UserStore) Find(ctx context.Context, id int64) (User, error) { ... }
func (s *UserStore) Save(ctx context.Context, u User) error { ... }
func (s *UserStore) ActiveSince(ctx context.Context, t time.Time) ([]User, error) { ... }
Net: -280 lines, and the next engineer could read the
whole store in one screen. The Go proverb that killed it: a little
copying is better than a little dependency. Generics earn their
keep on containers and algorithms (slices.Sort,
maps.Keys), not on your domain.
The abstraction wasn't wrong. It was early. Abstract on the third repeat, not the zeroth.
This is the bug Go's whole if err != nil tax is for. In a
try/catch language, failure hides between the lines. A billing job
skipped writes for a week:
# pseudo-Python, the language doesn't matter — the swallow does
def settle(batch):
try:
for inv in batch:
charge(inv) # raises on network blip
mark_paid(inv)
except Exception:
log.warning("settle hiccup") # <-- ate the partial failure
# loop is dead; remaining invoices never charged, never marked
One broad except turned a blip into lost revenue. Nothing
forced you to face it. Go makes the same logic admit failure at every
step:
func settle(ctx context.Context, batch []Invoice) error {
for _, inv := range batch {
if err := charge(ctx, inv); err != nil {
return fmt.Errorf("charge %s: %w", inv.ID, err)
}
if err := markPaid(ctx, inv); err != nil {
return fmt.Errorf("mark paid %s: %w", inv.ID, err)
}
}
return nil
}
You don't swallow this by accident. The error is a
return value, and errcheck / go vet yell if
you drop it. The %w wrap means the caller can
errors.Is the root cause. Errors are values, so they move
through the same pipes as your data. Removing
exceptions removed one of the best hiding spots bugs had.
Every Go shop inherits one of these: a "router" or "config loader" or "worker pool" someone wrote in 2019 because they came from framework land. The router story is the classic. Before:
// BEFORE: a bespoke middleware chain with reflection-based DI
r := framework.New(framework.WithLogger(log), framework.WithRecover())
r.Use(authMiddleware, tracingMiddleware, gzipMiddleware)
r.Group("/api", func(g *framework.Group) {
g.GET("/users/:id", inject(handlerUsers)) // params via reflection
})
// 200+ lines of the framework itself, plus a magic `inject` you must learn
Since Go 1.22 the standard net/http mux does method and
path patterns. The rewrite needed no dependency:
// AFTER: stdlib only, Go 1.22+
mux := http.NewServeMux()
mux.HandleFunc("GET /api/users/{id}", handlerUsers)
func handlerUsers(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// middleware is just a function that wraps a handler — no DI magic
}
func withAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !ok(r) { http.Error(w, "no", 401); return }
next.ServeHTTP(w, r)
})
}
srv := withAuth(mux)
-200 lines, zero dependencies, one less thing to upgrade. Middleware is a function. A handler is a function. There was never a framework to learn, only the standard library, which the next hire already knew. The stdlib is the framework.
Java habits tell you to define the interface up front "for testing." Go says the opposite: accept interfaces, return structs, and only cut the interface where it's used.
// BEFORE: producer-side interface, 12 methods, one impl
type EmailService interface {
Send(...); SendBulk(...); Schedule(...); Cancel(...); ...
}
// AFTER: consumer declares the one method it needs
func Notify(s interface{ Send(to, body string) error }, u User) error {
return s.Send(u.Email, "hi")
}
The test now stubs one method, not twelve. The interface lives next to the code that needs it, so it stays small by gravity. Bigger interface, weaker abstraction.
Every win above is a removal. Remove the speculative generic and
the domain reads again. Remove the exception and the bug has nowhere to
crouch. Remove the framework and the next hire is already inside. Remove
the twelve-method interface and the test turns back into a line. Go makes
removal the easy path: gofmt ends the style fight, the
error return makes you look, the missing try means there's
nowhere to stash the failure.
None of this needs religion. The constraints push you toward code that survives a year of strangers touching it. Boring code beats clever code because boring is what a stranger can change at 2am without summoning a ghost.
Minimalism starts, practically, by adopting the Go principles. Concrete first. Errors as values. Small interfaces, declared where used. Reach for the stdlib before the dependency. Delete the abstraction you can't point at three callers for.
You don't need to write Go to code like Go. You need the discipline Go enforces by subtraction. Clear is better than clever, and clever is usually the thing you'll delete next quarter anyway. Remove to accelerate.
Next → Nothing to Recover: the async machinery Go lets you delete.
← back to Code Like Go · start at Subtraction · the Non-Negotiables