Handle Errors
Handle Errors
Section titled “Handle Errors”This guide shows how to return structured error responses from Plumego handlers using contract.WriteError and contract.NewErrorBuilder. Every handler in the system uses these two functions as the single error-write path.
For the boundary rationale, see the Contract Primer.
What this guide covers
Section titled “What this guide covers”- The
APIErrorshape and how it serializes - Using
NewErrorBuilderto construct errors - The built-in
ErrorTypecatalog and when to use each entry - Returning errors from handlers consistently
The error shape
Section titled “The error shape”Every error response has the same JSON envelope:
{ "error": { "type": "required_field_missing", "code": "REQUIRED", "message": "name is required", "category": "validation_error", "severity": "error", "details": { "field": "name" }, "request_id": "abc-123" }}request_id is injected automatically when the requestid middleware has run.
Step 1 — Use NewErrorBuilder for all error responses
Section titled “Step 1 — Use NewErrorBuilder for all error responses”Never construct APIError as a struct literal. Use the builder:
import "github.com/spcent/plumego/contract"
_ = contract.WriteError(w, r, contract.NewErrorBuilder(). Type(contract.TypeRequired). Detail("field", "name"). Message("name is required"). Build())Type sets the type, code, category, and HTTP status automatically based on the ErrorType constant. You only need to override Message and optionally Detail.
Step 2 — Choose the right ErrorType
Section titled “Step 2 — Choose the right ErrorType”| Situation | ErrorType to use |
|---|---|
| Required query param or body field is absent | contract.TypeRequired |
| Input value has the wrong format | contract.TypeInvalidFormat |
| Input value is outside the allowed range | contract.TypeOutOfRange |
| Resource identified by ID does not exist | contract.TypeNotFound |
| Request is not authenticated | contract.TypeUnauthorized |
| Authenticated user lacks permission | contract.TypeForbidden |
| Conflict with existing state (e.g. duplicate) | contract.TypeConflict |
| Server-side failure that is not the client’s fault | contract.TypeInternal |
Step 3 — A complete handler example
Section titled “Step 3 — A complete handler example”func (h APIHandler) Greet(w http.ResponseWriter, r *http.Request) { name := r.URL.Query().Get("name") if name == "" { _ = contract.WriteError(w, r, contract.NewErrorBuilder(). Type(contract.TypeRequired). Detail("field", "name"). Message("name is required"). Build()) return }
_ = contract.WriteResponse(w, r, http.StatusOK, greetResponse{Message: "hello, " + name}, nil)}Always return after WriteError. The response has already been written; continuing the handler produces undefined behavior.
Step 4 — Propagate domain errors to the transport layer
Section titled “Step 4 — Propagate domain errors to the transport layer”Keep domain error types in your domain package and translate them to APIError in the handler:
func (h *ItemHandler) Get(w http.ResponseWriter, r *http.Request) { item, err := h.Repo.Find(r.Context(), id) switch { case errors.Is(err, repo.ErrNotFound): _ = contract.WriteError(w, r, contract.NewErrorBuilder(). Type(contract.TypeNotFound). Message("item not found"). Build()) case err != nil: _ = contract.WriteError(w, r, contract.NewErrorBuilder(). Type(contract.TypeInternal). Message("could not load item"). Build()) default: _ = contract.WriteResponse(w, r, http.StatusOK, item, nil) }}The handler translates; the domain stays clean.
Step 5 — Wrap multiple domain error types
Section titled “Step 5 — Wrap multiple domain error types”For handlers that deal with varied error conditions, a helper function keeps the translation logic in one place:
func writeRepoError(w http.ResponseWriter, r *http.Request, err error, resourceName string) { switch { case errors.Is(err, repo.ErrNotFound): _ = contract.WriteError(w, r, contract.NewErrorBuilder(). Type(contract.TypeNotFound). Message(resourceName + " not found"). Build()) case errors.Is(err, repo.ErrDuplicate): _ = contract.WriteError(w, r, contract.NewErrorBuilder(). Type(contract.TypeAlreadyExists). Message(resourceName + " already exists"). Build()) default: _ = contract.WriteError(w, r, contract.NewErrorBuilder(). Type(contract.TypeInternal). Message("could not load " + resourceName). Build()) }}
// Usage in a handler:func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) { user, err := h.Repo.Find(r.Context(), id) if err != nil { writeRepoError(w, r, err, "user") return } _ = contract.WriteResponse(w, r, http.StatusOK, user, nil)}Keep the translation function in your application code, not in a shared library — each service’s domain errors are its own.
What this pattern gives you
Section titled “What this pattern gives you”- One serialization path for every error in the system — no per-handler error envelope variants.
- HTTP status codes derived from
ErrorTypeautomatically; you cannot accidentally send a 200 for an error. request_idis injected into every error response without any action from the handler.- The builder prevents partially constructed
APIErrorvalues from escaping into responses.
If this does not work
Section titled “If this does not work”| Symptom | Check first |
|---|---|
| Error returns HTTP 200 | Use contract.WriteError, not contract.WriteResponse, for failures |
| Error body has the wrong shape | Build with contract.NewErrorBuilder().Type(...).Build() and write the result with WriteError |
| Handler writes twice | return immediately after WriteError |
request_id is missing | Confirm middleware/requestid ran before the handler |
| Error type is too broad | Prefer TypeRequired, TypeInvalidFormat, or TypeOutOfRange before falling back to TypeValidation |
Complete example in the reference app
Section titled “Complete example in the reference app”The reference service handler at reference/standard-service/internal/handler/api.go shows WriteError and NewErrorBuilder in a complete, runnable handler. The health handler at reference/standard-service/internal/handler/health.go shows the same pattern for liveness and readiness endpoints.