This is the lesson where everything comes together. Over the previous seven lessons we’ve looked at sentinels and typed errors, wrapping strategy, error classification, where to log, how to translate at layer boundaries, and when panic is actually defensible. Now I want to show you what all of that looks like assembled into a complete, production-ready error system for a real API service.
I’m going to build the full error architecture for a hypothetical orders API. By the end you’ll have a template you can adapt directly — the error type hierarchy, the middleware, the structured logging, and the client-facing error codes. This is the code I wish I’d had when I started building services in Go.
The Problem
A service without a designed error system looks like this: scattered errors.New calls with different string conventions, inconsistent HTTP status codes, raw database errors in API responses sometimes, duplicate log lines everywhere, and a 500 Internal Server Error for user mistakes because nobody translated the error before returning it.
// WRONG — no coherent error system
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
var req struct {
UserID string `json:"user_id"`
Amount float64 `json:"amount"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("decode error: %v", err) // log 1
http.Error(w, err.Error(), 400) // leaks parse details
return
}
if req.Amount <= 0 {
http.Error(w, "bad amount", 400) // no structure
return
}
order, err := h.svc.CreateOrder(r.Context(), req.UserID, req.Amount)
if err != nil {
log.Printf("create order error: %v", err) // log 2 (maybe also logged in service)
if strings.Contains(err.Error(), "not found") { // string matching
http.Error(w, "user not found", 404)
return
}
http.Error(w, "internal error", 500)
return
}
json.NewEncoder(w).Encode(order)
}
Every handler looks slightly different. There’s no contract. Clients can’t reliably parse error responses. Operators can’t count error rates by kind.
The Idiomatic Way
Here’s the full error type hierarchy I use. Everything builds on a single AppError struct:
// errors/errors.go — the complete error system
package apperrors
import (
"errors"
"fmt"
"net/http"
)
// ErrorKind classifies the error for routing decisions at boundaries
type ErrorKind string
const (
KindValidation ErrorKind = "VALIDATION_ERROR"
KindNotFound ErrorKind = "NOT_FOUND"
KindUnauthorized ErrorKind = "UNAUTHORIZED"
KindForbidden ErrorKind = "FORBIDDEN"
KindConflict ErrorKind = "CONFLICT"
KindTransient ErrorKind = "TRANSIENT"
KindInternal ErrorKind = "INTERNAL"
)
// AppError is the single error type used throughout the service
type AppError struct {
Kind ErrorKind // used for routing at boundaries
Code string // machine-readable code for clients ("ORDER_AMOUNT_INVALID")
Message string // human-readable, safe to return to clients
Detail string // internal detail for logging only — never sent to clients
Err error // underlying cause — for logging and chain walking
}
func (e *AppError) Error() string {
if e.Detail != "" && e.Err != nil {
return fmt.Sprintf("%s: %v", e.Detail, e.Err)
}
if e.Detail != "" {
return e.Detail
}
return e.Message
}
func (e *AppError) Unwrap() error { return e.Err }
// HTTPStatus maps the error kind to an HTTP status code
func (e *AppError) HTTPStatus() int {
switch e.Kind {
case KindValidation:
return http.StatusBadRequest
case KindNotFound:
return http.StatusNotFound
case KindUnauthorized:
return http.StatusUnauthorized
case KindForbidden:
return http.StatusForbidden
case KindConflict:
return http.StatusConflict
case KindTransient:
return http.StatusServiceUnavailable
default:
return http.StatusInternalServerError
}
}
// IsUserFacing reports whether this error is safe to describe to the client
func (e *AppError) IsUserFacing() bool {
switch e.Kind {
case KindValidation, KindNotFound, KindUnauthorized, KindForbidden, KindConflict:
return true
default:
return false
}
}
// Constructors — one per kind, self-documenting at the call site
func NotFound(code, message, detail string) *AppError {
return &AppError{Kind: KindNotFound, Code: code, Message: message, Detail: detail}
}
func Validation(code, message, detail string) *AppError {
return &AppError{Kind: KindValidation, Code: code, Message: message, Detail: detail}
}
func Conflict(code, message, detail string) *AppError {
return &AppError{Kind: KindConflict, Code: code, Message: message, Detail: detail}
}
func Transient(detail string, cause error) *AppError {
return &AppError{
Kind: KindTransient,
Code: "SERVICE_UNAVAILABLE",
Message: "service temporarily unavailable, please try again",
Detail: detail,
Err: cause,
}
}
func Internal(detail string, cause error) *AppError {
return &AppError{
Kind: KindInternal,
Code: "INTERNAL_ERROR",
Message: "an unexpected error occurred",
Detail: detail,
Err: cause,
}
}
// Sentinel errors — package-level for fast identity checks
var (
ErrOrderNotFound = NotFound("ORDER_NOT_FOUND", "order not found", "order not found")
ErrUserNotFound = NotFound("USER_NOT_FOUND", "user not found", "user not found")
)
Now the repository, service, and handler layers:
// repository/orders.go
func (r *OrderRepo) GetByID(ctx context.Context, id string) (*Order, error) {
var o Order
err := r.db.QueryRowContext(ctx,
`SELECT id, user_id, amount, status FROM orders WHERE id=$1`, id,
).Scan(&o.ID, &o.UserID, &o.Amount, &o.Status)
if errors.Is(err, sql.ErrNoRows) {
return nil, apperrors.ErrOrderNotFound
}
if err != nil {
return nil, translateDBError(err, fmt.Sprintf("get order %s", id))
}
return &o, nil
}
func (r *OrderRepo) Create(ctx context.Context, o *Order) error {
_, err := r.db.ExecContext(ctx,
`INSERT INTO orders (id, user_id, amount, status) VALUES ($1, $2, $3, $4)`,
o.ID, o.UserID, o.Amount, o.Status,
)
if err != nil {
return translateDBError(err, fmt.Sprintf("create order %s", o.ID))
}
return nil
}
func translateDBError(err error, op string) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case "23505":
return apperrors.Conflict("DUPLICATE_ORDER",
"this order already exists",
fmt.Sprintf("%s: constraint %s", op, pgErr.ConstraintName))
}
}
if isConnectionError(err) {
return apperrors.Transient(op, err)
}
return apperrors.Internal(op, err)
}
// service/orders.go
type CreateOrderRequest struct {
UserID string
Amount float64
Items []OrderItem
}
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) (*Order, error) {
// domain validation — returns Validation errors
if err := validateCreateOrder(req); err != nil {
return nil, err
}
// user must exist
user, err := s.users.GetByID(ctx, req.UserID)
if err != nil {
return nil, err // already classified by user repo
}
if !user.CanPlaceOrders {
return nil, apperrors.Forbidden("ACCOUNT_SUSPENDED",
"your account is not allowed to place orders",
fmt.Sprintf("user %s account suspended", req.UserID))
}
order := &Order{
ID: newOrderID(),
UserID: req.UserID,
Amount: req.Amount,
Status: "pending",
}
if err := s.orders.Create(ctx, order); err != nil {
return nil, err
}
return order, nil
}
func validateCreateOrder(req CreateOrderRequest) error {
if req.UserID == "" {
return apperrors.Validation("MISSING_USER_ID", "user_id is required",
"create order: empty user_id")
}
if req.Amount <= 0 {
return apperrors.Validation("INVALID_AMOUNT",
fmt.Sprintf("amount must be positive, got %.2f", req.Amount),
fmt.Sprintf("create order: invalid amount %.2f", req.Amount))
}
if len(req.Items) == 0 {
return apperrors.Validation("NO_ITEMS", "order must contain at least one item",
"create order: empty items")
}
return nil
}
In The Wild
Here’s the middleware and handler that tie everything together. This is the code that runs on every request:
// middleware/error.go
type ErrorResponse struct {
Error string `json:"error"` // human-readable
Code string `json:"code"` // machine-readable, for client logic
TraceID string `json:"trace_id,omitempty"` // correlate with logs
}
// ErrorMiddleware wraps handlers with panic recovery and response normalization
func ErrorMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
buf := make([]byte, 4096)
n := runtime.Stack(buf, false)
logger.Error("panic in handler",
"panic", fmt.Sprintf("%v", rec),
"stack", string(buf[:n]),
"path", r.URL.Path,
"trace_id", traceID(r.Context()),
)
writeErrorResponse(w, r, apperrors.Internal("panic", nil), logger)
}
}()
next.ServeHTTP(w, r)
})
}
}
func writeErrorResponse(w http.ResponseWriter, r *http.Request, err error, logger *slog.Logger) {
tid := traceID(r.Context())
var ae *apperrors.AppError
if !errors.As(err, &ae) {
// unclassified — always log
logger.Error("unclassified error",
"trace_id", tid,
"error", err.Error(),
"path", r.URL.Path,
)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(ErrorResponse{
Error: "an unexpected error occurred",
Code: "INTERNAL_ERROR",
TraceID: tid,
})
return
}
// Log internal and transient errors — these are operational problems
if !ae.IsUserFacing() {
logger.Error("service error",
"trace_id", tid,
"kind", ae.Kind,
"code", ae.Code,
"detail", ae.Detail,
"cause", fmt.Sprintf("%v", ae.Err),
"path", r.URL.Path,
"method", r.Method,
)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(ae.HTTPStatus())
json.NewEncoder(w).Encode(ErrorResponse{
Error: ae.Message,
Code: ae.Code,
TraceID: tid,
})
}
// handler/orders.go
func (h *OrderHandler) CreateOrder(w http.ResponseWriter, r *http.Request) {
var req struct {
UserID string `json:"user_id"`
Amount float64 `json:"amount"`
Items []OrderItem `json:"items"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorResponse(w, r, apperrors.Validation("INVALID_JSON",
"request body is not valid JSON", fmt.Sprintf("decode: %v", err)), h.logger)
return
}
order, err := h.svc.CreateOrder(r.Context(), service.CreateOrderRequest{
UserID: req.UserID,
Amount: req.Amount,
Items: req.Items,
})
if err != nil {
writeErrorResponse(w, r, err, h.logger)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(order)
}
The Gotchas
Keep error codes stable across releases. Once a client is checking if err.code === "ORDER_NOT_FOUND", changing that string is a breaking API change. Treat error codes like versioned API contract items — document them, don’t change them without a migration path.
Don’t make the error type hierarchy too deep. One AppError with a Kind field and a Code string is sufficient for most services. Resist the urge to create ValidationError, NotFoundError, ConflictError as separate types. The kind discrimination is already there in the Kind field — multiple types just mean multiple errors.As chains to maintain.
Test the error mapping explicitly. Write a table test that verifies each error kind maps to the expected HTTP status, response body, and log behavior. This is the contract your clients and operators depend on.
func TestErrorMapping(t *testing.T) {
tests := []struct {
err error
wantStatus int
wantCode string
wantLogged bool
}{
{
err: apperrors.Validation("INVALID_AMOUNT", "amount must be positive", ""),
wantStatus: http.StatusBadRequest,
wantCode: "INVALID_AMOUNT",
wantLogged: false, // user error — don't log
},
{
err: apperrors.ErrOrderNotFound,
wantStatus: http.StatusNotFound,
wantCode: "ORDER_NOT_FOUND",
wantLogged: false,
},
{
err: apperrors.Internal("db timeout", io.ErrUnexpectedEOF),
wantStatus: http.StatusInternalServerError,
wantCode: "INTERNAL_ERROR",
wantLogged: true, // operator error — do log
},
}
// ... run tests against writeErrorResponse
}
Key Takeaway
A production error system has three parts that work together: a single error type with a Kind field for routing, a Code string for client consumption, and Message/Detail split between what’s safe for clients and what’s for your logs. Layer constructors create errors with the right kind. Repositories translate driver errors at their boundary. Services create domain errors for business rule violations. The middleware logs internal errors and maps all errors to structured HTTP responses. That’s the complete pattern — and it’s designed to stay coherent as your service grows from one handler to fifty.
Previous: Lesson 7: Panic, Recover, and When They’re Actually Justified
You’ve reached the end of the Go Error Design at Scale series. You now have the full picture: from the difference between sentinels and typed errors, through wrapping strategy, error classification, where to log, boundary translation, panic handling, and finally the complete production architecture. Take the patterns from Lesson 8 and adapt them to your service. The error system is worth getting right — it’s the connective tissue of every layer in your codebase.