Old-School Backend / 2026 ・ No.02
Old-School Backend
Part 2: Java basics — holding money, dates and times correctly
Published
Part 1's homework: “The claim date was the 26th, the reply's Date header said the 25th, the log said a little after 15:00 and the database a little after midnight. Which is right?”
This part covers Java basics, centred on what goes wrong most often in business systems, money and dates and times, plus the null, record, list and string traps that can eat a whole day. Each is measured in jshell and small programs.
Before checking the homework
AraiThe homework answer is “all of them”, right?
FuruiRight. But if you can't explain why, one day you'll send out an invoice nine hours off.
AraiNine hours…
FuruiOr one yen off. Today we start with holding money and time correctly.
Route map of all 15 parts (you are here: part 2)
- Money — computing the pre-tax amount of ¥1,100 with double gives ¥999; creating, comparing and rounding BigDecimal (measured)
- Dates and times — the same instant has different dates in different places; JVM and database time zone settings (measured)
- Old tools' traps — a shared SimpleDateFormat garbled 4,763 dates out of 80,000 (measured)
- null, records, strings — the three-character 交通費 (travel) is nine bytes (measured, with Wireshark)
The experiments use JDK 25's jshell (a tool for trying Java one line at a time) and small programs run straight from source, like java Lesson2.java. The database is part 1's PostgreSQL.
No double for money
The first type you learn for decimals in Java is double. Here's what happens when it's used for expenses.
# Doing money with double (binary fractions)
0.1 + 0.2 → 0.30000000000000004
0.1 added ten times → 0.9999999999999999
# Pre-tax amount of ¥1,100 including 10% tax?
1100 / 1.1 → 999.9999999999999
Math.floor(1100 / 1.1) → 999.0
Math.floor(1080 / 1.08) → 999.0
# $0.29 in cents
(int) (0.29 * 100) → 28
0.1 + 0.2 isn't 0.3. Working out the pre-tax amount of a ¥1,100 tax-inclusive price by “divide by 1.1 and round down” gives ¥999 instead of ¥1,000. $0.29 becomes 28 cents. Doing the same for every tax-inclusive amount from ¥1 to ¥100,000, 4,174 came out a yen short at 10% tax, and 818 at 8%.
0.1 never ends in binary
A double holds numbers in binary. Just as 1/3 never ends in decimal, 0.1 never ends in binary, so a double holds the nearest value it can within 52 bits. Rounding at display time usually hides it, but repeated additions and divisions bring it out. That's negligible in science, but with money, one yen matters.
BigDecimal manners
For money, use BigDecimal: it keeps numbers in decimal, with as many digits as needed. It does have manners, though.
# Creating a BigDecimal
new BigDecimal(0.1) → 0.1000000000000000055511151231257827021181583404541015625
new BigDecimal("0.1") → 0.1
BigDecimal.valueOf(0.1) → 0.1
# Arithmetic
0.1 + 0.2 → 0.3
1100 / 1.1 → 1.00E+3
(1100 / 1.1).toPlainString() → 1000
# Are 1280 and 1280.00 the same?
a.equals(b) → false
a.compareTo(b) → 0
new HashSet<>(List.of(a, b)).size() → 2
- Create it from a string — new BigDecimal(0.1) copies the double's “nearest value” exactly, long tail and all. Use new BigDecimal("0.1") or BigDecimal.valueOf(0.1)
- Compare with compareTo — equals also compares the number of decimal places (the scale), so 1280 and 1280.00 are “different”. In part 1 the database returned 1280.00; compared with the 1280 from the page using equals, they don't match
- Display with toPlainString — division results can come out in exponent form like 1.00E+3
AraiPutting 1280 and 1280.00 in a HashSet gives two… so duplicate checks don't work?
FuruiRight. If an amount is a key, normalise it first with stripTrailingZeros() or setScale. The old bookkeepers lined up the decimal places before checking, too.
Rounding
Money calculations always leave fractions. BigDecimal is designed so that you choose how to round them.
# 8% tax: ¥1,285 × 0.08
tax = 1285 × 0.08 → 102.80
tax.setScale(0, RoundingMode.DOWN) → 102
tax.setScale(0, RoundingMode.HALF_UP) → 103
# Rounding modes: HALF_UP / HALF_EVEN / DOWN / FLOOR
2.5 → 3 / 2 / 2 / 2
3.5 → 4 / 4 / 3 / 3
-2.5 → -3 / -2 / -2 / -3
# Splitting ¥10,000 three ways
10000 / 3 → ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.
divide(3, 0, RoundingMode.DOWN) → 3333
divideAndRemainder(3) → [3333, 1]
# Total (with a stream)
1280 + 3300 + 880.50 → 5460.50
The same number, different results by rounding mode (measured)
8% of ¥1,285 is ¥102.80: ¥102 rounded down, ¥103 rounded half up. Which is right is a business rule, not a technical question; it comes from company policy or what's agreed with the other party. Once decided, keep it in one place in the code.
Splitting ¥10,000 three ways, BigDecimal threw an exception saying it can't divide exactly. Refusing to round silently is actually a kindness. Asked to round down to whole yen it gives ¥3,333, and divideAndRemainder also reports the leftover yen. Who pays that yen is a business rule too.
Dates and times: whose clock?
Time for the homework. Here is the same instant as part 1's Date header, on the clocks of four places.
# The same instant as part 1's Date header, in four places' local time
Instant → 2026-10-25T15:47:43Z
UTC → 2026-10-25T15:47:43 Z date 2026-10-25
Asia/Tokyo → 2026-10-26T00:47:43 +09:00 date 2026-10-26
Asia/Shanghai → 2026-10-25T23:47:43 +08:00 date 2026-10-25
America/New_York → 2026-10-25T11:47:43 -04:00 date 2026-10-25
HTTP Date → Sun, 25 Oct 2026 15:47:43 GMT
One instant worldwide, read on local clocks
A time is one instant worldwide plus whose clock you read it on. The same instant is 0:47 on the 26th in Tokyo and 15:47 on the 25th in UTC. HTTP's Date is GMT (UTC) by rule; part 1's expense server started with no time zone setting, so it used UTC; the database was created with Tokyo as its setting, so it used Tokyo's clock. All pointed at the same instant.
The scary part is code that asks “what's today on this JVM's clock?”, like LocalDate.now(). We asked two JVMs at the same moment, differing only in time zone setting.
# Ask "now" from two JVMs at the same moment, differing only in time zone setting
# JVM time zone: UTC
LocalDate.now() → 2026-10-25
LocalDateTime.now() → 2026-10-25T16:05:59
Instant.now() → 2026-10-25T16:05:59Z
# JVM time zone: Asia/Tokyo
LocalDate.now() → 2026-10-26
LocalDateTime.now() → 2026-10-26T01:06
Instant.now() → 2026-10-25T16:06:00Z
One says the 25th, the other the 26th. Depending on the server's setting, a claim made between midnight and 9 a.m. in Japan gets the previous day as its claim date. A date the user picks on a calendar, like the claim date, should arrive from the page as a LocalDate, never be decided by the server's “today”.
Storing times in the database
The same trap waits in the database. PostgreSQL has timestamp, with no time zone, and timestamptz, which holds an instant. At the same moment, a UTC JVM and a Tokyo JVM each wrote a row.
# At the same moment, a UTC JVM and an Asia/Tokyo JVM each write a row
# wall = LocalDateTime into timestamp; moment = OffsetDateTime into timestamptz
ps.setObject(2, LocalDateTime.now()); // wall
ps.setObject(3, OffsetDateTime.now()); // moment
# Read back (same result from either JVM)
Asia/Tokyo wall=2026-10-26T01:06:41 moment=2026-10-25T16:06:41Z
UTC wall=2026-10-25T16:06:41 moment=2026-10-25T16:06:41Z
The wall column, a LocalDateTime stored in timestamp, is nine hours apart for the same instant: it kept only the numbers on the wall clock and threw away whose clock it was. The moment column, stored in timestamptz, points at the same instant in both rows; changing where you display it only changes how the numbers look.
Pairing Java types with database types
Hold record times (created, approved) as Instant or OffsetDateTime, stored as timestamptz. Hold calendar dates like the claim date as LocalDate and date. Fix the server's time zone at startup (for example -Duser.timezone=UTC) and convert to the user's local clock only when showing it to them.
The old Date and SimpleDateFormat
Older code often uses java.util.Date for dates and SimpleDateFormat for formats; you'll surely meet them in legacy systems. Both have a famous trap. We shared one SimpleDateFormat among eight threads, each reading and writing dates 10,000 times.
# One formatter shared by 8 threads, each parsing and formatting dates 10,000 times (counts vary per run)
SimpleDateFormat → wrong date 4763, exceptions 590
DateTimeFormatter → wrong date 0, exceptions 0
Of 80,000, 4,763 came out as a different date and 590 stopped with an exception (the counts change every run). SimpleDateFormat keeps working state inside and, used at the same time, threads overwrite each other's. As part 1 showed, a server runs one thread per request in parallel, so this becomes the hardest bug of all to find: “sometimes a strange date gets in”. Since Java 8, java.time (LocalDate, DateTimeFormatter and friends) never changes once created (it's immutable), and shared it produced zero errors.
AraiAn “old-school” series telling us not to use the old stuff?
FuruiOld craftsmen look after their tools. Knowing an old tool's quirks and then switching to a better one is part of the craft.
null and Optional
Java variables can hold null, meaning “nothing here”. Use a null as if it were something, and you stop with a NullPointerException.
# A submission whose item is null
r.item().length() → NullPointerException: Cannot invoke "String.length()" because the return value of "ExpenseRequest.item()" is null
# Looking up a reference number that doesn't exist
status.get("EXP-2026-9999") → null
Optional.ofNullable(…).orElse("見つからない") → 見つからない
Modern Java tells you in a sentence what was null: it “cannot invoke String.length() because the return value of ExpenseRequest.item() is null”. It used to give just a line number; this alone saves a lot of digging.
When a result might be missing, wrapping it in Optional instead of returning null makes it harder for the caller to forget the “missing” case. We'll use it to look up claims by reference number (part 8). And checking at the door that incoming values aren't null is part 6's validation.
Records, lists and strings
Part 1's ExpenseRequest was written as a record: a type for carrying values that can't be changed once created.
# record: compared by contents
x → ExpenseRequest[date=2026-10-26, item=交通費, amount=1280]
x.equals(y) → true
x == y → false
x.equals(z) // z has 1280.00 → false
# A list made with List.of can't be changed afterwards
List.of("交通費", "会議費").add("宿泊費") → UnsupportedOperationException: null
# Compare strings with equals, not ==
"交通費" == new String("交通費") → false
"交通費".equals(new String("交通費")) → true
Records compare by contents, so equal contents make equals true. But with amounts of 1280 and 1280.00, BigDecimal's equals makes it false. == asks “is it the same object?”, so equal contents still give false. Strings work the same way: always compare them with equals. A list made with List.of can't be changed; adding to it throws.
# Characters versus bytes
"交通費".length() → 3
"交通費".getBytes(UTF_8).length → 9
"🚃".length() → 2
"🚃".codePointCount(…) → 1
"🚃".getBytes(UTF_8).length → 4
# Strings that look like numbers
Integer.parseInt("1,280") → NumberFormatException: For input string: "1,280"
new BigDecimal("1280") → 1280
# The same program with the LANG environment variable removed
pc$ env -u LANG java Lesson2.java text
"???".length() = 3
"???" ? UTF-8 ???? = 9
交通費 (travel) is three characters in Java and nine bytes in UTF-8. Reopening part 1's recording, it indeed went to the database as nine bytes. PostgreSQL's varchar(100) counts characters, but some systems count bytes, and either can disagree with the character count of an input box on the page. The emoji 🚃 has a length() of 2 in Java but is one character.
BigDecimal read the full-width digits “1280” as 1280. Helpful, but if validation doesn't expect it, unexpected values slip through (part 6). The last two lines are the same program run without the LANG environment variable: the Japanese turned into “?”. For garbled text on a server, suspect LANG and the encoding settings first.
Checking on your own PC
| Try | Command |
|---|---|
| Start jshell (quit with /exit) | jshell |
| double's error | Math.floor(1100 / 1.1) |
| Comparing BigDecimals | new BigDecimal("1280").compareTo(new BigDecimal("1280.00")) |
| Rounding modes | new BigDecimal("2.5").setScale(0, RoundingMode.HALF_EVEN) |
| The same instant elsewhere | Instant.now().atZone(ZoneId.of("Asia/Shanghai")) |
| The JVM's time zone | java.time.ZoneId.systemDefault() |
| Start with a fixed time zone | jshell -R-Duser.timezone=UTC |
| Characters versus bytes | "交通費".getBytes("UTF-8").length |
JDK 25's jshell imports the whole java.base module from the start (check with /imports), so BigDecimal and Instant work without any import.
§ — On one page, and next time
- No double for money: the pre-tax amount of ¥1,100 becomes ¥999.
- Create BigDecimal from strings and compare with compareTo; 1280 and 1280.00 differ under equals.
- Choose the rounding mode yourself and keep it in one place as a rule; inexact division stops.
- A time is an instant plus whose clock; the homework's four times were all the same instant.
- Record times as Instant and timestamptz, calendar dates as LocalDate and date; don't let the server's “today” set the claim date.
- Don't share SimpleDateFormat; move to java.time.
- Guard against null with Optional and validation; records compare by contents; 交通費 is three characters and nine bytes.
AraiI'm ready. Spring Boot next?
FuruiNot yet. Next time we take HTTP with nothing but the JDK, no Spring, and do by hand what Tomcat and Jackson did for us in part 1.
References
- Java SE 25 API — BigDecimal
- Java SE 25 API — RoundingMode
- Java SE 25 API — java.time
- Java SE 25 API — SimpleDateFormat(Synchronization)
- Java SE 25 — Introduction to JShell
- JEP 358 — Helpful NullPointerExceptions
- JEP 330 — Launch Single-File Source-Code Programs
- JEP 400 — UTF-8 by Default
- JEP 511 — Module Import Declarations
- PostgreSQL 16 — Date/Time Types
- RFC 9110 — HTTP Semantics(Date)
The recordings were made on 26 October 2026 on a cloud Linux machine (Ubuntu 24.04, kernel 6.18) with OpenJDK 25.0.4's jshell and java and part 1's PostgreSQL 16. The double tax error counts compare, for every tax-inclusive amount from ¥1 to ¥100,000, double division rounded down against integer arithmetic. The SimpleDateFormat counts change every run. 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 2 — Java basics / 2026.10