gopherpath
0%

Wrapping in practice: Join, defer, and boundaries

35 min · lesson 2 of 2 · #errors · #defer

One error per layer, wrapped, never logged twice

The rule that keeps logs readable: handle at one level, log at one level. Library code wraps and returns; main, HTTP middleware, or a job runner logs. If every layer logs, one failure produces five entries and no cause.

Go
// repository
if err := row.Scan(&u); err != nil {
	if errors.Is(err, sql.ErrNoRows) {
		return User{}, fmt.Errorf("get user %s: %w", id, ErrNotFound)
	}
	return User{}, fmt.Errorf("get user %s: %w", id, err)
}

// handler — the boundary. This is where logging happens.
if err != nil {
	if errors.Is(err, ErrNotFound) {
		http.Error(w, "not found", http.StatusNotFound)
		return
	}
	slog.Error("get user", "err", err, "user", id)
	http.Error(w, "internal error", http.StatusInternalServerError)
	return
}

%w at each layer means the final log line contains the whole chain: get user 42: sql: no rows in result set. Same information a Java stack trace gives you, minus 40 frames of framework noise.

Several things failed at once

errors.Join (Go 1.20) replaces the hand-rolled multiError type everyone used to write for "validate all fields, report all problems":

Go
package main

import (
	"errors"
	"fmt"
	"strings"
)

var ErrRequired = errors.New("required")
var ErrTooLong = errors.New("too long")

func validate(name string) error {
	var errs []error
	if name == "" {
		errs = append(errs, fmt.Errorf("name: %w", ErrRequired))
	}
	if len([]rune(name)) > 10 {
		errs = append(errs, fmt.Errorf("name: %w", ErrTooLong))
	}
	return errors.Join(errs...) // nil when the slice is empty
}

func main() {
	err := validate(strings.Repeat("x", 12))
	fmt.Println(err.Error())                  // both lines, newline-separated
	fmt.Println(errors.Is(err, ErrTooLong))   // true — Join keeps every cause inspectable
	fmt.Println(validate("vlad") == nil)      // true
}
Java
// Java has suppressed exceptions for one error per layer, not N independent failures:
try { a(); } catch (Exception e) { try { b(); } catch (Exception f) { e.addSuppressed(f); } throw e; }
// For "all validation errors", frameworks hand you a list<ConstraintViolation> instead.
TypeScript
const errs: Error[] = [];
if (!name) errs.push(new Error("required"));
if (errs.length) throw new AggregateError(errs, "validation failed"); // closest analogue

defer has three sharp edges

Go
// 1. Arguments are evaluated at defer time, not at run time.
for i := 0; i < 3; i++ {
	defer fmt.Println(i) // prints 2 1 0 — captured values, not the loop variable at exit
}

// 2. It runs per FUNCTION, not per block. In a loop over files, extract a func:
for _, p := range paths {
	func() {
		f, err := os.Open(p)
		if err != nil { return }
		defer f.Close() // now this runs each iteration
		_ = f
	}()
}

// 3. Named returns let you modify the error on the way out:
func flush(w io.Writer) (err error) {
	defer func() {
		if cerr := w.(io.Closer).Close(); cerr != nil && err == nil {
			err = cerr // don't swallow a close error over a clean path
		}
	}()
	_, err = w.Write([]byte("x"))
	return err
}

Java's try-with-resources fails compilation if you forget the close; Go's defer is opt-in and invisible to the type system. go vet and staticcheck catch the missing Close on well-known types — run them in CI, they are the Go equivalent of SpotBugs plus half of Checkstyle.

Panic policy you can defend in a review

Situation Do
Bad user input return an error, 400 at the boundary
Missing config at startup log.Fatal / panic — crashing early is correct
Impossible state in a library return an error; do not panic on a caller's behalf
HTTP/gRPC handler one recover middleware, log with stack, 500
Goroutine you spawned recover inside it, or a panic kills the whole process

That last row is the one that surprises people: a panic in any goroutine takes down the process, not just that goroutine. There is no "uncaught exception handler per thread" — which is why worker pools wrap their work function.

Trap: recover() only works when called directly by a deferred function. defer func(){ recover() }() is correct; defer recover() is not — the value is discarded and the panic continues.

Proof checkpoint

  • Build a 3-layer chain (repo → service → handler) where only the handler logs, and confirm the log contains all layers.
  • Use errors.Join for a form with three bad fields; assert errors.Is finds each sentinel.
  • Write a defer that turns a panic into an error with the stack captured (debug.Stack()), and prove it does not swallow the panic elsewhere.
stored in this browser only