part 4 — the diffs where boring won
Part 1 was what Go removed. Part 2 was the non-negotiables removal left standing. This part is the receipts: real shapes of code that rotted clever and shipped boring. Names sanded off where it matters, but anyone who has worked a Go codebase has lived every one of these.
Before generics landed in Go 1.18, the community spent years arguing for them. The funny part: when they arrived, the experienced advice was don't reach for them first. The canonical mistake is wrapping a thing that was 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 existed 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 second 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 model.
The abstraction wasn't wrong. It was early. You abstract on the third repeat, not the zeroth.
This is the bug Go's whole if err != nil tax is meant to
prevent. In a try/catch language the failure hides between the lines.
Here is the shape of a real one — a billing job that silently 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 transient error into lost
revenue. Nothing in the type system made you handle it. Go makes the
same logic admit its 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 cannot accidentally swallow this — the error is a
return value, and errcheck / go vet yell if
you drop it. The %w wrap means the caller can
errors.Is on the root cause. Errors are values, so they
flow through the same plumbing as your data. Removing
exceptions removed the place bugs hide.
Every Go shop eventually inherits a "router" or "config loader" or "worker pool" someone wrote in 2019 because they came from a framework culture. 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 fewer 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.
A smaller, quieter story, but the most common. Java habits make you define the interface up front "for testing." Go's advice is the opposite: accept interfaces, return structs, and only carve 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 depends on 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. Remove the exception and the bug can't hide. Remove
the framework and the next hire is already onboarded. Remove the
twelve-method interface and the test is one line. Go just makes the
removal the path of least resistance — 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 is about the language as a religion. It's that the constraints push you toward the version of the code that survives a year of strangers editing it. Boring code beats clever code because boring is what a stranger can change at 2am without fear.
If you do one thing: write code the way you'd write Go. 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 almost always the thing you'll be deleting next quarter anyway.
← back to Code Like Go · start at Subtraction · the Non-Negotiables