Skip to content

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.

  • The APIError shape and how it serializes
  • Using NewErrorBuilder to construct errors
  • The built-in ErrorType catalog and when to use each entry
  • Returning errors from handlers consistently

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.

SituationErrorType to use
Required query param or body field is absentcontract.TypeRequired
Input value has the wrong formatcontract.TypeInvalidFormat
Input value is outside the allowed rangecontract.TypeOutOfRange
Resource identified by ID does not existcontract.TypeNotFound
Request is not authenticatedcontract.TypeUnauthorized
Authenticated user lacks permissioncontract.TypeForbidden
Conflict with existing state (e.g. duplicate)contract.TypeConflict
Server-side failure that is not the client’s faultcontract.TypeInternal
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.

  • One serialization path for every error in the system — no per-handler error envelope variants.
  • HTTP status codes derived from ErrorType automatically; you cannot accidentally send a 200 for an error.
  • request_id is injected into every error response without any action from the handler.
  • The builder prevents partially constructed APIError values from escaping into responses.
SymptomCheck first
Error returns HTTP 200Use contract.WriteError, not contract.WriteResponse, for failures
Error body has the wrong shapeBuild with contract.NewErrorBuilder().Type(...).Build() and write the result with WriteError
Handler writes twicereturn immediately after WriteError
request_id is missingConfirm middleware/requestid ran before the handler
Error type is too broadPrefer TypeRequired, TypeInvalidFormat, or TypeOutOfRange before falling back to TypeValidation

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.