Old-School Backend / 2026 ・ No.02

Old-School Backend

Part 2: Java basics — holding money, dates and times correctly

Published

A terminal running jshell: 0.1 + 0.2 gives 0.30000000000000004, 1100 / 1.1 gives 999.9999999999999, and Math.floor(1100 / 1.1) gives 999.0. With BigDecimal, 1100 divided by 1.1 gives 1000, new BigDecimal(0.1) gives 0.1000000000000000055511151231257827021181583404541015625, and equals between BigDecimal 1280 and 1280.00 is false
Comparing double and BigDecimal in jshell (a tool for trying Java one line at a time). Working out the pre-tax amount of ¥1,100 with double and rounding down gives ¥999; BigDecimal gives ¥1,000 (Captured by the author on a cloud Linux machine on 26 October 2026. The bar across the top of the window was added for the illustration.)

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.

1

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)

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

2

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

decimal 1 / 3 = 0.3333333333… "3" repeats forever binary 0.1 = 0.0001100110011001100… "0011" repeats forever double: cut at 52 bits, nearest value kept 0.10000000000000000555… The error is tiny, but it surfaces as you add and divide For money, where one yen matters, use BigDecimal, which counts in decimal

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.

3

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.

4

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)

mode 2.5 3.5 -2.5 HALF_UP the schoolbook rule 3 4 -3 HALF_EVEN exact halves go to even 2 4 -2 DOWN cut toward zero 2 3 -2 FLOOR cut toward smaller 2 3 -3 Red: different from HALF_UP. DOWN and FLOOR part ways below zero Which to use is policy, not technique; tax rounding follows your rules Without a rounding mode, BigDecimal division can stop (10000 / 3 throws)

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.

5

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

one instant, worldwide (Instant) 2026-10-25T15:47:43Z UTC 15:47 10-25 Asia/Shanghai 23:47 10-25 Asia/Tokyo 00:47 10-26 America/New_York 11:47 10-25 A time is an instant plus a local clock; one instant, different dates Homework: Date and app log were UTC, the DB log Tokyo. All correct A calendar day (claim date) is a LocalDate; record times as instants

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”.

6

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
A terminal showing the clock table twice with psql. In the row whose jvm is Asia/Tokyo, wall is 2026-10-26 01:06:41; in the UTC row it is 2026-10-25 16:06:41. The moment column reads 2026-10-26 01:06:41+09 in both rows the first time, and 2026-10-25 16:06:41+00 in both rows the second time, with PGTZ=UTC
A table where two JVMs with different time zone settings each wrote “now” at the same moment. The timestamp column, wall, is nine hours apart; the timestamptz column, moment, is the same instant, and changing where you display it changes only the clock numbers (Captured by the author on a cloud Linux machine on 26 October 2026. The bar across the top of the window was added for the illustration.)

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

Java type what it holds PostgreSQL type Instant one instant, worldwide timestamptz OffsetDateTime instant + local offset timestamptz LocalDate a calendar date (claim date) date LocalDateTime wall-clock numbers only timestamp Record times as instants; a LocalDateTime doesn't say whose clock it is Measured: timestamp differed by 9 h; timestamptz matched exactly Pin the server zone (-Duser.timezone=UTC); display in the user's zone
POINT

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.

7

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.

8

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.

9

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
The Wireshark window: packet 13 from part 1's recording, with Parameter values of the PostgreSQL Bind message expanded. Column lengths 13, 10, 9 and 10 are listed, and the third Data field, e4baa4e9809ae8b2bb, is selected. The status bar reads Parameter data (pgsql.val.data), 9 byte(s)
Part 1's recording reopened, looking at the bytes sent to the database for the item 交通費 (travel). Three characters in Java, nine bytes of UTF-8 on the wire (Captured by the author on a cloud Linux machine on 26 October 2026. The bar across the top of the window was added for the illustration.)

交通費 (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.

10

Checking on your own PC

TryCommand
Start jshell (quit with /exit)jshell
double's errorMath.floor(1100 / 1.1)
Comparing BigDecimalsnew BigDecimal("1280").compareTo(new BigDecimal("1280.00"))
Rounding modesnew BigDecimal("2.5").setScale(0, RoundingMode.HALF_EVEN)
The same instant elsewhereInstant.now().atZone(ZoneId.of("Asia/Shanghai"))
The JVM's time zonejava.time.ZoneId.systemDefault()
Start with a fixed time zonejshell -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

  1. No double for money: the pre-tax amount of ¥1,100 becomes ¥999.
  2. Create BigDecimal from strings and compare with compareTo; 1280 and 1280.00 differ under equals.
  3. Choose the rounding mode yourself and keep it in one place as a rule; inexact division stops.
  4. A time is an instant plus whose clock; the homework's four times were all the same instant.
  5. Record times as Instant and timestamptz, calendar dates as LocalDate and date; don't let the server's “today” set the claim date.
  6. Don't share SimpleDateFormat; move to java.time.
  7. 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

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

← Back to all posts