REST · API Design
Stop Returning 200 OK for Everything
A 200 response means the request succeeded. Not "the server responded", not "here is some JSON", but succeeded. When that contract breaks, every client, every monitoring dashboard, and every engineer reading a log has to re-derive what actually happened from the response body instead of the status line.
The problem
Out-of-stock inventory, an expired promo code, an order that already shipped: none of these are server errors, and none of them are successes either. Teams under deadline pressure often solve this by wrapping every outcome in the same envelope and always returning 200:
1@PostMapping("/orders")2public ResponseEntity<ApiResponse> createOrder(@RequestBody OrderRequest request) {3 try {4 Order order = orderService.create(request);5 return ResponseEntity.ok(new ApiResponse(true, order));6 } catch (Exception e) {7 return ResponseEntity.ok(new ApiResponse(false, e.getMessage()));8 }9}The response body carries the truth, the status line lies about it:
1// HTTP 200 OK2{3 "success": false,4 "message": "Something went wrong"5}Why it breaks
- Load balancers, API gateways, and uptime monitors read status codes, not bodies. A wall of silent 200s hides a service that is actually failing every request.
- HTTP clients and SDKs branch on status by default. Forcing every caller to also parse a body just to know if a call worked doubles the integration surface.
- It erases the difference between "your input was wrong" (client's problem, 4xx) and "we couldn't process it right now" (retry later, 5xx) and "this conflicts with existing state" (409). Retry logic built on status codes cannot tell these apart.
The production solution
Business errors are not exceptions to hide inside a 200, they are 4xx responses with a distinct, stable code. The trick is keeping the mapping in one place instead of scattering HttpStatus literals across every controller:
1public enum BusinessError {2 INSUFFICIENT_STOCK(HttpStatus.CONFLICT),3 ORDER_ALREADY_SHIPPED(HttpStatus.CONFLICT),4 PROMO_CODE_EXPIRED(HttpStatus.UNPROCESSABLE_ENTITY),5 PRODUCT_NOT_FOUND(HttpStatus.NOT_FOUND);67 private final HttpStatus status;89 BusinessError(HttpStatus status) {10 this.status = status;11 }1213 public HttpStatus status() {14 return status;15 }16}The GlobalExceptionHandler from the first article in this series reads error.status() off the thrown BusinessException instead of hardcoding a status per handler method. The response now tells the truth at every layer:
1// HTTP 409 Conflict2{3 "code": "INSUFFICIENT_STOCK",4 "message": "Product is currently out of stock",5 "path": "/api/orders",6 "traceId": "01K7Q3G2ZP5NBM8"7}Making it reusable
The status codes worth actually using on a typical API are a short list: 200/201/204 for success, 400 for malformed input, 401/403 for auth, 404 for missing resources, 409 for state conflicts, 422 for semantically invalid input, 429 for rate limits, and 500/503 for things that are genuinely the server's fault. Every BusinessError enum value maps to exactly one of these, decided once, at the point the error is defined, not re-decided in every controller that throws it.
Testing it
Because the status now comes from the enum, a unit test on BusinessError itself catches an accidental misclassification (marking a real server fault as a 409, say) before it ships. Controller tests then only need to assert that the right exception gets thrown for the right precondition; the status mapping is already covered.
What I learned
The habit of "always 200, explain in the body" usually comes from one bad experience with a client that could not parse a 4xx body correctly. The actual fix for that is fixing the client, not flattening the contract for everyone downstream. A status code is cheaper to get right than a body, and unlike a body, nothing else in the HTTP stack can see the body without parsing it first.