ankush.fyi — writings
31 jan 2026 ยท 7 min read go error handling slog observability patterns

Error Handling in Go: Patterns That Scale

Go 1.22 didn't change how errors work โ€” but how you handle them at scale still matters enormously. Here's the error handling architecture behind a system processing 40M events/day.

Error handling in Go is a feature, not a flaw. The explicitness forces you to think about failure modes at every callsite. But at scale, you need more than if err != nil { return err }.

Here’s what our error architecture looks like after three years of production evolution.

The domain error pattern

The core idea: errors carry both a code (for programmatic handling) and a message (for humans/logs). Never use raw errors.New in domain logic.

type Code string

const (
    CodeNotFound      Code = "NOT_FOUND"
    CodeUnauthorized  Code = "UNAUTHORIZED"
    CodeInvalidInput  Code = "INVALID_INPUT"
    CodeConflict      Code = "CONFLICT"
    CodeInternal      Code = "INTERNAL"
)

type DomainError struct {
    Code    Code
    Message string
    Op      string    // operation where error occurred
    Err     error     // underlying cause
}

func (e *DomainError) Error() string {
    if e.Op != "" {
        return fmt.Sprintf("%s: %s", e.Op, e.Message)
    }
    return e.Message
}

func (e *DomainError) Unwrap() error { return e.Err }

func (e *DomainError) Is(target error) bool {
    t, ok := target.(*DomainError)
    if !ok {
        return false
    }
    return e.Code == t.Code
}

Usage in domain logic:

func (s *UserService) GetByEmail(ctx context.Context, email string) (*User, error) {
    user, err := s.repo.FindByEmail(ctx, email)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, &DomainError{
                Code:    CodeNotFound,
                Message: "no user with that email",
                Op:      "UserService.GetByEmail",
                Err:     err,
            }
        }
        return nil, &DomainError{
            Code:    CodeInternal,
            Message: "database error",
            Op:      "UserService.GetByEmail",
            Err:     err,
        }
    }
    return user, nil
}

Translating domain errors to HTTP

A single function converts domain errors to HTTP responses. The handler stays clean:

func HTTPStatusFromError(err error) int {
    var de *DomainError
    if !errors.As(err, &de) {
        return http.StatusInternalServerError
    }
    switch de.Code {
    case CodeNotFound:
        return http.StatusNotFound
    case CodeUnauthorized:
        return http.StatusUnauthorized
    case CodeInvalidInput:
        return http.StatusBadRequest
    case CodeConflict:
        return http.StatusConflict
    default:
        return http.StatusInternalServerError
    }
}

// In your handler:
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    user, err := h.svc.GetByEmail(r.Context(), r.URL.Query().Get("email"))
    if err != nil {
        status := HTTPStatusFromError(err)
        h.respond(w, status, map[string]string{"error": err.Error()})
        return
    }
    h.respond(w, http.StatusOK, user)
}

Structured logging with slog (Go 1.21+)

Stop logging raw error strings. Log structured fields so you can query them:

func (s *OrderService) Process(ctx context.Context, orderID string) error {
    order, err := s.repo.Get(ctx, orderID)
    if err != nil {
        slog.ErrorContext(ctx, "failed to fetch order",
            slog.String("order_id", orderID),
            slog.String("op", "OrderService.Process"),
            slog.Any("error", err),
        )
        return fmt.Errorf("OrderService.Process: %w", err)
    }
    // ...
    return nil
}

This logs as JSON in production (slog.NewJSONHandler) and as human text locally (slog.NewTextHandler). Switch with an env var:

func newLogger(env string) *slog.Logger {
    if env == "production" {
        return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
            Level: slog.LevelInfo,
            ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
                if a.Key == slog.TimeKey {
                    a.Value = slog.StringValue(a.Value.Time().UTC().Format(time.RFC3339))
                }
                return a
            },
        }))
    }
    return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelDebug,
    }))
}

Panic recovery as middleware

Services die from panics in unexpected places. A recovery middleware keeps them alive and gives you a stack trace:

func Recovery(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)
                slog.Error("panic recovered",
                    slog.Any("panic", rec),
                    slog.String("stack", string(buf[:n])),
                    slog.String("url", r.URL.String()),
                )
                http.Error(w, "internal server error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

The rule I follow

Wrap at every boundary crossing. When control crosses from one package to another, return a wrapped error with the operation name. This gives you a call chain in the error message without needing a stack trace.

// storage/user.go
func (r *UserRepo) FindByID(ctx context.Context, id string) (*User, error) {
    // ...
    if err != nil {
        return nil, fmt.Errorf("storage.UserRepo.FindByID id=%s: %w", id, err)
    }
}

// service/user.go
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
    u, err := s.repo.FindByID(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("service.UserService.GetUser: %w", err)
    }
    return u, nil
}

The resulting error message reads like a call stack: service.UserService.GetUser: storage.UserRepo.FindByID id=abc123: sql: no rows in result set

No surprises when something breaks at 3am.

โ† all writings