MiniCart
Stage 06 Honest Errors

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 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.

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):

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"})
	}
}

2. 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))
}

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.

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.

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.