Skip to main content

05 — One error shape, and the 500 that is supposed to happen

Read this first: this lesson reads the backend's entire error surface — one advice class, one response shape, nine domain exceptions. By the end, given any failure anywhere in the backend, you can write down the exact wire response before it happens — and explain the one exception that is deliberately not mapped. Code is quoted inline so you can read this without the repository open.

Time: about 25 minutes. Assumes lesson 04.

Seventeen handlers, one funnel​

Every controller in the backend is allowed to throw. Nothing catches locally; everything lands in GlobalControllerAdvice, a @RestControllerAdvice with exactly seventeen @ExceptionHandler methods. Each handler does one job — decide the message and the status — and then all but one of them delegate to the same private method:

private ResponseEntity<ApiExceptionResponse> buildResponse(String message, HttpStatus status) {
ApiExceptionResponse body = new ApiExceptionResponse(message, status.value(), LocalDateTime.now(ZoneId.of("Asia/Manila")));
return ResponseEntity.status(status).body(body);
}

That is the whole funnel. Handlers translate exceptions into a message and a status; the funnel stamps the Manila wall-clock time and wraps the body. Because every path converges here, there is exactly one error shape on the wire — a client never has to ask "which kind of error is this endpoint's kind".

The shape: three fields, plus one that is almost always null​

The body is ApiExceptionResponse: message, status, time — and a fourth member whose javadoc is the design document:

/**
* Which input the caller should fix, when the server knows. Null on every response that does
* not name one, which is most of them -- a form-level message is the right answer when the
* problem is not attributable to a single field.
*
* <p>Exists so a signup form can put "that address is taken" under the address box instead of
* in a banner, without the frontend substring-matching prose that someone will later reword.
*/
private String field;

Read the second paragraph twice. The alternative to a structured field is a frontend doing message.includes('address') — which works until someone rewords the prose, and then a real error silently stops pointing at its box. The three-argument constructor passes null for field, so the funnel produces field: null on every response unless a handler goes out of its way to say otherwise.

Exactly one handler does. SignupConflictException carries the offending field name, and its handler is the only one in the class that builds the body by hand instead of calling the funnel:

/**
* Declared before {@link ConflictException}'s handler in source order for readability only --
* Spring picks the most specific handler regardless, so a SignupConflictException never falls
* through to the one below and loses its field.
*/
@ExceptionHandler(SignupConflictException.class)
public ResponseEntity<ApiExceptionResponse> handleSignupConflict(SignupConflictException ex) {
ApiExceptionResponse body = new ApiExceptionResponse(
ex.getMessage(), HttpStatus.CONFLICT.value(),
LocalDateTime.now(ZoneId.of("Asia/Manila")), ex.getField());
return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
}

The comment is worth internalizing: Spring dispatches to the most specific handler for the exception's type, not the first match in source order. SignupConflictException extends ConflictException, and both have handlers — the ordering in the file is for the human reading it, not for the dispatcher. On the wire, a taken workspace address looks like this:

HTTP/1.1 409 Conflict

{
"message": "That workspace address is already taken. Try another.",
"status": 409,
"time": "2026-08-08T09:15:42.183421",
"field": "slug"
}

The status map, and the omission that matters​

Which exception maps to which status is already written down as a table in section 4 of the backend README — go read it there rather than have two copies drift apart. In one sentence: framework and validation failures become 400s, security failures become 401/403/423, the domain exceptions become 404/409/422/503, and Exception itself is the fallback.

What the table doesn't contain is the interesting part.

Predict: deep inside payslip generation, a service throws IllegalStateException because no SSS contribution bracket covers an employee's salary. Write down the wire response — status code and body. Then decide: is that a bug in the error handling, or correct?

There is no handler for IllegalStateException. It falls through to the catch-all:

@ExceptionHandler(Exception.class)
public ResponseEntity<ApiExceptionResponse> handleGenericException(Exception ex) {
return buildResponse(ex.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
}

So the client sees a 500, same four-field shape, with the exception's own message. And that is correct — deliberately so, as the backend README states next to the table. IllegalArgumentException means "the caller sent something wrong": a 400, the caller's problem to fix. IllegalStateException in this codebase means "the server's reference data is broken" — a bracket table with a hole in it, a precondition that provisioning was supposed to guarantee. No retry from the client can fix that, and dressing it up as a 4xx would tell the wrong person to act. Fail-loud checks are written expecting the 500: the bracket-coverage validation throws one IllegalStateException naming every affected employee, precisely so the 500's message is a complete work order.

The exception that opts into the 500​

One domain exception makes this choice explicit in its inheritance:

public class MissingTenantSettingsException extends IllegalStateException {

By extending IllegalStateException instead of RuntimeException, it routes itself to the catch-all and arrives as a 500 without any handler existing for it. Its javadoc explains why a missing settings row must never be smoothed over with defaults:

Refusing to compute is the safe answer. A payroll run that stops with a named cause is a support ticket; one that quietly uses another company's rules is a liability.

That is the error contract working as a system: severity is chosen by what the exception extends, and the advice class doesn't need to know the type exists.

The nine domain exceptions​

Everything in the exception package besides the advice and the body class, one line each:

  • ConflictException — the base 409: the thing you tried to create already exists; message written at the throw site.
  • DuplicateClockInException — a ConflictException with its message baked into the constructor ("You have already clocked in today"); it has no handler of its own and rides its parent's.
  • InvalidTimesheetStateException — 422: the timesheet exists, but its lifecycle state forbids this action.
  • MissingTenantSettingsException — extends IllegalStateException, so a 500 on purpose (above).
  • ResourceNotFoundException — 404: the id in the URL names nothing.
  • ScheduleMismatchException — 422: the requested pay period disagrees with the configured pay schedule; per its javadoc the caller can knowingly proceed via a confirmation flag, which is what separates it from a plain validation error.
  • SignupConflictException — the one 409 that names its field (above).
  • SignupUnavailableException — 503: this deployment cannot send the verification email, so signup cannot be honoured; the person at the form did nothing wrong, and no retry of theirs can fix it.
  • UnauthorizedTimesheetActionException — 403: authenticated, but this timesheet is not yours to act on.

Validation house style: zero custom validators​

Request DTO validation is plain Bean Validation, all the way down. Grep the backend tree for ConstraintValidator and you get zero hits — there is not a single custom validator implementation in the codebase. The house style for enum-ish inputs is @Pattern on a String, and EmployeeCreateRequest is the canonical example:

@NotBlank(message = "Status is required")
@Pattern(regexp = "PROBATIONARY|REGULAR|INACTIVE", message = "Status must be one of PROBATIONARY, REGULAR, INACTIVE")
private String status;

Why not bind to an enum and let the framework reject bad values? Because of where the rejection happens. An enum field in a @RequestBody fails during Jackson deserialization — before validation ever runs — and surfaces as HttpMessageNotReadableException, whose handler deliberately hides Jackson's internals behind the blanket "Request body is missing or malformed". Correct status, useless message. A String binds successfully no matter what, so the request reaches Bean Validation, and @Pattern produces a message that names the field and lists the legal values. (The optional variant follows the same style: @Pattern(regexp = "|T|TR|R|D", ...) on separationReason starts with an empty alternative so a blank value passes.)

Multiple failures don't become an array. The MethodArgumentNotValidException handler joins them into one string:

String message = ex.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining(", "));
return buildResponse(message.isEmpty() ? "Validation failed" : message, HttpStatus.BAD_REQUEST);

Predict: you POST an employee with "status": "FULL_TIME" and everything else valid. What exactly is message? Answer, by reading the two snippets above together: status: Status must be one of PROBATIONARY, REGULAR, INACTIVE — field name, colon, the @Pattern message. That string is the product of the DTO and the handler cooperating; neither alone determines it.

The client already knows this shape​

None of this pays off unless the other side of the wire agrees. It does: frontend lesson 04 decodes getApiErrorMessage, the frontend helper that normalizes exactly this {message, status, time, field} body into toast text. The two lessons describe one contract from its two ends — the wire-level conventions both sides follow (URL prefix, pagination, and this envelope) are summarized in ../api/conventions.md.

Where this shows up in MotorPH​

Recap​

  • Every failure leaves through one funnel. Seventeen handlers choose message and status; buildResponse stamps Manila time and one four-field shape.
  • field is null unless the server truly knows which box to fix. Only signup conflicts fill it — structured data instead of frontend substring-matching that a rewording would break.
  • IllegalStateException is unmapped on purpose. It means "the server's reference data is broken", falls to the catch-all as a 500, and MissingTenantSettingsException extends it precisely to get that severity.
  • Validation is plain Bean Validation — zero custom validators. @Pattern on String keeps enum-ish rejections at the named-field 400 instead of an opaque Jackson one, and failures join into one field: message string.

Next: 06 — GET /api/employees: sixty filters, one fields list.