Skip to content

Error handling

FieldValue
TypeSkill Resource
Source~/.copilot/skills/backend/references/errors.md
DescriptionNot specified

Source Content

Error handling

Errors are values (Dave Cheney). Handle them — do not just check them. The reader of a log line should reconstruct the call path from the wrapped chain alone.

Wrap at every boundary with %w

Every error that crosses a function or layer boundary gets context and is wrapped with %w so errors.Is / errors.As still see through it.

inv, err := s.repo.ByID(ctx, id)
if err != nil {
return domain.Invoice{}, fmt.Errorf("finalize invoice %s: %w", id, err)
}

The chain reads top to bottom: finalize invoice inv_42: load invoice inv_42: no rows in result set. No stack trace needed — the message is the trace.

  • Add the identifier, not just the verb: "load invoice %s: %w", never "db error: %w".
  • Wrap once per boundary, not once per line — over-wrapping makes the chain noisy.
  • Use %w, not %v, anywhere a caller might branch on the underlying error.

Log or return, never both

Handle an error exactly once. If you log it, do not also return or wrap it up the stack — the caller cannot tell it was already logged and will log it again, duplicating the entry every time the call chain is more than one layer deep. If you return it, do not also log it at that layer; log only at the one place that finally handles it. In this codebase that place is almost always the handler, per “domain errors are defined in domain; mapped only in the handler” above — the handler is where the error stops propagating, so it is where the log line belongs.

// wrong: logged here, then wrapped and returned — the handler logs it again
if err != nil {
log.Error("load invoice failed", zap.Error(err))
return domain.Invoice{}, fmt.Errorf("load invoice %s: %w", id, err)
}
// right: wrap and return; the handler is the one place that logs
if err != nil {
return domain.Invoice{}, fmt.Errorf("load invoice %s: %w", id, err)
}

Combining independent errors with errors.Join

When several independent operations can each fail and the caller benefits from seeing every failure, not just the first, use errors.Join instead of returning early. This fits validating multiple fields, or a cleanup path that closes several resources.

func validateInvoice(inv domain.Invoice) error {
var errs []error
if inv.TotalCents < 0 {
errs = append(errs, fmt.Errorf("total_cents: must be non-negative"))
}
if inv.Status == "" {
errs = append(errs, fmt.Errorf("status: required"))
}
return errors.Join(errs...) // nil if errs is empty
}

errors.Is and errors.As still traverse a joined error correctly — each traverses into every member errors.Join combined, so a sentinel or typed error buried in one of several joined failures is still found.

Break the chain at public API boundaries

Everything above assumes wrapping with %w inside the module, and that default still holds at every internal layer boundary — handler, service, repository always use %w. The one deliberate exception is a package’s public API surface: a function other teams’ code calls, or a published library’s exported entry point. There, wrapping with %v instead of %w stops external callers from taking a dependency on your internal error types via errors.As, which keeps those internal types free to change without breaking callers outside the module.

// public SDK function — internal error type intentionally not exposed
func (c *Client) FetchInvoice(ctx context.Context, id string) (Invoice, error) {
inv, err := c.doRequest(ctx, id)
if err != nil {
return Invoice{}, fmt.Errorf("fetch invoice %s: %v", id, err) // %v: internal error type stays internal
}
return inv, nil
}

Sentinel errors for expected conditions

When a caller branches on a condition, expose a sentinel and check with errors.Is.

internal/domain/errors.go
package domain
import "errors"
var (
ErrNotFound = errors.New("not found")
ErrAlreadyFinalized = errors.New("already finalized")
)
// caller
if errors.Is(err, domain.ErrNotFound) {
return problem(c, fiber.StatusNotFound, "invoice not found")
}

Typed errors when callers need fields

When the caller needs data from the error (a field name, a limit, a retry-after), use a typed error and errors.As.

type ValidationError struct {
Field string
Reason string
}
func (e ValidationError) Error() string {
return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Reason)
}
// caller
var ve ValidationError
if errors.As(err, &ve) {
return problem(c, fiber.StatusUnprocessableEntity, ve.Reason)
}

Watch the typed-nil-in-interface trap: a nil pointer to a concrete error type, returned as the error interface, is not a nil interface — the interface’s type word is still set, so err != nil is true even though there is no error.

var ve *ValidationError // nil pointer
func check() error {
return ve // wrong: returns a non-nil error interface wrapping a nil *ValidationError
}
// fix: return a literal nil, or check the pointer before boxing it into the interface
func check() error {
if ve == nil {
return nil
}
return ve
}

Domain errors are defined in domain; mapped only in the handler

The domain package owns the error values. The handler is the only layer that translates them to HTTP status codes, using RFC 9457 Problem Details (ADR-027). The service returns domain errors; it never knows what a 404 is.

Handler error-mapping table

Domain errorHTTP statusProblem title
domain.ErrNotFound404resource not found
domain.ErrAlreadyFinalized409conflict with current state
ValidationError (typed)422request failed validation
domain.ErrUnauthorized403not permitted
anything else (err != nil)500internal error (log, do not leak detail)
func problem(c *fiber.Ctx, status int, title string) error {
return c.Status(status).JSON(fiber.Map{
"type": "about:blank",
"title": title,
"status": status,
})
}

The 500 case logs the full wrapped error (references/observability.md) but returns a generic title — never echo internal error text to the client.

Never panic for control flow

  • Panic only on programmer error at startup — a nil dependency, an unparseable embedded template, a missing required config. These should crash the process loudly.
  • Recover middleware is the safety net, not the strategy. It exists so one bad request does not take down the process; it does not excuse panicking in business logic.
  • A returned error is always preferred to a panic inside a handler or service.

Never silently discard an error

_ = thing() is allowed only with a one-line comment stating why the error is safe to drop.

_ = resp.Body.Close() // best-effort close; read already succeeded

Anything else — assign, wrap, return.