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— aConflictExceptionwith 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— extendsIllegalStateException, 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 itsfield(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
- GlobalControllerAdvice.java
— the seventeen handlers and the
buildResponsefunnel. - ApiExceptionResponse.java
— the shape, and the
fieldjavadoc quoted above. - MissingTenantSettingsException.java and SignupConflictException.java — the two exceptions whose javadocs carry the most design reasoning.
- EmployeeCreateRequest.java
—
@Pattern-on-Stringhouse style in full. - ../backend/README.md — the status table, section 4; the single source for which exception maps to which code.
- ../api/conventions.md — the error envelope alongside the rest of the wire conventions.
Recap
- Every failure leaves through one funnel. Seventeen handlers choose message and status;
buildResponsestamps Manila time and one four-field shape. fieldis 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.IllegalStateExceptionis unmapped on purpose. It means "the server's reference data is broken", falls to the catch-all as a 500, andMissingTenantSettingsExceptionextends it precisely to get that severity.- Validation is plain Bean Validation — zero custom validators.
@PatternonStringkeeps enum-ish rejections at the named-field 400 instead of an opaque Jackson one, and failures join into onefield: messagestring.
Next: 06 — GET /api/employees: sixty filters, one fields list.