Wrapping in practice: Join, defer, and boundaries
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.
// 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":
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 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.
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
// 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.Joinfor a form with three bad fields; asserterrors.Isfinds each sentinel. - Write a
deferthat turns a panic into an error with the stack captured (debug.Stack()), and prove it does not swallow the panic elsewhere.