Old-School Backend / 2026 ・ No.07

Old-School Backend

Part 7: Exceptions and replies — graduating from “A system error has occurred”

Published

A terminal. A claim with an empty item and a negative amount, sent with Accept-Language: ja, gets HTTP/1.1 400 with Content-Type: application/problem+json. The body has a detail and title in Japanese, type https://expenses.example/problems/invalid-request, and errors listing item and amount, each with a Japanese message
A 400 after adding the exception helper: which field failed, and why, in the user's language (Captured by the author on a cloud Linux machine on 31 October 2026. The bar across the top of the window was added for the illustration.)

Last time the server learned to refuse claims. But the replies say only “Bad Request” or 500, and the screen has no idea what to tell the user.
This time we decide where to catch exceptions, which status code to use and what to write back. We measure plain Spring Boot, ProblemDetail (RFC 9457), an exception helper (@RestControllerAdvice), swallowed exceptions, reference numbers, and what must never be sent back.

1

“A system error has occurred”

AraiFor errors, “A system error has occurred. Please contact the administrator” will do, right?

FuruiWhat should a user who sees that screen fix?

Arai…Ask the administrator?

FuruiAnd which log should I, the administrator, look at? On old-school sites, that one sentence brought in hundreds of enquiries.

The 15-part route map (you are here: part 7)

1 Big picture 2 Java basics 3 Raw HTTP 4 Raw JDBC 5 Spring Boot 6 Validation 7 Errors 8 DB access 9 Transactions 10 Workflow 11 Auth 12 Files 13 Logging 14 Testing 15 Running it You are here Basics By hand Front door Data Rules & safety Operations
  • Nothing added — a missing reference number and a double click are both 500s: the user's doing, looking like failures (measured)
  • ProblemDetail — one setting tidies the shape, but the content is “Invalid request content.” (measured)
  • An exception helper — 400 with per-field reasons, 404, 409, and 500 with a reference number (measured)
  • Swallowing — nothing was written, yet 201 and a reference number came back (measured)
  • What never to send — one setting put the SQL and the row's contents in the reply (measured)

The application is last time's Spring Boot 4.1.2 plus an experimental counter (/api/v3), a clerk who swallows exceptions, and an exception helper (ApiErrors). The database has a unique index allowing one claim per applicant, date, item and amount (for the double-click experiment).

2

Replies with nothing added

First, five kinds of troublesome requests to Spring Boot with nothing added.

# Spring Boot's replies with nothing added
## 1 fails the marks (empty item, negative amount)
HTTP/1.1 400   Content-Type: application/json
{"timestamp":"2026-10-30T16:08:06.420Z","status":400,"error":"Bad Request","path":"/api/v3/expenses"}
## 2 broken JSON
HTTP/1.1 400   Content-Type: application/json
{"timestamp":"2026-10-30T16:08:06.519Z","status":400,"error":"Bad Request","path":"/api/v3/expenses"}
## 3 a reference number that doesn't exist
HTTP/1.1 500   Content-Type: application/json
{"timestamp":"2026-10-30T16:08:06.609Z","status":500,"error":"Internal Server Error","path":"/api/v3/expenses/EXP-2026-9999"}
## 4 the same claim twice (a double click)
HTTP/1.1 201   Content-Type: application/json
{"id":"EXP-2026-2894"}
HTTP/1.1 500   Content-Type: application/json
{"timestamp":"2026-10-30T16:08:06.763Z","status":500,"error":"Internal Server Error","path":"/api/v3/expenses"}
## 5 the DB refuses (a negative amount through the counter without @Valid)
HTTP/1.1 500   Content-Type: application/json
{"timestamp":"2026-10-30T16:08:06.824Z","status":500,"error":"Internal Server Error","path":"/api/novalid/expenses"}

A claim failing the marks and broken JSON got 400. Fine so far, except both say only “Bad Request”. And “a missing reference number” and “the same claim twice” got 500. Nobody caught the clerk's “not found” exception or the double click refused by the database's unique index, so both came back as “server failure”. The user's mistakes look like breakdowns. Monitoring counts 500s and raises alarms, so the operations team gets woken up at night too (part 13).

3

Turning on ProblemDetail

There is a standard shape for HTTP APIs reporting failures: ProblemDetail (RFC 9457, formerly RFC 7807). Spring Boot turns it on with one setting.

# Started with spring.mvc.problemdetails.enabled=true
## 1 fails the marks (empty item, negative amount)
HTTP/1.1 400   Content-Type: application/problem+json
{"detail":"Invalid request content.","instance":"/api/v3/expenses","status":400,"title":"Bad Request"}
## 2 broken JSON
HTTP/1.1 400   Content-Type: application/problem+json
{"detail":"Failed to read request","instance":"/api/v3/expenses","status":400,"title":"Bad Request"}
## 3 a reference number that doesn't exist
HTTP/1.1 500   Content-Type: application/json
{"timestamp":"2026-10-30T16:08:13.943Z","status":500,"error":"Internal Server Error","path":"/api/v3/expenses/EXP-2026-9999"}
## 4 the same claim twice (a double click)
HTTP/1.1 201   Content-Type: application/json
{"id":"EXP-2026-2897"}
HTTP/1.1 500   Content-Type: application/json
{"timestamp":"2026-10-30T16:08:14.102Z","status":500,"error":"Internal Server Error","path":"/api/v3/expenses"}
## 5 the DB refuses (a negative amount through the counter without @Valid)
HTTP/1.1 500   Content-Type: application/json
{"timestamp":"2026-10-30T16:08:14.161Z","status":500,"error":"Internal Server Error","path":"/api/novalid/expenses"}

The 400s became application/problem+json with title, detail and instance. But the content is “Invalid request content.”, with no hint of which field was wrong, and what should be 404 or 409 is still 500. The machinery for a tidy shape is there, but which exception means what is something only the application can decide.

4

One exception helper

The clerk simply throws a business exception when a claim isn't there. It knows nothing about HTTP.

@Transactional(readOnly = true)
public Expense find(String id) {
    return repo.findById(id).orElseThrow(() -> new ExpenseNotFoundException(id));  // not there → a business exception
}

Then we put one helper in the whole application to catch exceptions and build replies. The @ExceptionHandler methods of a class marked @RestControllerAdvice pick a status code and wording for each kind of exception.

@RestControllerAdvice
@ConditionalOnProperty("demo.advice")
public class ApiErrors {
    private static final Logger log = LoggerFactory.getLogger(ApiErrors.class);

    @ExceptionHandler(MethodArgumentNotValidException.class)  // fails the marks → 400, reasons per field
    ProblemDetail invalid(MethodArgumentNotValidException e) {
        ProblemDetail p = problem(HttpStatus.BAD_REQUEST, "invalid-request", "入力に誤りがあります", "直してから、もう一度送ってください");
        List<Map<String, String>> errors = e.getBindingResult().getFieldErrors().stream()
                .map(f -> Map.of("field", f.getField(), "message", String.valueOf(f.getDefaultMessage()))).toList();
        p.setProperty("errors", errors);
        return p;
    }

    @ExceptionHandler(HttpMessageNotReadableException.class)  // unreadable as JSON → 400
    ProblemDetail unreadable(HttpMessageNotReadableException e) {
        return problem(HttpStatus.BAD_REQUEST, "unreadable", "申請の形が読めません", "JSON の書き方か、項目の型が違います");
    }

    @ExceptionHandler(ExpenseNotFoundException.class)  // not there → 404
    ProblemDetail notFound(ExpenseNotFoundException e) {
        return problem(HttpStatus.NOT_FOUND, "expense-not-found", "申請が見つかりません", "受付番号 " + e.id() + " の申請はありません");
    }

    @ExceptionHandler(DuplicateKeyException.class)  // the same claim exists → 409
    ProblemDetail duplicate(DuplicateKeyException e) {
        return problem(HttpStatus.CONFLICT, "duplicate", "同じ申請がすでにあります", "同じ日・品目・金額の申請は、1回だけ出せます");
    }

    @ExceptionHandler(Exception.class)  // anything else → 500; hide details, link by number
    ProblemDetail unexpected(Exception e) {
        String no = "ERR-" + HexFormat.of().toHexDigits(ThreadLocalRandom.current().nextInt());
        log.error("問い合わせ番号 {}", no, e);
        ProblemDetail p = problem(HttpStatus.INTERNAL_SERVER_ERROR, "unexpected", "処理できませんでした", "時間をおいてもう一度お試しください。続くときは問い合わせ番号をお知らせください");
        p.setProperty("errorId", no);
        return p;
    }
# With an exception-catching helper (ApiErrors) added
## 1 fails the marks (empty item, negative amount)
HTTP/1.1 400   Content-Type: application/problem+json
{"detail":"直してから、もう一度送ってください","instance":"/api/v3/expenses","status":400,"title":"入力に誤りがあります","type":"https://expenses.example/problems/invalid-request","errors":[{"field":"item","message":"must not be blank"},{"field":"amount","message":"must be greater than 0"}]}
## 2 broken JSON
HTTP/1.1 400   Content-Type: application/problem+json
{"detail":"JSON の書き方か、項目の型が違います","instance":"/api/v3/expenses","status":400,"title":"申請の形が読めません","type":"https://expenses.example/problems/unreadable"}
## 3 a reference number that doesn't exist
HTTP/1.1 404   Content-Type: application/problem+json
{"detail":"受付番号 EXP-2026-9999 の申請はありません","instance":"/api/v3/expenses/EXP-2026-9999","status":404,"title":"申請が見つかりません","type":"https://expenses.example/problems/expense-not-found"}
## 4 the same claim twice (a double click)
HTTP/1.1 201   Content-Type: application/json
{"id":"EXP-2026-2900"}
HTTP/1.1 409   Content-Type: application/problem+json
{"detail":"同じ日・品目・金額の申請は、1回だけ出せます","instance":"/api/v3/expenses","status":409,"title":"同じ申請がすでにあります","type":"https://expenses.example/problems/duplicate"}
## 5 the DB refuses (a negative amount through the counter without @Valid)
HTTP/1.1 500   Content-Type: application/problem+json
{"detail":"時間をおいてもう一度お試しください。続くときは問い合わせ番号をお知らせください","instance":"/api/novalid/expenses","status":500,"title":"処理できませんでした","type":"https://expenses.example/problems/unexpected","errorId":"ERR-6ec9cea0"}

Plain Spring Boot versus with the helper (measured)

Request nothing added with the helper fails the marks 400 Bad Request 400 + reasons per field broken JSON 400 Bad Request 400 “can't read the claim” missing reference 500 404 “claim not found” same claim twice 500 409 “already exists” the DB refuses 500 500 + reference number “Not found” and double clicks were 500s, looking like server failures Enabling ProblemDetail alone only tidies the 400s (measured) Which exception means which status is the app's rule; write it once

The 400 gained per-field reasons, the missing reference number became 404, and the double click 409 (Conflict: it clashes with the current state). Only the database refusal is still a 500, and now it carries a reference number. The counter's code has not a single try-catch.

ProblemDetail fields and their readers

ProblemDetail (RFC 9457) fields and their readers type …/problems/duplicate programs branch on it title 同じ申請がすでにあります people read it status 409 programs branch on it detail 同じ日・品目・金額の申請は、1回だけ出せます people read it instance /api/v3/expenses programs branch on it errors [{field, message}…] screen shows per field errorId ERR-6ec9cea0 support searches logs below: fields we added (extensions) type names the problem kind; programs branch on it, whatever the wording Content-Type: application/problem+json; screens pick by status and type
5

Reasons in the user's language

Right after adding the helper, the 400's reasons were in English, like “must not be blank”. We sent the same claim with different language preferences (Accept-Language).

# The same claim with a different Accept-Language
## 1 fails the marks (Accept-Language: ja)
HTTP/1.1 400   Content-Type: application/problem+json
{"detail":"直してから、もう一度送ってください","instance":"/api/v3/expenses","status":400,"title":"入力に誤りがあります","type":"https://expenses.example/problems/invalid-request","errors":[{"field":"item","message":"空白は許可されていません"},{"field":"amount","message":"0 より大きな値にしてください"}]}
## 1 fails the marks (Accept-Language: en)
HTTP/1.1 400   Content-Type: application/problem+json
{"detail":"直してから、もう一度送ってください","instance":"/api/v3/expenses","status":400,"title":"入力に誤りがあります","type":"https://expenses.example/problems/invalid-request","errors":[{"field":"amount","message":"must be greater than 0"},{"field":"item","message":"must not be blank"}]}

With Accept-Language: ja the reasons came back in Japanese. Spring picks the marks' wording to match the request's language, and browsers send the user's language automatically, so claims from the screen get reasons in that language. The default wording is a little stiff, though; in production, write your own in the marks (message = "…") or provide a message list (ValidationMessages.properties).

6

Don't swallow exceptions

Here is a pattern common in old-school code before helpers existed: catch the exception, print it, carry on.

# The old-school clerk (the gist of SwallowService)
public String submit(ExpenseRequest req) {
    String id = "EXP-2026-%04d".formatted(repo.nextNumber());
    try {
        repo.insert(id, "新井", req);
    } catch (Exception e) {
        e.printStackTrace();  // the screen learns nothing
    }
    return id;  // returns a number even if nothing was written
}
## 6 a negative amount to the clerk who swallows exceptions
HTTP/1.1 201   Content-Type: application/json
{"id":"EXP-2026-2903"}
# Looking up that reference number
HTTP/1.1 404   Content-Type: application/problem+json
{"detail":"受付番号 EXP-2026-2903 の申請はありません","instance":"/api/v3/expenses/EXP-2026-2903","status":404,"title":"申請が見つかりません","type":"https://expenses.example/problems/expense-not-found"}
# Application log (part of the stack trace printed to stderr)
    at java.base/java.lang.Thread.run(Thread.java:1474)
Caused by: org.postgresql.util.PSQLException: ERROR: new row for relation "expense" violates check constraint "amount_positive"
  Detail: Failing row contains (EXP-2026-2903, 2026-10-28, 交通費, -1280.00, submitted, 2026-10-31 01:08:22.039223+09, 新井).
    at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2993)

The database refused the negative amount, yet the screen got 201 and a reference number. The user goes home thinking it worked, and at month end accounting says “there's no such claim”. The log does show the exception, but nothing links it to the request. If you catch and do nothing, don't catch. Let exceptions rise to the helper and the transaction rolls back (part 5) and the reply is right.

AraiWith a reference number on screen, anyone would believe it…

FuruiWorse than an error is a false success. Users notice errors; accounting notices false successes a month later.

7

Link 500s with a reference number

Whatever matches no exception type (the database refused, another system is down, a bug) really is the server's problem, so it's a 500. The helper writes no details into the reply, just a number, and logs everything under the same number.

A terminal. At the bottom, a claim the database refuses gets a 500 whose body has a Japanese title meaning could not process and errorId ERR-a6a0bb49. At the top, the application log shows ERROR ApiErrors with the same reference number, then DataIntegrityViolationException, the SQL and the Failing row contains line
The user gets only “could not process” and a reference number (bottom). Under the same number, the log keeps everything, down to the SQL and the failing row (top) (Captured by the author on a cloud Linux machine on 31 October 2026. The bar across the top of the window was added for the illustration.)
# Meanwhile, the application log (same reference, full details)
01:08:21.825 http-nio-8080-exec-7   ERROR ApiErrors                    問い合わせ番号 ERR-6ec9cea0
org.springframework.dao.DataIntegrityViolationException: PreparedStatementCallback; SQL [insert into expense (id, applicant, applied_on, item, amount, status) values (?, ?, ?, ?, ?, 'submitted')]; ERR
  Detail: Failing row contains (EXP-2026-2902, 2026-10-28, 交通費, -1280.00, submitted, 2026-10-31 01:08:21.818124+09, 新井).
	at org.springframework.jdbc.support.SQLStateSQLExceptionTranslator.doTranslate(SQLStateSQLExceptionTranslator.java:140)

If the user tells us “it said ERR-6ec9cea0”, we land on the log's first line. Conversely, there are settings that write the details into the reply.

A terminal. Sending a claim the database refuses to an application with include-message and include-stacktrace set to always returns a 500 JSON whose trace holds the stack trace, showing the column names in insert into expense, the CHECK constraint name amount_positive, the failing row's contents, and class names and line numbers from Spring and the PostgreSQL driver
The setting not to use. The reply body carried table and column names, the constraint name, the row's contents, and clues to which components and versions are in use (Captured by the author on a cloud Linux machine on 31 October 2026. The bar across the top of the window was added for the illustration.)
# Started with spring.web.error.include-message=always and include-stacktrace=always
## 5 the DB refuses (include-message=always, include-stacktrace=always)
HTTP/1.1 500   Content-Type: application/json
{"timestamp":"2026-10-30T16:08:28.782Z","status":500,"error":"Internal Server Error","trace":"org.springframework.dao.DataIntegrityViolationException: PreparedStatementCallback; SQL [insert into expense (id, applicant, applied_on, item, amount, status) v…(91 行、8842 文字)","message":"PreparedStatementCallback; SQL [insert into expense (id, applicant, applied_on, item, amount, status) values (?, ?, ?, ?, ?, 'submitted')]; ERROR: new row for relation \"expense\" violates check constraint \"amount_positive\"\n  Detail: Failing row contains (EXP-2026-2904, 2026-10-28, 交通費, -1280.00, submitted, 2026-10-31 01:08:28.733548+09, 新井).","path":"/api/novalid/expenses"}

The reply carried a 91-line, 8,842-character stack trace, the SQL, table and column names, the constraint name and the failing row's contents. Component names and line numbers tell an attacker which weaknesses to try (part 11). Use include-stacktrace and include-message nowhere but your own development PC.

8

Choosing status codes

StatusMeaningIn the expense systemWho fixes it
400the request's shape is wrongempty item, broken JSONthe sender (fix and resend)
401 / 403unknown caller / not allowednot logged in, someone else's claim (part 11)the sender
404not therea missing reference numberthe sender
409clashes with the current statedouble click, editing an approved claim (part 10)the sender (recheck the state)
500the server's problemdatabase refused, a bugthe server side (reference number)
503can't take it right nowmid-deployment, overloaded (part 15)waiting fixes it

Exceptions go up and become a reply in one place

storekeeper Repository clerk Service counter Controller error helper ApiErrors Exceptions go up and become a reply in one place translates DB errors into Spring's throws business exceptions writes no try-catch picks a status per exception application/problem+json status + reason log: everything stack trace, SQL, reference no. Don't catch halfway; catch-and-ignore turns failure into success (§6) The clerk just throws “not found” or “already exists”; it knows no HTTP The helper maps exception types to status and wording, once for all How to fix for the user, evidence for the log, linked by a reference
POINT

4xx means “the sender can fix it”; 5xx means “the server's problem”. Keep that line and the screen can choose between showing how to fix and apologising, and monitoring counts only real failures. When unsure, ask: would it pass if the user changed something?

9

How the screen takes it

The “red text next to the field” from part 9 of the frontend series can show the server's reasons too. For example:

const res = await fetch("/api/v3/expenses", {
  method: "POST",
  headers: { "Content-Type": "application/json" },   // the browser adds Accept-Language
  body: JSON.stringify(form),
});
if (res.ok) { /* show the reference number */ }
else if (res.headers.get("Content-Type")?.startsWith("application/problem+json")) {
  const p = await res.json();
  (p.errors ?? []).forEach(e => showFieldError(e.field, e.message));   // next to each field
  if (p.status === 409) showMessage(p.title);                           // double click
  if (p.errorId) showMessage(`${p.title} (reference ${p.errorId})`);
}

The screen chooses how to show it by status and type, and can show title and detail as they are. The screen should also disable the button against double clicks (frontend series); the server's 409 is the last brake for when that's bypassed.

10

Checking on your own PC

What to tryHow to write it
Use the ProblemDetail shapespring.mvc.problemdetails.enabled=true
Add an exception helper@RestControllerAdvice + @ExceptionHandler
Add fieldsproblem.setProperty("errors", …)
Try another languagecurl -H 'Accept-Language: ja' …
See the reply's shape and statuscurl -si … | head
Never in productionspring.web.error.include-stacktrace=always

In Spring Boot 4, the settings for including stack traces and the like in replies moved from server.error.* to spring.web.error.*. Copying setting names from older articles may do nothing; in this experiment the old names changed nothing.

§ — On one page, and next time

  1. With nothing added, “not found” and double clicks are 500s: the user's mistakes look like failures.
  2. ProblemDetail (RFC 9457) tidies the shape, but the application decides which exception means what.
  3. The clerk throws; one @RestControllerAdvice helper turns exceptions into status codes and wording. No try-catch in counters.
  4. 400s carry per-field reasons in the user's language.
  5. Don't swallow exceptions. A false success, a 201 for nothing written, is the worst.
  6. 500s hide the details and link to the log by reference number. Never send stack traces.

AraiSo we've graduated from “A system error has occurred”.

FuruiWe have. Next time, back to the storekeeper: JdbcClient with hand-written SQL, JPA and MyBatis. We'll write the same claims list three ways and compare what happens.

References

The recordings were made on 31 October 2026 on a cloud Linux machine (Ubuntu 24.04, kernel 6.18) with Spring Boot 4.1.2 (Spring Framework 7, Tomcat 11, Hibernate Validator 9), OpenJDK 25.0.4 and PostgreSQL 16.15. The PC, the claims server and the database server are separate network spaces (network namespaces) inside one machine. Reference numbers change every run. The screen code is an illustrative example. Every number and output in the text is a real value read from the commands' output at the time. The characters are fictional.

Old-School Backend, part 7 — Exceptions and replies / 2026.10

← Back to all posts