MiniCart

Stage 06 - Honest Errors

Translate at the edge

The stage-05 handlers each guessed a status code in their own error branch. That works by luck and fails in three ways:

  • It leaks. The create handler wrote err.Error() straight into the response. Today that string is a safe domain message, but the day a Postgres adapter returns pq: duplicate key value violates unique constraint "products_pkey", the stopgap ships table names and driver internals to the client - information disclosure, and a message text clients start depending on.
  • It duplicates. The same "not-found is 404" decision is copied into every handler, so the handlers drift apart over time.
  • It pushes HTTP inward. If a deeper layer decided status codes, the domain would have to import net/http - breaking the Dependency Rule.

The fix is one rule: translate errors at the edge. Deeper layers return typed domain sentinels (the Valid by construction section) and nothing else. A single function at the HTTP boundary owns the mapping from sentinel to status and to the client-facing message. Anything it does not recognise collapses to a generic 500 internal error - a fixed string - so internal detail never crosses the boundary.

Two things stay true at once: clients get honest, stable status codes, and the core stays HTTP-agnostic and testable.

Production note: the same discipline draws the line between a 4xx the caller can fix and a 5xx that pages an on-call engineer. A correct mapping is what makes dashboards and alerts mean anything.

Map domain errors

Checkpoint

Run the server (go run .), then in a second terminal:

Shell
curl -s -o /dev/null -w "%{http_code}\n" localhost:8080/v1/products/nope
# 404

curl -s -w " %{http_code}\n" -X POST localhost:8080/v1/products \
  -d '{"name":"Bad","price_cents":0,"stock":1}'
# {"error":"product price must be greater than zero"} 400

A missing product is a 404 and an invalid price a 400 - decided in one function, not three.

respondDomainError is the one function that turns a domain sentinel into an HTTP status. It matches with errors.Is, so it still works when a lower layer wraps a sentinel with %w. The order is deliberate: known errors map to 404 or 400; the default catches everything else as a generic 500.

Extend catalog/presentation/http/response.go - add the errors and domain imports and the mapping function (the two helpers from the Handlers section stay):

Go
package http

import (
	"encoding/json"
	"errors"
	"net/http"

	"minimart/catalog/domain"
)

func writeJSON(w http.ResponseWriter, status int, body any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(body)
}

type errorResponse struct {
	Error string `json:"error"`
}

// respondDomainError is the single place that maps a domain error to an HTTP
// status. Translation happens only at the edge; deeper layers never import
// net/http. A message we don't recognise becomes a generic 500, so internal
// details never leak to clients.
func respondDomainError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, domain.ErrProductNotFound):
		writeJSON(w, http.StatusNotFound, errorResponse{err.Error()})
	case errors.Is(err, domain.ErrEmptyName),
		errors.Is(err, domain.ErrNonPositivePrice),
		errors.Is(err, domain.ErrNegativeStock):
		writeJSON(w, http.StatusBadRequest, errorResponse{err.Error()})
	default:
		writeJSON(w, http.StatusInternalServerError, errorResponse{"internal error"})
	}
}

Replace the stopgap error branch in each handler with a single call. In create_product.go:

Go
	if err != nil {
		respondDomainError(w, err)
		return
	}

get_product.go and list_products.go change the same way - delete the guessed status and call respondDomainError(w, err). All three now read identically at the error branch, and the mapping lives in exactly one place. For reference, get_product.go in full:

Go
package http

import "net/http"

func (h *Handlers) GetProduct(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	p, err := h.svc.GetProduct(r.Context(), id)
	if err != nil {
		respondDomainError(w, err)
		return
	}
	writeJSON(w, http.StatusOK, toProductResponse(p))
}

What you learned

  • Error translation happens once, at the edge; deeper layers stay HTTP-free.
  • errors.Is matches sentinels through %w wrapping.
  • Unrecognised errors collapse to a generic 500, so internals never leak.

The catalog is now a complete vertical slice. Stage 07 makes it wire itself as a module, so main.go stops hand-assembling the graph.

Practice & reflection

Translate at the edge

Exercise

An unrecognised error becomes 500 {"error":"internal error"} with the real message dropped from the response. Where should the real message go?

Answer

Into the server logs, with a request ID, for engineers - not the client. The client learns only that something failed; the operator gets the detail. Splitting the audience is the whole point of translating at the edge.

Map domain errors

Exercise

Why should the default error response hide err.Error() from the client?

Answer

Unknown errors often contain implementation details: SQL, file paths, package names, or internal state. The edge can log that detail for operators, but the public response should stay stable and generic.