2026-04-24
Most codebases don't have an error handling strategy — they have a collection of ad-hoc try/catch blocks accumulated over years. Let's fix that by looking at three patterns that, used together, give you predictable and debuggable failure behavior.
Pattern 1: Fail Fast at the Boundary
Validate inputs at system boundaries — API endpoints, message consumers, file readers — and reject bad data immediately. Don't let invalid state propagate deep into your domain logic where it becomes harder to diagnose.
quantity. Return a 400 before calling any service method.Rule of thumb: every layer deeper an invalid input travels, the debugging cost roughly doubles. An error caught at the controller is a 5-minute fix; the same error surfacing as a NullPointerException in a repository is a 40-minute investigation.
Pattern 2: Use Domain-Specific Error Types
Stop throwing generic Error or RuntimeException everywhere. Create a small hierarchy of error types that map to your domain:
ValidationError — bad input, caller's fault, maps to HTTP 400.NotFoundError — resource doesn't exist, maps to 404.ConflictError — optimistic locking failure or duplicate, maps to 409.InfrastructureError — database down, third-party timeout, maps to 503.This gives your global error handler a clean contract. Instead of inspecting error messages with string matching (fragile and untestable), you switch on type. Your API responses become consistent automatically.
Pattern 3: The Error Boundary
Centralize translation of errors into user-facing responses in one place. In Express, this is an error-handling middleware. In Spring, it's a @ControllerAdvice. In Go, it's a wrapper around your handler functions. The key principle: domain code throws meaningful errors, the boundary translates them.
Real-world example: a payment service processes a charge. The Stripe SDK throws a CardDeclinedError. Your service wraps it in a PaymentFailedError with context (user ID, amount, reason). The error boundary maps it to a 422 response with a safe, user-facing message — never leaking Stripe internals to the client.
What NOT to do:
UserNotFoundException to check if a user exists). Use return types like Optional, Result, or nullable returns instead.