Stage 06 - Honest Errors
Replace the stage-05 stopgaps with one translation layer that maps each domain error to the right HTTP status - and never leaks internals.
Prereqs: stage 05 complete · Time: ~15 min
By the end: a not-found returns 404, an invalid price returns 400, and unknown errors collapse to a generic 500.
Translate at the edge
Goal: understand why error-to-HTTP mapping belongs only at the boundary.
Learn
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 returnspq: 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.
In production: 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.
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
Goal: add respondDomainError and route every handler through it.
Learn
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.
Build
1. Extend catalog/presentation/http/response.go - add the errors and domain imports and the mapping function (the two helpers from the Handlers section stay):
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"})
}
}2. Replace the stopgap error branch in each handler with a single call. In create_product.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:
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))
}Checkpoint
Run the server (go run .), then in a second terminal:
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"} 400A missing product is a 404 and an invalid price a 400 - decided in one function, not three.
What you learned
- Error translation happens once, at the edge; deeper layers stay HTTP-free.
errors.Ismatches sentinels through%wwrapping.- 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.
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.