Old-School Backend / 2026 ・ No.06

Old-School Backend

Part 6: Input checks — what to check, where, and how far

Published

A terminal. python3 send.py c1 sends to the counter missing @Valid: an empty item, an item of one full-width space, a negative amount and an amount of 0 all get 200 and land in the database, and the claim with no date gets 500 from a not-null violation in the database. send.py c2 sends to the counter with @Valid: all five get 400, with item: must not be blank, amount: must be greater than 0 and date: must not be null
The same five claims sent to a counter missing @Valid (top) and one with it (bottom). Without it, an empty item and a negative amount went straight into the database (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 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.

1

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)

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
  • @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.

2

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.

3

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.

4

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
5

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)

Claim part 1's marks (v1) strict marks (v2) spaces around stored with spaces stripped, stored ¥1280.125 rounded to 1280.13 400 @Digits 101-char item 500 (the DB says no) 400 @Size(max=100) ¥1 trillion 500 (the DB says no) 400 @Digits a future date stored as is 400 @PastOrPresent ¥60,000, no reason stored as is 400 @AssertTrue Missing marks mean silent saves, or a 500 from the database Keep the marks in step with the DB types (varchar(100), numeric(12,2)) The DB rounded the ¥0.005 silently; decide fractions yourself (part 2) Strip before checking; a fix made after checking goes unchecked
  • 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).

6

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.

7

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.

A terminal. date with TZ=Asia/Tokyo shows 2026-10-31 00:55:07 JST and with TZ=UTC 2026-10-30 15:55:07 UTC. A claim dated 2026-10-31 gets 400 with date: must be a date in the past or in the present
Just after midnight on 31 October in Tokyo. A claim dated that today was rejected as a future date by the application with no time zone set (running in UTC) (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.)
# 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)

@PastOrPresent's “today” is the JVM clock's today Tokyo (the user) 00:55 2026-10-31 JVM (unset = UTC) 15:55 2026-10-30 claim date 2026-10-31 future → 400 Today for the user; still tomorrow for the JVM The same claim got 201 from the app started with -Duser.timezone=Asia/Tokyo Every day from midnight to 09:00, Tokyo's today is rejected Same root as part 2's homework: decide whose today a rule means Pin the zone at startup (-Duser.timezone) or use the office's clock

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.

8

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.

A terminal. psql's \d expense shows Check constraints: amount_positive CHECK (amount > 0::numeric). A negative amount through the counter without @Valid gets 500 with violates check constraint amount_positive, and a direct insert in psql fails with ERROR: new row for relation expense violates check constraint amount_positive
With a CHECK constraint saying the amount must be above 0, negative amounts no longer get in, whether through the counter without @Valid or written directly with psql (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.)
# 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.

9

What to check, and where

Checkpoints before a claim reaches the database

Screen frontend required min, max (bypassable) JSON Jackson types match? 30 February unknown fields Shape Validation empty, null range, digits future dates Rules clerk across fields look at the DB (parts 7, 10) DB PostgreSQL NOT NULL varchar(100) CHECK Checkpoints before a claim reaches the database Screen checks serve the user; the four behind protect the server Earlier: sooner, clearer refusals; later: surer but crude (500) JSON: a type mismatch is 400; unknown fields are dropped by default Shape: works only on counters with @Valid; forget it, nothing is checked DB: last line, even for writes that skip the app; but it answers 500
POINT

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.

10

Checking on your own PC

What to tryHow 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 fieldsspring.jackson.deserialization.fail-on-unknown-properties=true
Make the JVM's today Tokyo'sjava -Duser.timezone=Asia/Tokyo -jar app.jar
Give the database rules tooalter 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

  1. Screen checks are for the user. The server checks no matter who sent it.
  2. Bean Validation marks work only on counters with @Valid. Forget it and empty and negative values went in.
  3. Match marks to the database types. Otherwise values are silently rounded or reach the database and become 500s.
  4. Clean before checking (strip). Rules across fields: @AssertTrue; rules needing the database: the clerk.
  5. At the JSON stage, the string "1280" passes and full-width gets 400. Whether to reject unknown fields is a rule to write down.
  6. “Today” is the JVM's today. The UTC application rejected Tokyo's today from midnight to 09:00 every day.
  7. 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

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

← Back to all posts