Back to Blog
· 10 min read · EN

Error Handling in Go Microservices: Patterns That Scale

Practical error handling patterns for Go microservices covering custom types, wrapping, propagation across service boundaries, structured logging, and client-facing responses.

BackendArchitecture #go#golang#error-handling#microservices#patterns#best-practices
Error Handling in Go Microservices: Patterns That Scale

Every Go developer has an opinion about if err != nil. Most of those opinions miss the point. The explicit error handling is not the challenge. The challenge is building error handling patterns that work across dozens of services, give you actionable debugging information, and never leak internal details to clients.

After building 15+ microservices in Go, I have settled on patterns that work at scale. These are not theoretical. They run in production handling payment processing, real-time messaging, and data pipelines. This article builds on the foundation from my production Go API guide.

Why Go Error Handling is a Feature

Before the patterns, let me address the criticism.

In Java or Python, exceptions propagate invisibly up the stack. You might not know a function can fail until it throws in production. Go makes failure paths explicit. Every function that can fail returns an error. You see it, you handle it, or you deliberately ignore it with _.

This means:

  • No hidden control flow
  • No need to read implementation details to know what can fail
  • Error handling is code-reviewed alongside business logic
  • The compiler forces you to acknowledge returned errors

The verbosity is the trade-off. But verbosity in error handling is a feature for production systems. I want to see exactly where failures are handled, not guess which catch block might fire three stack frames up.

Pattern 1: Sentinel Errors

Sentinel errors are predefined errors that callers check against. Use them for expected, well-known failure conditions:

package domain

import "errors"

var (
    ErrNotFound      = errors.New("not found")
    ErrAlreadyExists = errors.New("already exists")
    ErrUnauthorized  = errors.New("unauthorized")
    ErrForbidden     = errors.New("forbidden")
    ErrValidation    = errors.New("validation failed")
)

Callers use errors.Is() to check:

user, err := repo.GetByID(ctx, id)
if errors.Is(err, domain.ErrNotFound) {
    // Handle not found specifically
    return nil, status.Errorf(codes.NotFound, "user %s not found", id)
}
if err != nil {
    // Handle unexpected errors
    return nil, status.Errorf(codes.Internal, "failed to get user")
}

When to Use Sentinel Errors

  • The error condition is well-known and expected
  • Multiple callers need to handle it differently
  • No additional context is needed beyond “this happened”

When NOT to Use Sentinel Errors

  • You need to attach context (which field failed validation, what ID was not found)
  • The error is specific to one code path
  • You are wrapping errors from lower layers

Pattern 2: Custom Error Types with Context

When you need more information than “not found,” use custom error types:

package domain

import "fmt"

// ValidationError carries field-level details
type ValidationError struct {
    Field   string
    Message string
    Value   interface{}
}

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

func (e *ValidationError) Unwrap() error {
    return ErrValidation
}

// NotFoundError carries the entity type and identifier
type NotFoundError struct {
    Entity string
    ID     string
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s with id %s not found", e.Entity, e.ID)
}

func (e *NotFoundError) Unwrap() error {
    return ErrNotFound
}

// Usage
func NewNotFound(entity, id string) error {
    return &NotFoundError{Entity: entity, ID: id}
}

func NewValidationError(field, message string, value interface{}) error {
    return &ValidationError{Field: field, Message: message, Value: value}
}

Callers extract the details with errors.As():

var validationErr *domain.ValidationError
if errors.As(err, &validationErr) {
    // Access field-level details
    log.Printf("Validation failed: field=%s message=%s", validationErr.Field, validationErr.Message)
}

The key: custom error types implement Unwrap() returning the sentinel error. This means errors.Is(err, domain.ErrNotFound) still works even when the error is a *NotFoundError with additional context.

Pattern 3: Error Wrapping with fmt.Errorf

The %w verb creates error chains that preserve the original error while adding context at each layer:

// Repository layer
func (r *UserRepo) GetByID(ctx context.Context, id string) (*domain.User, error) {
    var user domain.User
    err := r.db.QueryRow(ctx, "SELECT ...", id).Scan(&user.ID, &user.Name)
    if errors.Is(err, pgx.ErrNoRows) {
        return nil, domain.NewNotFound("user", id)
    }
    if err != nil {
        return nil, fmt.Errorf("querying user by id %s: %w", id, err)
    }
    return &user, nil
}

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

// Handler layer
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
    user, err := h.svc.GetUser(r.Context(), id)
    if err != nil {
        // errors.Is still works through the wrapping chain
        if errors.Is(err, domain.ErrNotFound) {
            writeError(w, http.StatusNotFound, "user not found")
            return
        }
        // Log the full chain for debugging
        slog.Error("failed to get user", "error", err, "user_id", id)
        writeError(w, http.StatusInternalServerError, "internal error")
        return
    }
}

The error chain reads like a breadcrumb trail: "getting user: querying user by id abc123: connection refused". You know exactly which layer failed and why.

Wrapping Rules

  1. Always wrap with additional context when crossing layer boundaries
  2. Include relevant identifiers (user ID, request ID, resource name)
  3. Do NOT wrap when re-raising the same error without new context
  4. Use %w (not %v) to preserve the error chain for errors.Is/As

Pattern 4: Domain Errors vs Infrastructure Errors

This is the pattern that prevents internal details from leaking to clients. Separate your errors into two categories:

Domain errors: Business logic failures. The client can and should know about these.

  • User not found
  • Invalid email format
  • Insufficient balance
  • Duplicate entry

Infrastructure errors: Technical failures. The client should get a generic message.

  • Database connection failed
  • Redis timeout
  • External API returned 500
  • Disk full
// Domain error - safe to expose details to client
func (s *UserService) CreateUser(ctx context.Context, req CreateUserRequest) (*User, error) {
    if !isValidEmail(req.Email) {
        return nil, domain.NewValidationError("email", "invalid format", req.Email)
    }

    existing, _ := s.repo.GetByEmail(ctx, req.Email)
    if existing != nil {
        return nil, domain.NewValidationError("email", "already registered", req.Email)
    }

    // ...
}

// Infrastructure error - hide details from client
func (r *UserRepo) Create(ctx context.Context, user *domain.User) error {
    _, err := r.db.Exec(ctx, "INSERT INTO users ...", user.ID, user.Name)
    if err != nil {
        // Wrap with context for logging, but this should NEVER reach the client
        return fmt.Errorf("inserting user into postgres: %w", err)
    }
    return nil
}

At the HTTP/gRPC boundary, map accordingly:

func mapErrorToHTTP(err error) (int, string) {
    // Domain errors - expose details
    var validationErr *domain.ValidationError
    if errors.As(err, &validationErr) {
        return http.StatusBadRequest, validationErr.Message
    }
    if errors.Is(err, domain.ErrNotFound) {
        return http.StatusNotFound, "resource not found"
    }
    if errors.Is(err, domain.ErrUnauthorized) {
        return http.StatusUnauthorized, "unauthorized"
    }
    if errors.Is(err, domain.ErrForbidden) {
        return http.StatusForbidden, "forbidden"
    }

    // Infrastructure errors - generic message
    return http.StatusInternalServerError, "internal server error"
}

Error Propagation Across Service Boundaries

In a microservice architecture, errors cross network boundaries. Here is how to handle gRPC error propagation, which complements the patterns from my gRPC vs REST guide:

Server Side: Domain to gRPC Status

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

    var validationErr *domain.ValidationError
    if errors.As(err, &validationErr) {
        st := status.New(codes.InvalidArgument, validationErr.Message)
        detailed, _ := st.WithDetails(&errdetails.BadRequest{
            FieldViolations: []*errdetails.BadRequest_FieldViolation{
                {Field: validationErr.Field, Description: validationErr.Message},
            },
        })
        return detailed.Err()
    }

    if errors.Is(err, domain.ErrNotFound) {
        return status.Errorf(codes.NotFound, "resource not found")
    }

    if errors.Is(err, domain.ErrUnauthorized) {
        return status.Errorf(codes.Unauthenticated, "authentication required")
    }

    // Log internal errors, return generic status
    slog.Error("internal error in gRPC handler", "error", err)
    return status.Errorf(codes.Internal, "internal error")
}

Client Side: gRPC Status to Domain

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

    st, ok := status.FromError(err)
    if !ok {
        return fmt.Errorf("non-grpc error from service: %w", err)
    }

    switch st.Code() {
    case codes.NotFound:
        return domain.ErrNotFound
    case codes.InvalidArgument:
        return domain.ErrValidation
    case codes.Unauthenticated:
        return domain.ErrUnauthorized
    case codes.PermissionDenied:
        return domain.ErrForbidden
    default:
        return fmt.Errorf("service error (code=%s): %s", st.Code(), st.Message())
    }
}

Structured Error Logging with slog

Go 1.21 introduced slog for structured logging. Use it to create queryable error logs:

func (h *Handler) handleError(r *http.Request, err error, msg string) {
    attrs := []slog.Attr{
        slog.String("error", err.Error()),
        slog.String("request_id", middleware.GetReqID(r.Context())),
        slog.String("method", r.Method),
        slog.String("path", r.URL.Path),
    }

    // Add error type for filtering in log aggregation
    var validationErr *domain.ValidationError
    if errors.As(err, &validationErr) {
        attrs = append(attrs, slog.String("error_type", "validation"))
        attrs = append(attrs, slog.String("field", validationErr.Field))
    } else if errors.Is(err, domain.ErrNotFound) {
        attrs = append(attrs, slog.String("error_type", "not_found"))
    } else {
        attrs = append(attrs, slog.String("error_type", "internal"))
    }

    slog.LogAttrs(r.Context(), slog.LevelError, msg, attrs...)
}

Log Once, At the Boundary

The most common mistake: logging the same error at every layer.

// BAD: logs at every level
func (r *UserRepo) GetByID(ctx context.Context, id string) (*User, error) {
    // ...
    if err != nil {
        slog.Error("database error", "error", err) // Log #1
        return nil, fmt.Errorf("repo: %w", err)
    }
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
    user, err := s.repo.GetByID(ctx, id)
    if err != nil {
        slog.Error("service error", "error", err) // Log #2 (same error!)
        return nil, fmt.Errorf("service: %w", err)
    }
}
// GOOD: log once at the handler boundary
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
    user, err := h.svc.GetUser(r.Context(), id)
    if err != nil {
        h.handleError(r, err, "failed to get user") // Single log with full context
        writeError(w, mapErrorToHTTP(err))
        return
    }
}

Client-Facing Error Responses: RFC 7807

For REST APIs, use the Problem Details format (RFC 7807) for structured error responses:

type ProblemDetail struct {
    Type     string `json:"type"`
    Title    string `json:"title"`
    Status   int    `json:"status"`
    Detail   string `json:"detail,omitempty"`
    Instance string `json:"instance,omitempty"`
}

func writeProblem(w http.ResponseWriter, r *http.Request, status int, title, detail string) {
    problem := ProblemDetail{
        Type:     fmt.Sprintf("https://api.example.com/errors/%d", status),
        Title:    title,
        Status:   status,
        Detail:   detail,
        Instance: r.URL.Path,
    }

    w.Header().Set("Content-Type", "application/problem+json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(problem)
}

Response example:

{
  "type": "https://api.example.com/errors/422",
  "title": "Validation Error",
  "status": 422,
  "detail": "Email address is already registered",
  "instance": "/api/v1/users"
}

Testing Error Paths

Error paths need tests just as much as happy paths. Here are patterns I use:

func TestUserService_GetUser_NotFound(t *testing.T) {
    repo := &mockRepo{
        getByIDErr: domain.NewNotFound("user", "abc123"),
    }
    svc := service.NewUserService(repo)

    _, err := svc.GetUser(context.Background(), "abc123")

    // Verify error type propagation
    assert.Error(t, err)
    assert.True(t, errors.Is(err, domain.ErrNotFound))

    // Verify error details are accessible
    var notFoundErr *domain.NotFoundError
    assert.True(t, errors.As(err, &notFoundErr))
    assert.Equal(t, "user", notFoundErr.Entity)
    assert.Equal(t, "abc123", notFoundErr.ID)
}

func TestUserService_GetUser_DBError(t *testing.T) {
    repo := &mockRepo{
        getByIDErr: fmt.Errorf("connection refused"),
    }
    svc := service.NewUserService(repo)

    _, err := svc.GetUser(context.Background(), "abc123")

    // Infrastructure error should NOT be a domain error
    assert.Error(t, err)
    assert.False(t, errors.Is(err, domain.ErrNotFound))
    assert.Contains(t, err.Error(), "connection refused")
}

Anti-Patterns to Avoid

1. Swallowing errors silently:

// NEVER do this
result, _ := riskyOperation()

2. Logging and returning:

// Pick one: log OR return. Not both.
if err != nil {
    log.Error(err) // If you log here...
    return err     // ...it gets logged again at the boundary
}

3. Stringly-typed error checking:

// FRAGILE: breaks when error message changes
if strings.Contains(err.Error(), "not found") { ... }

// USE: errors.Is or errors.As
if errors.Is(err, domain.ErrNotFound) { ... }

4. Panic for recoverable errors:

// NEVER panic for expected failures
func GetUser(id string) *User {
    user, err := db.Find(id)
    if err != nil {
        panic(err) // This kills your whole service
    }
    return user
}

5. Generic error messages everywhere:

// TOO GENERIC: impossible to debug
return fmt.Errorf("operation failed")

// BETTER: include what, where, and relevant identifiers
return fmt.Errorf("creating user %s in postgres: %w", user.ID, err)

Putting It All Together

Good error handling in Go microservices follows these principles:

  1. Define domain errors as sentinel values and custom types
  2. Wrap errors with context at layer boundaries using %w
  3. Separate domain errors (client-safe) from infrastructure errors (internal-only)
  4. Map errors to appropriate status codes at the transport boundary
  5. Log once at the boundary with full context
  6. Test error propagation explicitly

These patterns work whether you are building a single service or a distributed system with dozens of microservices communicating over gRPC.

Want to level up your team’s Go practices? I run professional development workshops covering production patterns like these, tailored to your team’s codebase and challenges.

See these error handling patterns in practice across my Go backend projects and telco service work.