Old-School Backend / 2026 ・ No.06
Old-School Backend
Part 6: Input checks — what to check, where, and how far
Published
Last time Jackson silently dropped an unknown field. So where do an empty item or a negative amount get stopped? Part 9 of the frontend series checked input on the screen, but the server also receives claims that never pass through it.
This time we try, one by one, the checkpoints a claim passes before reaching the database: @Valid and Bean Validation, where the marks fall short, the JSON stage, whose “today” today is, and the last line, database constraints.
Checking at the door
AraiWe did input checks on the screen in the frontend series. The one that goes red when the amount is empty.
FuruiThat's a kindness to the user. Send it with curl and the screen is never involved.
AraiWho would do that?
FuruiOther systems, old apps, pranksters. The server checks no matter who sent it.
The 15-part route map (you are here: part 6)
- @Valid — on a counter missing it, an empty item and a negative amount went into the database (measured)
- Marks fall short — ¥1280.125 silently became ¥1280.13, and a 101-character item became a 500 (measured)
- The JSON stage — the string "1280" passes, full-width "1280" gets 400; one setting rejects unknown fields (measured)
- Today — a UTC application rejected a claim dated Tokyo's today as “future” (measured)
- Database constraints — a CHECK also stopped writes that skip the application (measured)
The stage is the same three machines. The application is last time's Spring Boot 4.1.2 (Hibernate Validator 9, Jackson 3) with three counters of differing strictness: /api/novalid (no @Valid), /api/v1 (part 1's marks) and /api/v2 (strict marks). Claims were sent from the PC, and for accepted ones we checked what landed in the database.
Forget @Valid, and…
Part 1's claim shape (ExpenseRequest) already carries marks. But the marks only work when the counter's argument has @Valid.
@PostMapping("/api/novalid/expenses") // a counter missing @Valid
public Map<String, String> noValid(@RequestBody ExpenseRequest req) {
return Map.of("id", service.submit(req));
}
@PostMapping("/api/v1/expenses") // part 1's marks (@NotNull, @NotBlank, @Positive)
public Map<String, String> v1(@Valid @RequestBody ExpenseRequest req) {
return Map.of("id", service.submit(req));
}
# A counter missing @Valid (/api/novalid/expenses) a empty item 200 stored: 2026-10-28||1280.00 a2 item is one full-width space 200 stored: 2026-10-28| |1280.00 b negative amount 200 stored: 2026-10-28|交通費|-1280.00 c no date 500 ERROR: null value in column "applied_on" of relation "expense" violates not-null constraint d amount 0 200 stored: 2026-10-28|交通費|0.00 # @Valid with part 1's marks (/api/v1/expenses) a empty item 400 item: must not be blank a2 item is one full-width space 400 item: must not be blank b negative amount 400 amount: must be greater than 0 c no date 400 date: must not be null d amount 0 400 amount: must be greater than 0
The counter without @Valid stored an empty item, an item of one full-width space, a negative amount and ¥0 as they were. Only the claim with no date got a 500, and that was the database's NOT NULL, not the application. The counter with @Valid rejected all five with 400, and the log recorded which field failed and why. Forget one word, and every mark becomes decoration.
Bean Validation marks
public record ExpenseRequest(
@NotNull LocalDate date,
@NotBlank String item,
@NotNull @Positive BigDecimal amount) {
}
The @NotNull (not null), @NotBlank (text that isn't only spaces) and @Positive (greater than 0) on the fields are Bean Validation (Jakarta Validation) marks. In Spring Boot, Hibernate Validator reads them and checks arguments marked @Valid before the counter gets them. An item of one full-width space was rejected by @NotBlank too.
The good thing about marks is that a rule like “the amount is positive” sits right next to the claim's shape. But the 400 reply, as last time, says only “Bad Request”; which field was wrong never reaches the user. Making the reply carry that is next time's job.
Claims that pass the marks but cause trouble
Part 1's marks are the bare minimum. We sent six claims that slip past them and cause trouble.
# Claims that pass part 1's marks but cause trouble (/api/v1/expenses) e spaces around the item 200 stored: 2026-10-28| 交通費 |1280.00 f amount 1280.125 200 stored: 2026-10-28|交通費|1280.13 g item of 101 characters 500 ERROR: value too long for type character varying(100) h amount ¥1 trillion 500 ERROR: numeric field overflow i a future date 200 stored: 2027-04-01|交通費|1280.00 j ¥60,000, no reason 200 stored: 2026-10-28|接待費|60000.00
- Surrounding spaces — “ 交通費 ” was stored spaces and all, and counts as a different item from 交通費 in totals
- ¥1280.125 — the database's numeric(12,2) silently rounded it to ¥1280.13. Nobody decided how to round (part 2)
- A 101-character item, ¥1 trillion — the application let them through and the database types (varchar(100), numeric(12,2)) refused: 500. To the user, it looks like a server failure
- A future date, ¥60,000 with no reason — the business wants to refuse them, but with no marks they were stored
Strict marks, and cleaning before checking
We made a claim shape with more marks (StrictExpenseRequest).
public record StrictExpenseRequest(
@NotNull @PastOrPresent LocalDate date, // no future dates
@NotBlank @Size(max = 100) String item, // matches varchar(100) in the DB
@NotNull @Positive @Digits(integer = 9, fraction = 0) BigDecimal amount, // yen: no decimals, up to 9 digits
@Size(max = 200) String reason) {
public StrictExpenseRequest {
if (item != null) item = item.strip(); // strip spaces (full-width too) before checking
if (reason != null) reason = reason.strip();
}
@AssertTrue(message = "5万円を超える申請には理由が必要です")
public boolean isReasonGivenForLargeAmount() { // a rule across fields
return amount == null || amount.compareTo(new BigDecimal("50000")) <= 0 || (reason != null && !reason.isEmpty());
}
# The same claims at the strict counter (/api/v2/expenses) e spaces around the item 200 stored: 2026-10-28|交通費|1280.00 f amount 1280.125 400 amount: numeric value out of bounds (<9 digits>.<0 digits> expected) g item of 101 characters 400 item: size must be between 0 and 100 h amount ¥1 trillion 400 amount: numeric value out of bounds (<9 digits>.<0 digits> expected); reasonGivenForLargeAmount: 5万円を超える申請には理由が必要です i a future date 400 date: must be a date in the past or in the present j ¥60,000, no reason 400 reasonGivenForLargeAmount: 5万円を超える申請には理由が必要です k ¥60,000 with a reason 200 stored: 2026-10-28|接待費|60000.00
Part 1's marks versus strict marks (measured)
- Match the database types — @Size(max = 100) and @Digits(integer = 9, fraction = 0). What used to be a 500 from the database became a 400
- Clean before checking — strip() (full-width spaces included) in the record's constructor. Fix a value after checking and the fixed value was never checked
- Rules across fields — “over ¥50,000 needs a reason” looks at both amount and reason; a method marked @AssertTrue does it
Marks can only express “what this one claim alone decides”, though. Rules you can't know without looking at the database, like “there's already a travel claim for the same amount that day” or “a claim approved by a manager can't be changed”, are the clerk's (Service's) job (part 10).
What happens at the JSON stage
Before Bean Validation, Jackson turns JSON into Java values. Here too, some things are refused and some surprisingly pass.
# What happens at the JSON stage (/api/v2/expenses) l amount as the string "1280" 200 stored: 2026-10-28|交通費|1280.00 m amount in full-width "1280" 400 Cannot deserialize value of type `java.math.BigDecimal` from String "1280": not a valid representation n amount "1,280" 400 Cannot deserialize value of type `java.math.BigDecimal` from String "1,280": not a valid representation o item as the number 123 200 stored: 2026-10-28|123|1280.00 p amount true 400 Cannot deserialize value of type `java.math.BigDecimal` from Boolean value (token `JsonToken.VALUE_TRUE`) q 30 February 400 Cannot deserialize value of type `java.time.LocalDate` from String "2026-02-30": Failed to deserialize `java.time.LocalDate` r unknown field receipts 200 stored: 2026-10-28|交通費|1280.00 # Started with spring.jackson.deserialization.fail-on-unknown-properties=true r unknown field receipts 400 Unrecognized property "receipts" (class example.expenses.StrictExpenseRequest), not marked as ignorable
Jackson helpfully turned the string "1280" into a number and the number 123 into the item text “123”. Full-width "1280", on the other hand, got 400. In part 2, new BigDecimal("1280") read it as 1280, so the answer depends on the tool. "1,280", true and 30 February got 400 too.
The receipts silently dropped last time was rejected with 400 when we started with spring.jackson.deserialization.fail-on-unknown-properties=true. If one team builds both the screen and the API, rejecting is safer: you catch mistakes early. For an API published to other companies and growing new fields, letting unknown fields pass avoids breaking older clients. Which one you choose is a rule to decide and write down.
Whose “today” is today?
We tried the strict marks' @PastOrPresent (today or earlier) at 00:55 on 31 October in Tokyo, with that day's date.
# Tokyo: just after midnight on 31 October; UTC: 15:xx on the 30th. JVM with no time zone set (UTC) s today (Tokyo) 2026-10-31 400 date: must be a date in the past or in the present # The same claim, to the app started with -Duser.timezone=Asia/Tokyo s today (Tokyo) 2026-10-31 200 stored: 2026-10-31|交通費|1280.00
“Today” is the JVM clock's today (measured)
The application's JVM has no time zone set and runs in UTC. In UTC it was still 30 October, so a claim dated Tokyo's today was rejected as a future date. Every day from midnight to 09:00, Tokyo's users can't submit today's claims. It's the same root as part 2's homework. Started with -Duser.timezone=Asia/Tokyo, the same claim got 201. For any rule that uses “today”, first decide whose today.
AraiWho submits claims in the middle of the night…
FuruiSomeone back from a business trip on the last train, filing from home. Those are exactly the people who get angry when refused.
The last line: database constraints
The application's marks only work on claims that pass through the application. Migration scripts, other systems and hand edits in psql write to the database directly. So the database gets rules of its own.
# Adding a CHECK constraint to the database alter table expense add constraint amount_positive check (amount > 0); # A negative amount through the counter without @Valid b negative amount 500 ERROR: new row for relation "expense" violates check constraint "amount_positive" # Even writing directly with psql ERROR: new row for relation "expense" violates check constraint "amount_positive" DETAIL: Failing row contains (EXP-2026-9999, 2026-10-28, 交通費, -1280.00, submitted, 2026-10-31 00:53:08.807753+09, 新井).
With CHECK (amount > 0), negative amounts no longer got in, through the counter without @Valid or written directly in psql. In section 2, what stopped the dateless claim was the database's NOT NULL too. But when the database refuses, the application gets an exception and the user gets a 500. Database constraints are the last line; explaining things kindly to the user is the job of the checkpoints in front.
What to check, and where
Checkpoints before a claim reaches the database
Screen checks are for the user; server checks are for the data. Writing the same rule in both places isn't waste. On the server: JSON types (Jackson), field shapes (Bean Validation with @Valid), business rules that need the database (the clerk), and database constraints. The earlier ones refuse sooner and more clearly; the later ones are surer.
Checking on your own PC
| What to try | How to write it |
|---|---|
| Make marks work at the counter | @Valid @RequestBody StrictExpenseRequest req |
| Match the database's length and digits | @Size(max = 100), @Digits(integer = 9, fraction = 0) |
| A rule across fields | @AssertTrue public boolean isReasonGiven…() |
| Reject unknown fields | spring.jackson.deserialization.fail-on-unknown-properties=true |
| Make the JVM's today Tokyo's | java -Duser.timezone=Asia/Tokyo -jar app.jar |
| Give the database rules too | alter table expense add check (amount > 0); |
The marks' messages change with the JVM's language setting; this experiment ran with LANG=C.UTF-8, so they were in English. Choosing the wording users see yourself, rather than relying on the marks' defaults, also belongs to next time's topic of building replies.
§ — On one page, and next time
- Screen checks are for the user. The server checks no matter who sent it.
- Bean Validation marks work only on counters with @Valid. Forget it and empty and negative values went in.
- Match marks to the database types. Otherwise values are silently rounded or reach the database and become 500s.
- Clean before checking (strip). Rules across fields: @AssertTrue; rules needing the database: the clerk.
- At the JSON stage, the string "1280" passes and full-width gets 400. Whether to reject unknown fields is a rule to write down.
- “Today” is the JVM's today. The UTC application rejected Tokyo's today from midnight to 09:00 every day.
- Database constraints are the last line, stopping even writes that skip the application, but the reply is a 500.
AraiWe can refuse now, but replies that just say “Bad Request” or 500 feel unkind.
FuruiThat's next time: where to catch exceptions, which status code, and what to write back. Time to graduate from the old-school “A system error has occurred.”
References
- Jakarta Validation 3.1
- Hibernate Validator — Documentation
- Spring Framework — Java Bean Validation
- Spring Boot — Validation
- PostgreSQL 16 — Constraints(CHECK、NOT NULL)
- OWASP — Input Validation Cheat Sheet
The recordings were made on 31 October 2026 (Tokyo time) on a cloud Linux machine (Ubuntu 24.04, kernel 6.18) with Spring Boot 4.1.2 (Hibernate Validator 9, Jackson 3), 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. The “today” experiment ran just after midnight in Tokyo (15:xx UTC). 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 6 — Input checks / 2026.10