← Code Like Go

Stories & Before/After

part 4 — the diffs where boring won

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

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.

1. The generic abstraction that rotted

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.

2. The exception an error-value would have caught

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.

3. The 200-line framework replaced by 20 lines of stdlib

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.

4. The interface you declared too early

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.

The thread through all four

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