Spring Boot Exception Handling: Returning 200 OK for Failed Payments
Learn why returning 200 OK for failed payments in Spring Boot is a production anti-pattern.
20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.
- ✓Java 17+ installed (we use Java 21 in examples)
- ✓Spring Boot 3.2+ project with spring-boot-starter-web
- ✓Basic understanding of REST APIs and HTTP status codes
- ✓Familiarity with Maven or Gradle build tools
• Never return HTTP 200 for business failures like payment declines — it breaks API contracts and confuses clients. • Use @ControllerAdvice with @ExceptionHandler to map exceptions to proper HTTP status codes like 402 Payment Required. • Always return a structured error response body with error codes and messages. • Avoid swallowing exceptions in try-catch blocks; let them propagate to centralized handlers. • Use Spring's ResponseEntityExceptionHandler for consistent error formatting across your API.
Think of ordering a pizza online. You pay, and the website says 'Order received!' (200 OK). But then the pizza never arrives because your card was declined. That's confusing, right? Instead, the site should say 'Payment failed — try another card' (402 Payment Required) immediately. That's what proper exception handling does: it tells you exactly what went wrong, right when it happens.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
I've seen it too many times in code reviews and production incidents: a payment-processing service that returns HTTP 200 OK even when the payment fails. The developer thought, 'Well, the request was processed successfully — the payment just didn't go through.' That's a dangerous mindset. HTTP status codes are part of your API contract. Returning 200 for a failed payment is like a cashier saying 'Your transaction is complete' while handing back your declined credit card.
In Spring Boot, exception handling is not just about preventing 500 errors. It's about building a consistent, predictable API that clients can rely on. In this tutorial, we'll build a realistic payment-processing endpoint and show you how to handle failures properly using Spring's @ControllerAdvice, custom exceptions, and structured error responses. We'll also cover testing strategies and common pitfalls that I've seen in production codebases. By the end, you'll never return 200 for a failed payment again.
Setting Up the Payment Controller
Let's start with a realistic payment-processing controller. We'll use a simple PaymentRequest DTO that contains card details and amount. The controller method processes the payment and returns a response. In the naive implementation, we catch all exceptions and return 200 OK with a 'success: false' body. This is the anti-pattern we're going to fix. We'll use Spring Boot 3.2 with Java 21 records for DTOs. The controller endpoint is POST /api/payments. We'll also add a simple PaymentService that simulates payment gateway calls and can throw different exceptions like InsufficientFundsException or CardDeclinedException.
What the Official Docs Won't Tell You
Spring's official documentation shows you how to use @ExceptionHandler and @ControllerAdvice, but it doesn't emphasize the critical point: HTTP status codes must reflect the outcome of the business operation, not just the request processing. The docs show examples with generic RuntimeException mapping to 500, but they rarely mention 402 Payment Required, 409 Conflict for duplicate payments, or 422 Unprocessable Entity for validation errors. In production, you need fine-grained error codes that map to specific business failures. Also, the docs don't warn you about the common pitfall of using ResponseEntityExceptionHandler without customizing the body. The default implementation returns Spring's internal error structure, which might not match your API contract. You should always create a custom ErrorResponse DTO that includes fields like errorCode, message, timestamp, and path.
Creating Custom Exceptions for Payment Failures
Instead of catching generic Exception, create specific exception classes for each business failure scenario. This makes your code more readable and allows fine-grained error handling. We'll create PaymentDeclinedException, InsufficientFundsException, and DuplicatePaymentException. Each extends RuntimeException and carries an error code and message. In Spring Boot 3.2, you can use records for DTOs and sealed classes for exceptions if you want stricter control. The key is that these exceptions carry enough context for the handler to build a meaningful response. Also, include a constructor that accepts the original cause so you can log the stack trace server-side while returning a sanitized message to the client.
Structured Error Response DTO
A consistent error response format is crucial for client-side error handling. We'll create a record ErrorResponse with fields: errorCode, message, timestamp, statusCode, and optional path. The path field helps clients know which endpoint failed. Use Jackson annotations to control serialization. In Spring Boot 3.2, records work perfectly with Jackson out of the box. Also, consider adding a correlationId field that ties the error to a specific server-side log entry. This is invaluable for debugging production issues. The timestamp should be in ISO 8601 format (e.g., 2024-01-15T10:30:00Z). Don't include sensitive data like stack traces in the response body — log them server-side instead.
Global Exception Handler with @ControllerAdvice
Now we'll wire everything together with a @ControllerAdvice class. This global handler catches all exceptions thrown from controllers and maps them to appropriate HTTP responses. We'll handle PaymentDeclinedException -> 402, InsufficientFundsException -> 402, DuplicatePaymentException -> 409 Conflict, and a generic fallback for unexpected errors -> 500 Internal Server Error. Also, override handleMethodArgumentNotValid from ResponseEntityExceptionHandler to handle validation errors with 422 Unprocessable Entity. The key is to log the exception before returning the response. Use a Logger with MDC (Mapped Diagnostic Context) to include the correlation ID in logs. For the generic handler, always log the full stack trace and return a sanitized message like 'An unexpected error occurred. Please contact support.'
Testing the Exception Handling
Testing exception handling is just as important as testing happy paths. Use Spring Boot's @WebMvcTest to test the controller layer in isolation. Mock the service to throw specific exceptions and verify the HTTP status code and response body. Also test the error response structure using JsonPath assertions. For integration tests, use @SpringBootTest with a real embedded server and TestRestTemplate. Test scenarios: successful payment (200), declined card (402), insufficient funds (402), duplicate payment (409), and generic error (500). Also test that validation errors (like missing required fields) return 422. Use MockMvc for fine-grained control over request/response inspection.
Logging and Monitoring Error Responses
Proper logging is essential for debugging payment failures. Use SLF4J with Lombok's @Slf4j. Log at different levels: warn for business failures (declined card), error for system failures (database down). Include relevant context like transaction ID, customer ID, and error code in the log message. Use MDC to automatically add correlation IDs to all log entries. For monitoring, set up metrics with Micrometer (bundled in Spring Boot 3.2) to count occurrences of each error code. Create a custom metric like 'payment.errors' with tags for errorCode and statusCode. This allows you to set up alerts when payment errors exceed a threshold. Also, log the full request body for failed payments (but mask sensitive data like full card numbers).
Advanced: Retry with Exponential Backoff
Some payment failures are transient (e.g., timeout from payment gateway). For these, implement automatic retry with exponential backoff. Use Spring Retry (@EnableRetry and @Retryable) or a custom RetryTemplate. But be careful: never retry on business failures like 'card declined' or 'insufficient funds'. Only retry on technical failures like timeout or network error. Create a separate exception type for transient failures (e.g., PaymentGatewayTimeoutException). Configure retry with a max of 3 attempts, initial delay of 1 second, and multiplier of 2. Also implement a circuit breaker pattern using Resilience4j to avoid hammering a failing gateway. The circuit breaker should open after 5 failures in 10 seconds and half-open after 30 seconds.
The $50,000 200 OK
- HTTP status codes are part of your API contract — use them semantically.
- Never swallow exceptions without returning appropriate error status.
- Always log the actual exception details server-side even if you return a sanitized message to the client.
grep -r "ResponseEntity.ok" src/main/javagrep -r "@ExceptionHandler" src/main/java | grep -v "PaymentDeclinedException"| File | Command / Code | Purpose |
|---|---|---|
| PaymentController.java | @RestController | Setting Up the Payment Controller |
| CustomExceptionHandler.java | @ControllerAdvice | What the Official Docs Won't Tell You |
| PaymentExceptions.java | public class PaymentDeclinedException extends RuntimeException { | Creating Custom Exceptions for Payment Failures |
| ErrorResponse.java | public record ErrorResponse( | Structured Error Response DTO |
| GlobalExceptionHandler.java | @ControllerAdvice | Global Exception Handler with @ControllerAdvice |
| PaymentControllerTest.java | @WebMvcTest(PaymentController.class) | Testing the Exception Handling |
| LoggingConfig.java | @ControllerAdvice | Logging and Monitoring Error Responses |
| RetryConfig.java | @Configuration | Advanced |
Key takeaways
Interview Questions on This Topic
What is @ControllerAdvice and how does it work in Spring Boot?
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.
That's Spring Boot. Mark it forged?
4 min read · try the examples if you haven't