Go Error Handling Architecture: Boundaries and Patterns

Handle errors at the right boundary.

Page content

Go error handling is easy to complain about. Every Go developer has written this code hundreds of times:

if err != nil {
	return err
}

That is not the interesting part. The interesting part is what the error means, where it should be handled, where it should be wrapped, where it should be translated, where it should be logged, and what should be exposed to the caller — that is the architecture question.

Go treats errors as values. That makes failures explicit. It also means your codebase needs a clear error-handling design. Without one, errors become random strings, HTTP handlers leak database details, logs duplicate the same failure five times, retries happen for the wrong reasons, and callers inspect text instead of behavior.

Go error handling architecture: errors flowing between layers

This article is not a beginner introduction to if err != nil.

It is a practical guide to Go error handling architecture: wrapping, sentinels, custom error types, errors.Is, errors.As, error boundaries, API mapping, logging, retries, security, and production patterns.

The slightly opinionated version: do not try to make Go errors disappear. Make them meaningful at the right boundary.

What Go errors are

In Go, an error is just a value implementing this interface:

type error interface {
	Error() string
}

That small interface is the reason Go error handling feels so direct.

Functions return errors explicitly:

func LoadUser(id string) (*User, error) {
	// ...
}

Callers decide what to do:

user, err := LoadUser(id)
if err != nil {
	return nil, err
}

There are no exceptions and no hidden stack unwinding. Failure is part of the function signature.

That is good, but it also means errors need design. If every package returns arbitrary messages, callers cannot make reliable decisions. If every layer wraps every error without discipline, operators get noisy messages and developers get confused chains. If no layer wraps errors, failures lose context.

The goal is not less error handling, but better error meaning.

The three jobs of an error

A useful error usually has one or more jobs.

Job 1: Explain what failed

For humans, the error should explain what operation failed.

Example:

return fmt.Errorf("load user %s: %w", id, err)

This gives context. It says the failure happened while loading a user.

Job 2: Preserve the cause

For code, the error should preserve the underlying cause when that cause matters.

Example:

return fmt.Errorf("load user %s: %w", id, err)

The %w wraps the original error so callers can inspect it with errors.Is or errors.As.

Job 3: Let a boundary make a decision

At some boundary, the program must decide what to do.

Examples:

  • Return HTTP 404
  • Return HTTP 409
  • Retry the operation
  • Log at warning level
  • Show a user-safe message
  • Abort the transaction
  • Send the error to monitoring
  • Ignore cancellation

That decision should usually be based on error identity or type, not string matching.

The main error tools in modern Go

Modern Go gives you a small but powerful set of tools.

errors.New

Use errors.New to create a simple error value:

var ErrNotFound = errors.New("not found")

This is useful for sentinel errors.

fmt.Errorf with %w

Use fmt.Errorf with %w to wrap an error:

return fmt.Errorf("query user: %w", err)

Wrapping adds context while preserving the original error for inspection.

errors.Is

Use errors.Is to check whether an error matches a specific target somewhere in its chain:

if errors.Is(err, ErrNotFound) {
	// handle not found
}

Use this for sentinel errors and known conditions.

errors.As

Use errors.As to extract a specific error type from a chain:

var validationErr *ValidationError
if errors.As(err, &validationErr) {
	// use validationErr.Field or validationErr.Reason
}

Use this when the error carries structured data.

errors.Join

Use errors.Join when multiple errors happened and all should be preserved:

return errors.Join(closeErr, flushErr)

Joined errors can still be inspected with errors.Is and errors.As.

Use this carefully. A joined error means several failures are part of one result.

Sentinel errors

A sentinel error is a package-level error value that represents a known condition.

Example:

var ErrUserNotFound = errors.New("user not found")
var ErrDuplicateEmail = errors.New("duplicate email")

Sentinel errors are useful when the caller only needs to know what category of failure happened.

Example:

func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := r.queryUser(ctx, id)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrUserNotFound
		}
		return nil, fmt.Errorf("query user: %w", err)
	}

	return user, nil
}

Then a service or handler can check:

if errors.Is(err, ErrUserNotFound) {
	// return 404
}

When to use sentinel errors

Use sentinel errors when:

  • The condition is stable.
  • The caller needs to branch on it.
  • No extra structured data is needed.
  • The error belongs to your package or domain.

Good examples:

var ErrNotFound = errors.New("not found")
var ErrAlreadyExists = errors.New("already exists")
var ErrPermissionDenied = errors.New("permission denied")
var ErrConflict = errors.New("conflict")

When not to use sentinel errors

Do not create sentinels for every possible failure.

Bad:

var ErrCouldNotOpenFile = errors.New("could not open file")
var ErrCouldNotReadFile = errors.New("could not read file")
var ErrCouldNotParseLine = errors.New("could not parse line")

If callers do not branch on these, they may just be messages.

Also be careful about exporting too many sentinels. Exported sentinel errors become part of your package API.

Custom error types

A custom error type is useful when the error carries structured information.

Example:

type ValidationError struct {
	Field  string
	Reason string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("validation failed for %s: %s", e.Field, e.Reason)
}

Caller:

var validationErr *ValidationError
if errors.As(err, &validationErr) {
	fmt.Println(validationErr.Field)
}

This is better than parsing an error string.

When to use custom error types

Use custom error types when:

  • Callers need structured data.
  • The error has meaningful fields.
  • The type is part of your package contract.
  • The caller may need to handle multiple values differently.

Examples:

  • Validation error with field name
  • Rate limit error with retry time
  • HTTP error with status code
  • Parse error with line and column
  • Domain error with resource ID

When not to use custom error types

Do not create custom types just to avoid errors.New.

This is unnecessary:

type NotFoundError struct{}

func (e NotFoundError) Error() string {
	return "not found"
}

If there is no useful data, a sentinel is often enough.

Error wrapping

Wrapping adds context to an error while preserving the original error.

Example:

func LoadConfig(path string) error {
	data, err := os.ReadFile(path)
	if err != nil {
		return fmt.Errorf("read config %s: %w", path, err)
	}

	if err := parseConfig(data); err != nil {
		return fmt.Errorf("parse config %s: %w", path, err)
	}

	return nil
}

If os.ReadFile fails, the caller gets both:

  • the high-level operation: read config
  • the low-level cause: permission denied, file not found, etc.

Both are available through the error chain, which is what makes wrapping with %w worth doing consistently.

Wrap with useful context

Good wrapping says what operation failed:

return fmt.Errorf("create invoice %s: %w", invoiceID, err)

Bad wrapping adds noise:

return fmt.Errorf("error: %w", err)

This tells the caller nothing.

Also avoid repeating the same noun at every layer:

return fmt.Errorf("user service: get user: user repository: query user: %w", err)

That kind of chain is technically correct and practically annoying.

Wrap where context changes meaning. If you cannot explain in one phrase what operation failed, you are probably either wrapping too aggressively or not enough.

When to wrap and when not to wrap

This is one of the most important architecture decisions.

Wrap when crossing a meaningful boundary

Wrap when the error moves from one operation to a higher-level operation.

Example:

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

	return user, nil
}

The repository error is now part of a service operation, and that added context is useful when operators trace a failure back through the logs.

Do not wrap just to say “failed”

Bad:

if err != nil {
	return fmt.Errorf("failed: %w", err)
}

The word “failed” is usually implied by the fact that an error exists.

Do not wrap if you are translating

Sometimes you should translate one error into another domain error.

Example:

if errors.Is(err, sql.ErrNoRows) {
	return nil, ErrUserNotFound
}

This intentionally hides the database detail and exposes a domain condition.

You may still preserve the cause if useful, but do it deliberately.

Do not expose implementation details accidentally

If you wrap a low-level error with %w, callers can inspect it.

That is usually good inside your application.

But in a public package API, wrapping may expose implementation details as part of your contract.

For example, if your package wraps sql.ErrNoRows, callers may start depending on it:

if errors.Is(err, sql.ErrNoRows) {
	// caller now knows you use database/sql
}

If you may change storage later, prefer a domain sentinel:

var ErrUserNotFound = errors.New("user not found")

Then return that from the package boundary.

Error boundaries

The most useful way to think about Go error handling is through boundaries.

A boundary is a place where an error changes meaning or audience.

Common boundaries include:

  • database to repository
  • repository to service
  • service to HTTP handler
  • service to CLI command
  • internal error to user-facing message
  • transient failure to retry decision
  • operation failure to log event
  • domain error to API response

Error architecture is mostly boundary design. Each boundary is a decision point where errors either gain context, lose implementation details, or get translated into a form the next layer can act on.

Repository boundary

The repository talks to storage.

It should usually translate database-specific errors into domain errors.

Example:

var ErrUserNotFound = errors.New("user not found")
var ErrDuplicateEmail = errors.New("duplicate email")

type UserRepository struct {
	db *sql.DB
}

func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
	const query = `
		select id, email, name
		from users
		where id = $1
	`

	var user User

	err := r.db.QueryRowContext(ctx, query, id).Scan(
		&user.ID,
		&user.Email,
		&user.Name,
	)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrUserNotFound
		}

		return nil, fmt.Errorf("query user by id: %w", err)
	}

	return &user, nil
}

The repository hides sql.ErrNoRows and exposes ErrUserNotFound — a clean boundary that means the service does not need to know anything about how storage represents “not found”.

Service boundary

The service owns business meaning.

It should usually add operation context and preserve domain errors.

Example:

type UserService struct {
	repo *UserRepository
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := s.repo.GetUser(ctx, id)
	if err != nil {
		if errors.Is(err, ErrUserNotFound) {
			return nil, err
		}

		return nil, fmt.Errorf("get user %s: %w", id, err)
	}

	return user, nil
}

This preserves the domain condition while adding context for unexpected errors.

For more complex business rules, the service may create domain errors directly:

var ErrAccountDisabled = errors.New("account disabled")

func (s *UserService) Login(ctx context.Context, email string) (*Session, error) {
	user, err := s.repo.GetUserByEmail(ctx, email)
	if err != nil {
		return nil, fmt.Errorf("get user by email: %w", err)
	}

	if user.Disabled {
		return nil, ErrAccountDisabled
	}

	// ...
	return session, nil
}

The service is the right place for business-level errors — created directly from domain logic rather than translated from infrastructure conditions.

HTTP handler boundary

The HTTP handler translates application errors into HTTP responses.

This is a boundary where internal details should become user-safe responses.

Example:

func GetUserHandler(svc *UserService) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		user, err := svc.GetUser(r.Context(), r.PathValue("id"))
		if err != nil {
			writeHTTPError(w, err)
			return
		}

		writeJSON(w, http.StatusOK, user)
	}
}

Error mapping:

func writeHTTPError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, ErrUserNotFound):
		http.Error(w, "user not found", http.StatusNotFound)

	case errors.Is(err, ErrAccountDisabled):
		http.Error(w, "account disabled", http.StatusForbidden)

	case errors.Is(err, context.Canceled):
		return

	case errors.Is(err, context.DeadlineExceeded):
		http.Error(w, "request timed out", http.StatusGatewayTimeout)

	default:
		http.Error(w, "internal server error", http.StatusInternalServerError)
	}
}

The handler maps domain errors to HTTP semantics rather than exposing raw database or internal error details. This is where many Go applications go wrong — they either expose too much internal detail or collapse all errors into HTTP 500. For a complete picture of handler patterns and middleware in Go APIs, Building REST APIs in Go covers authentication, routing, and error handling across the standard library, Gin, Echo, and Fiber.

CLI boundary

A CLI has a different boundary than an HTTP API.

In a CLI, the error should be useful to the person running the command.

Example:

func RunImport(ctx context.Context, args []string) error {
	if len(args) == 0 {
		return ErrMissingInputFile
	}

	if err := importFile(ctx, args[0]); err != nil {
		return fmt.Errorf("import %s: %w", args[0], err)
	}

	return nil
}

At the command boundary:

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, formatCLIError(err))
		os.Exit(exitCode(err))
	}
}

Map known errors to exit codes:

func exitCode(err error) int {
	switch {
	case errors.Is(err, ErrMissingInputFile):
		return 2
	case errors.Is(err, ErrValidation):
		return 3
	default:
		return 1
	}
}

A CLI can often show more detail than a public API, but it should still avoid leaking secrets.

API error type pattern

For HTTP APIs, a small app-level error type can be useful.

Example:

type APIError struct {
	Status  int
	Code    string
	Message string
	Err     error
}

func (e *APIError) Error() string {
	if e.Err == nil {
		return e.Message
	}

	return e.Message + ": " + e.Err.Error()
}

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

Constructor:

func NewAPIError(status int, code string, message string, err error) *APIError {
	return &APIError{
		Status:  status,
		Code:    code,
		Message: message,
		Err:     err,
	}
}

Usage:

return NewAPIError(
	http.StatusConflict,
	"duplicate_email",
	"email is already registered",
	ErrDuplicateEmail,
)

Handler:

func writeAPIError(w http.ResponseWriter, err error) {
	var apiErr *APIError
	if errors.As(err, &apiErr) {
		writeJSON(w, apiErr.Status, map[string]string{
			"code":    apiErr.Code,
			"message": apiErr.Message,
		})
		return
	}

	writeJSON(w, http.StatusInternalServerError, map[string]string{
		"code":    "internal_error",
		"message": "internal server error",
	})
}

This pattern is useful when you want structured API errors with stable codes.

Use it at the API boundary. Do not force every internal package to return API-specific errors.

Domain errors vs transport errors

Keep domain errors separate from transport errors.

Domain error:

var ErrInsufficientBalance = errors.New("insufficient balance")

Transport mapping:

if errors.Is(err, ErrInsufficientBalance) {
	http.Error(w, "insufficient balance", http.StatusConflict)
	return
}

Do not make your domain layer return HTTP status codes:

return &APIError{Status: http.StatusConflict}

That couples business logic to HTTP and prevents your service layer from working cleanly across HTTP, CLI, workers, tests, and future gRPC adapters. Transport mapping belongs at the transport boundary, not in domain code. For guidance on where to define domain errors, sentinels, and transport adapters within your project layout, Go Project Structure: Practices & Patterns covers the internal/, pkg/, and adapter conventions that keep these layers cleanly separated.

Retryable errors

Some errors should trigger retry. Some should not.

Do not decide this by matching strings.

Use a marker interface or explicit function.

Example:

type RetryableError struct {
	Err error
}

func (e *RetryableError) Error() string {
	return e.Err.Error()
}

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

Helper:

func Retryable(err error) error {
	if err == nil {
		return nil
	}

	return &RetryableError{Err: err}
}

func IsRetryable(err error) bool {
	var retryable *RetryableError
	return errors.As(err, &retryable)
}

Usage:

if err := callRemoteAPI(ctx); err != nil {
	if isTemporaryNetworkError(err) {
		return Retryable(fmt.Errorf("call remote api: %w", err))
	}

	return fmt.Errorf("call remote api: %w", err)
}

Retry loop:

err := doWork(ctx)
if err != nil {
	if IsRetryable(err) {
		// retry with backoff
	}
	return err
}

This is much better than checking whether the error string contains “timeout” — string matching breaks silently when messages change and creates invisible coupling between producer and consumer.

Validation errors

Validation errors often need structured data.

Example:

type FieldError struct {
	Field   string
	Message string
}

type ValidationError struct {
	Fields []FieldError
}

func (e *ValidationError) Error() string {
	return "validation failed"
}

Usage:

func ValidateCreateUser(req CreateUserRequest) error {
	var fields []FieldError

	if req.Email == "" {
		fields = append(fields, FieldError{
			Field:   "email",
			Message: "email is required",
		})
	}

	if len(fields) > 0 {
		return &ValidationError{Fields: fields}
	}

	return nil
}

Handler:

var validationErr *ValidationError
if errors.As(err, &validationErr) {
	writeJSON(w, http.StatusBadRequest, validationErr)
	return
}

This is a good use of errors.As because the caller needs structured information — field names and validation messages — not just an opaque error string.

Multiple errors

Sometimes several things fail.

Examples:

  • closing multiple resources
  • validating many fields
  • shutting down several workers
  • running independent checks
  • flushing and closing output

Use errors.Join when all errors should be preserved.

Example:

func CloseAll(closers ...io.Closer) error {
	var errs []error

	for _, closer := range closers {
		if err := closer.Close(); err != nil {
			errs = append(errs, err)
		}
	}

	return errors.Join(errs...)
}

Caller:

if err := CloseAll(a, b, c); err != nil {
	return fmt.Errorf("close resources: %w", err)
}

Both errors.Is and errors.As can inspect joined errors, which means joined error values remain fully compatible with standard error-checking patterns.

When not to use errors.Join

Do not use errors.Join when there is one primary error and some logging context.

Do not use it to avoid deciding which error matters.

Do not return huge joined errors to users.

Joined errors are useful, but they can become noisy quickly.

Panic is not error handling

In normal application code, do not use panic for expected errors.

Bad:

if err != nil {
	panic(err)
}

Use panic for programmer errors or truly unrecoverable situations.

Examples:

  • impossible internal invariant violation
  • invalid package initialization
  • test helper failure with t.Fatal or panic in limited cases
  • unrecoverable startup configuration error, depending on style

Do not panic because a database query failed or a user submitted invalid input.

Those are normal errors.

Logging errors

A common Go mistake is logging the same error at every layer.

Bad:

func (r *Repo) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := r.query(ctx, id)
	if err != nil {
		log.Printf("query failed: %v", err)
		return nil, err
	}
	return user, nil
}

func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := s.repo.GetUser(ctx, id)
	if err != nil {
		log.Printf("service failed: %v", err)
		return nil, err
	}
	return user, nil
}

This creates duplicate logs for one failure.

Better:

  • wrap errors as they move up
  • log once at the boundary where the error is handled
  • include structured context in the log

Example:

func (s *Server) handleError(r *http.Request, err error) {
	s.logger.ErrorContext(
		r.Context(),
		"request failed",
		"method", r.Method,
		"path", r.URL.Path,
		"err", err,
	)
}

This gives one log event with the full error chain. For a production-ready structured logging setup, Structured Logging in Go with slog covers log/slog records, JSON handlers, context correlation, and redaction — all of which pair naturally with boundary-level error logging.

When to log inside lower layers

Log inside lower layers only when the layer is actually handling the error or adding important operational context that will not be visible elsewhere.

For example, a retry loop may log each retry attempt at debug or warning level.

But a repository should not log every query error if the handler will log the final request failure.

User-facing errors vs operator errors

Do not show internal errors directly to users.

Internal error:

query user by id: dial tcp 10.0.4.12:5432: connection refused

User-facing message:

internal server error

Operator log:

request failed err="get user 123: query user by id: dial tcp 10.0.4.12:5432: connection refused"

These are different audiences, and a good error architecture keeps them separate:

  • internal diagnostic error
  • user-safe response
  • stable API error code
  • operator log context

Forcing one error string to serve all these audiences produces either an exposure risk or a debugging nightmare. Design your error architecture around distinct values for distinct consumers.

Secure error handling

Errors can leak sensitive information.

Avoid exposing:

  • database connection strings
  • SQL queries with secrets
  • internal hostnames
  • file paths
  • access tokens
  • API keys
  • stack traces
  • private customer data
  • authorization policy details

This matters especially in HTTP APIs.

Bad:

http.Error(w, err.Error(), http.StatusInternalServerError)

Good:

http.Error(w, "internal server error", http.StatusInternalServerError)

Log the internal error securely for operators. Return a safe message to the user.

Error codes

For public APIs, stable error codes are often better than relying only on messages.

Example response:

{
  "code": "user_not_found",
  "message": "user not found"
}

The message can change. The code should be stable.

Use error codes for:

  • client behavior
  • documentation
  • SDKs
  • localization
  • support diagnostics

Do not make clients parse English error messages.

A practical layered error design

Here is a clean pattern for many Go backend services.

Repository layer

  • Talks to database or external storage.
  • Converts storage-specific not-found errors to domain errors.
  • Wraps unexpected storage errors with operation context.
  • Does not return HTTP errors.
  • Usually does not log.

Example:

if errors.Is(err, sql.ErrNoRows) {
	return nil, ErrUserNotFound
}

return nil, fmt.Errorf("query user by id: %w", err)

Service layer

  • Owns business rules.
  • Creates domain errors.
  • Preserves known domain errors.
  • Wraps unexpected lower-level errors.
  • Does not return HTTP status codes.
  • Usually does not log.

Example:

if user.Disabled {
	return nil, ErrAccountDisabled
}

Transport layer

  • Maps domain errors to HTTP, gRPC, or CLI responses.
  • Logs unhandled or unexpected errors.
  • Hides internal details from users.
  • Sets status codes and API error codes.

Example:

switch {
case errors.Is(err, ErrUserNotFound):
	writeError(w, http.StatusNotFound, "user_not_found", "user not found")
default:
	writeError(w, http.StatusInternalServerError, "internal_error", "internal server error")
}

This separation keeps error handling understandable and lets each layer evolve independently — you can change storage technology without touching service logic or transport mapping. The layered design works best when dependencies are injected rather than hard-coded; Dependency Injection in Go: Patterns & Best Practices covers the constructor and interface patterns that make each boundary easy to test in isolation.

Complete example

Here is a small end-to-end example.

Domain errors:

package users

import "errors"

var (
	ErrUserNotFound   = errors.New("user not found")
	ErrDuplicateEmail = errors.New("duplicate email")
	ErrAccountDisabled = errors.New("account disabled")
)

Repository:

package users

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
)

type Repository struct {
	db *sql.DB
}

func (r *Repository) GetByID(ctx context.Context, id string) (*User, error) {
	const query = `
		select id, email, name, disabled
		from users
		where id = $1
	`

	var user User

	err := r.db.QueryRowContext(ctx, query, id).Scan(
		&user.ID,
		&user.Email,
		&user.Name,
		&user.Disabled,
	)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrUserNotFound
		}

		return nil, fmt.Errorf("query user by id: %w", err)
	}

	return &user, nil
}

Service:

package users

import (
	"context"
	"errors"
	"fmt"
)

type Service struct {
	repo *Repository
}

func (s *Service) GetProfile(ctx context.Context, id string) (*Profile, error) {
	user, err := s.repo.GetByID(ctx, id)
	if err != nil {
		if errors.Is(err, ErrUserNotFound) {
			return nil, err
		}

		return nil, fmt.Errorf("get profile for user %s: %w", id, err)
	}

	if user.Disabled {
		return nil, ErrAccountDisabled
	}

	return &Profile{
		ID:    user.ID,
		Email: user.Email,
		Name:  user.Name,
	}, nil
}

HTTP handler:

package httpapi

import (
	"context"
	"errors"
	"net/http"

	"example.com/app/users"
)

type Handler struct {
	users *users.Service
}

func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) {
	profile, err := h.users.GetProfile(r.Context(), r.PathValue("id"))
	if err != nil {
		h.writeError(w, err)
		return
	}

	writeJSON(w, http.StatusOK, profile)
}

func (h *Handler) writeError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, users.ErrUserNotFound):
		writeJSON(w, http.StatusNotFound, map[string]string{
			"code":    "user_not_found",
			"message": "user not found",
		})

	case errors.Is(err, users.ErrAccountDisabled):
		writeJSON(w, http.StatusForbidden, map[string]string{
			"code":    "account_disabled",
			"message": "account is disabled",
		})

	case errors.Is(err, context.Canceled):
		return

	case errors.Is(err, context.DeadlineExceeded):
		writeJSON(w, http.StatusGatewayTimeout, map[string]string{
			"code":    "request_timeout",
			"message": "request timed out",
		})

	default:
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"code":    "internal_error",
			"message": "internal server error",
		})
	}
}

This structure gives you:

  • domain errors
  • storage translation
  • service context
  • safe HTTP mapping
  • inspectable error chains
  • no string matching
  • no transport leakage into domain code

That is the kind of error architecture that scales — straightforward enough for a new contributor to understand, yet structured enough that domain logic never leaks into transport responses.

Testing error behavior

Error behavior should be tested just as thoroughly as the happy path, because boundary decisions — sentinel mapping, type extraction, HTTP codes — are often where bugs hide longest. For a full guide to Go test structure, mocking, and coverage patterns, see Go Unit Testing: Structure & Best Practices.

Test sentinel mapping

func TestGetByIDNotFound(t *testing.T) {
	repo := newTestRepository(t)

	_, err := repo.GetByID(t.Context(), "missing")
	if !errors.Is(err, users.ErrUserNotFound) {
		t.Fatalf("got %v, want ErrUserNotFound", err)
	}
}

Test custom error extraction

func TestValidationError(t *testing.T) {
	err := ValidateCreateUser(CreateUserRequest{})

	var validationErr *ValidationError
	if !errors.As(err, &validationErr) {
		t.Fatalf("got %T, want ValidationError", err)
	}

	if len(validationErr.Fields) == 0 {
		t.Fatal("expected validation fields")
	}
}

Test HTTP mapping

func TestWriteErrorNotFound(t *testing.T) {
	rec := httptest.NewRecorder()

	writeHTTPError(rec, users.ErrUserNotFound)

	if rec.Code != http.StatusNotFound {
		t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
	}
}

Tests should prove that known errors produce the right behavior at each boundary, so that refactoring storage or transport layers cannot silently change the failure contract.

Common anti-patterns

Anti-pattern 1: String matching

Bad:

if strings.Contains(err.Error(), "not found") {
	// ...
}

Use errors.Is or errors.As instead — both handle wrapped error chains automatically and do not break when messages are reformatted or localized.

Anti-pattern 2: Losing the cause

Bad:

return errors.New("query failed")

Better:

return fmt.Errorf("query user: %w", err)

Anti-pattern 3: Wrapping without meaning

Bad:

return fmt.Errorf("error happened: %w", err)

Wrap with operation context that explains what was being attempted, such as "create invoice %s: %w" rather than a vague prefix that adds no diagnostic value.

Anti-pattern 4: Logging at every layer

Bad:

log.Println(err)
return err

at every level. Log once where the error is finally handled, not at every intermediate layer that simply passes it upward.

Anti-pattern 5: Returning HTTP errors from domain code

Bad:

return &APIError{Status: http.StatusNotFound}

from a domain service. Map domain errors to HTTP status codes and response bodies at the handler boundary, keeping your service layer independent of transport concerns.

Anti-pattern 6: Exposing internal errors to users

Bad:

http.Error(w, err.Error(), http.StatusInternalServerError)

Return safe generic messages to users and log the full internal error with structured context for operators. Never expose database connection strings, file paths, or raw stack traces in API responses.

Anti-pattern 7: Too many exported sentinels

Exported errors are part of your package API, and adding them commits you to maintaining them. Do not export every internal condition unless external callers genuinely need to branch on it — prefer keeping sentinels unexported until there is a clear need.

Anti-pattern 8: Using panic for expected failures

Bad:

panic(err)

for normal runtime failures. Reserve panic for truly unrecoverable conditions or programmer errors, not for missing records or invalid user input — always return errors in those cases.

Anti-pattern 9: Ignoring context errors

Bad:

return fmt.Errorf("request failed")

when the real cause was context.Canceled. Preserve context errors so that callers can distinguish between a genuine operation failure and a canceled or timed-out request, and respond appropriately to each.

Error review checklist

Use this checklist in code review.

Error creation

  • Is this a known condition?
  • Should it be a sentinel?
  • Does it need structured data?
  • Should it be a custom type?
  • Is the error message clear?

Error wrapping

  • Does the wrap add useful operation context?
  • Does %w preserve the cause where needed?
  • Is the code accidentally exposing implementation details?
  • Is the chain too noisy?

Error translation

  • Is a low-level error translated at the right boundary?
  • Is database-specific behavior hidden from service code?
  • Are domain errors independent of HTTP or CLI concerns?

Error handling

  • Does the caller branch with errors.Is or errors.As?
  • Are context cancellation and deadlines handled correctly?
  • Are retryable errors identified explicitly?
  • Are validation errors structured?

Logging

  • Is the error logged once, at the handling boundary?
  • Are logs structured?
  • Are sensitive details excluded from user responses?
  • Is there enough context for operators?

Testing

  • Are known error cases tested?
  • Are HTTP or CLI mappings tested?
  • Are validation details tested?
  • Are retry decisions tested?

My opinionated rules

Rule 1: Errors should cross boundaries with meaning

Do not just pass errors around. Decide what they mean at each layer.

Rule 2: Wrap for context, not decoration

If wrapping does not add useful information about what operation failed, do not wrap. An extra layer of context without meaning makes the error chain harder to read and adds no diagnostic value.

Rule 3: Translate implementation errors into domain errors

Do not let sql.ErrNoRows become part of your business logic. Translate implementation errors to domain errors at the storage boundary, so the rest of the application never needs to know which database or ORM is underneath.

Rule 4: Do not parse error strings

If code needs to branch on failure type, use sentinels, custom types, errors.Is, or errors.As. String inspection creates invisible coupling that breaks silently when error messages change.

Rule 5: Log once

Wrap as errors move up. Log where the error is finally handled.

Rule 6: Keep user messages safe

Internal diagnostic errors are for logs. User-facing messages are for users.

Rule 7: Keep transport errors at the transport boundary

HTTP status codes belong in handlers or API adapters, not in domain services. Domain code should be reusable across transports — today HTTP, tomorrow CLI, gRPC, or an event-driven worker.

Final thoughts

Go error handling is not about writing if err != nil forever — it is about making failure explicit and understandable at every boundary.

The mechanics are simple:

return errors
wrap with %w
check with errors.Is
extract with errors.As
join when several errors matter

The architecture is the harder part:

translate at boundaries
preserve causes
hide internals from users
log once
test known failures

That is Go error handling done well — not clever, not magical, but clear enough that the next developer, operator, API client, and future you can understand what failed and what should happen next. For a broader view of production Go patterns across integration, testing, and data access, see App Architecture in Production.

Sources

Subscribe

Get new posts on AI systems, Infrastructure, and AI engineering.