If there’s one thing that separates production-ready code from prototype code, it’s how it handles failures. Most developers write the happy path first — and that’s natural. But the difference between a system that crashes at 2 AM and one that degrades gracefully comes down to the error handling patterns baked into its architecture.
Today, with services depending on dozens of external APIs, databases, and third-party dependencies (npm, PyPI, you name it), failure isn’t exceptional — it’s routine. The npm registry going down, as it did today, is just the latest reminder. Your code needs to be prepared.
Let’s walk through five error handling patterns that every developer should have in their toolkit, with practical examples in Go.
1. The Result Type Pattern
Instead of relying on exceptions or raw error values scattered through your code, wrap results in an explicit type that forces callers to acknowledge both success and failure cases. This is the foundation of robust error handling in languages like Go and Rust.
In Go, the idiomatic approach is the (T, error) return tuple. But you can take it further with a generic Result type that makes the intent crystal clear:
type Result[T any] struct {
Value T
Err error
}
func (r Result[T]) Unwrap() T {
if r.Err != nil {
panic(r.Err)
}
return r.Value
}
func (r Result[T]) IsOk() bool {
return r.Err == nil
}
func FetchUser(id int) Result[User] {
user, err := db.QueryUser(id)
if err != nil {
return Result[User]{Err: fmt.Errorf("fetch user %d: %w", id, err)}
}
return Result[User]{Value: user}
}
// Usage forces you to handle both cases
func HandleRequest(id int) string {
result := FetchUser(id)
if !result.IsOk() {
log.Error("failed to fetch user", "error", result.Err)
return "user not found"
}
return result.Value.Name
}
The key benefit: the compiler and code review process make it impossible to silently ignore an error. If you call FetchUser, you must deal with the Result.
2. Wrapped Errors with Context
A raw err != nil check tells you something failed, but not where in the call stack or what the operation was. Go’s fmt.Errorf with %w lets you wrap errors with context while preserving the original error for errors.Is() and errors.As() checks.
var ErrNotFound = errors.New("resource not found")
func (s *Service) GetOrder(ctx context.Context, id string) (*Order, error) {
order, err := s.repo.FindByID(ctx, id)
if err != nil {
// BAD: loses context
// return nil, err
// GOOD: adds context, preserves error chain
return nil, fmt.Errorf("Service.GetOrder(id=%s): %w", id, err)
}
return order, nil
}
// At the HTTP handler level, you can check the chain:
func (h *Handler) HandleGetOrder(w http.ResponseWriter, r *http.Request) {
order, err := h.svc.GetOrder(r.Context(), r.PathValue("id"))
if errors.Is(err, ErrNotFound) {
http.Error(w, "order not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(order)
}
Every layer of your application should add its own context to the error. When you’re debugging a production issue, a stack of wrapped errors like HTTPHandler: Service.GetOrder: Repo.FindByID: connection refused tells you exactly where to look.
3. Circuit Breaker for External Dependencies
When an external service starts failing, the worst thing your code can do is keep trying. Each failed request wastes resources, adds latency, and can cascade into a system-wide outage. The circuit breaker pattern monitors failure rates and “trips” — short-circuiting calls — when a threshold is exceeded.
type CircuitBreaker struct {
mu sync.Mutex
failures int
maxFailures int
resetTimeout time.Duration
state string // "closed", "open", "half-open"
lastFailure time.Time
}
func (cb *CircuitBreaker) Call(fn func() error) error {
cb.mu.Lock()
switch cb.state {
case "open":
if time.Since(cb.lastFailure) > cb.resetTimeout {
cb.state = "half-open"
} else {
cb.mu.Unlock()
return fmt.Errorf("circuit breaker open")
}
case "half-open":
// Allow one request through as a probe
}
cb.mu.Unlock()
err := fn()
cb.mu.Lock()
defer cb.mu.Unlock()
if err != nil {
cb.failures++
cb.lastFailure = time.Now()
if cb.failures >= cb.maxFailures {
cb.state = "open"
}
return err
}
// Success resets the counter
cb.failures = 0
cb.state = "closed"
return nil
}
// Usage
err := breaker.Call(func() error {
resp, err := http.Get("https://api.example.com/data")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
return fmt.Errorf("server error: %d", resp.StatusCode)
}
return nil
})
This pattern is essential for any microservice architecture. Libraries like gobreaker provide production-ready implementations, but understanding how it works under the hood helps you configure thresholds correctly for your specific use case.
4. Graceful Degradation with Fallbacks
When a non-critical dependency fails, your application should continue working with reduced functionality rather than crashing entirely. This is graceful degradation — and it’s one of the most underappreciated patterns in software engineering.
func (h *Handler) GetDashboard(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Critical: must succeed
user, err := h.userService.GetCurrent(ctx)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
dashboard := Dashboard{User: user}
// Non-critical: fetch with fallback
if recommendations, err := h.recoService.GetFor(user.ID); err == nil {
dashboard.Recommendations = recommendations
} else {
log.Warn("recommendations service unavailable, using defaults",
"error", err)
dashboard.Recommendations = DefaultRecommendations()
}
// Non-critical: cached data is fine
if stats, err := h.analyticsService.GetStats(user.ID); err == nil {
dashboard.Stats = stats
} else {
dashboard.Stats = h.cache.GetStats(user.ID)
}
json.NewEncoder(w).Encode(dashboard)
}
The key insight is categorizing your dependencies: which ones are critical (the page can’t function without them) and which are nice-to-have (the page works fine with stale data or defaults). This categorization should happen during design, not during an incident.
5. Structured Error Logging
Logs like ERROR: something went wrong are useless in production. Structured logging — emitting errors as key-value pairs that can be queried and filtered — is non-negotiable for any service running at scale.
import "log/slog"
// BAD: unstructured
func ProcessOrder(order Order) error {
if order.Items == nil {
log.Printf("error: no items in order %s", order.ID)
return errors.New("empty order")
}
// ...
}
// GOOD: structured
func ProcessOrder(ctx context.Context, order Order) error {
if len(order.Items) == 0 {
slog.ErrorContext(ctx, "order validation failed",
"order_id", order.ID,
"customer_id", order.CustomerID,
"error", "empty_items",
"validation_step", "item_check",
)
return fmt.Errorf("order %s: %w", order.ID, ErrEmptyOrder)
}
if err := validatePayment(order.Payment); err != nil {
slog.ErrorContext(ctx, "payment validation failed",
"order_id", order.ID,
"payment_method", order.Payment.Method,
"error", err,
)
return fmt.Errorf("order %s payment: %w", order.ID, err)
}
// ...
}
With structured logging, you can filter by order_id, group errors by validation_step, and build dashboards that show error rates per payment_method. That’s the difference between “I see errors in the logs” and “I know exactly what’s failing, for whom, and how often.”
Putting It All Together
These patterns aren’t meant to be used in isolation. A well-architected service combines them: Result types at the domain layer, wrapped errors for traceability, circuit breakers for external calls, graceful degradation for non-critical paths, and structured logging throughout.
The npm outage making headlines today is a perfect illustration. If your build pipeline has circuit breakers and fallback caches for dependency resolution, an npm outage is a blip. If every npm install is a hard dependency with no fallback, it’s a blocker.
Start small. Pick one pattern — I’d suggest wrapped errors with context, since it’s the lowest-effort, highest-impact improvement — and apply it consistently across your codebase. Then layer on the others as your system’s complexity grows.
Your future self (and whoever gets paged at 2 AM) will thank you.
Sources
- npm Status — Incident History (npm website outage, April 27, 2026)
- sony/gobreaker — Circuit Breaker implementation in Go (Sony, 3.6k stars)
- Working with Errors in Go 1.13 — Official Go blog on
errors.Is(),errors.As(), andfmt.Errorfwith%w - log/slog — Go standard library structured logging package (added in Go 1.21)
- Routing Enhancements for Go 1.22 — Official Go blog on enhanced
net/http.ServeMuxwithPathValue - Go 1.18 Release Notes — Generics support, enabling the
Result[T any]pattern