Module 1
Records & immutability
This series walks through a real production-style Java service — a beta calculatorthat loads a file of closing prices at startup and answers “what's the beta of AAPL vs SPY over this window?” over HTTP. Every module quotes the actual source. We start at the bottom of the stack, with the most boring-looking and most important type in the whole codebase: the row.
In financial code, a bad row is not a cosmetic bug — it's a wrong number that flows silently into a risk report. A negative close price turns a log return into NaN; a ticker of "aapl " vs "AAPL" silently splits one series into two. So the first rule of the service is: a price row that exists is a price row that is valid. There is no such thing as a half-constructed, not-yet-checked, or later-mutated row.
Java records (Java 16+) are built for exactly this. A record declares its components once and the compiler generates final fields, accessors, equals, hashCode and toString. The fields are final forever — there are no setters to write and none to misuse.
The compact constructor is the validation gate
A record lets you write a compact constructor— a body with no parameter list that runs before the fields are assigned. It's the one door into the type, so it's where every rule lives. Here is the whole thing, from core-api/…/model/PriceRecord.java:
public record PriceRecord(LocalDate date, String ticker, double closePrice) {
public PriceRecord {
Objects.requireNonNull(date, "date");
Objects.requireNonNull(ticker, "ticker");
ticker = ticker.trim().toUpperCase(Locale.ROOT);
if (ticker.isEmpty()) {
throw new IllegalArgumentException("ticker must not be blank");
}
if (!(closePrice > 0.0) || !Double.isFinite(closePrice)) { // rejects NaN, +/-Inf
throw new IllegalArgumentException(
"closePrice must be a positive finite number: " + ticker + " " + date + " = " + closePrice);
}
if (date.getYear() < 1000 || date.getYear() > 9999) {
throw new IllegalArgumentException(
"date year out of supported range 1000-9999: " + date);
}
}
}Four things worth noticing:
- It normalizes, not just checks. Reassigning the parameter (ticker = ticker.trim().toUpperCase(…)) before the implicit field assignment means every record that exists holds the canonical ticker. Case-insensitive lookups everywhere downstream cost nothing.
- The price check is written as !(closePrice > 0.0), not closePrice <= 0.0 — because every comparison with NaN is false, this form rejects NaN too, and Double.isFinite catches an overflowed 1e309 from a sloppy input file.
- Error messages carry context — ticker, date and the offending value — so a bad line in a million-row file is findable in seconds.
- The year-range check protects a downstream representation: later modules store dates as int epoch days, and the gate guarantees that cast can never truncate.
This is the principle called “make illegal states unrepresentable.” The type system, not programmer discipline, guarantees that no PriceRecord with a blank ticker or a NaN price exists anywhere in the process. Downstream code — the store, the beta calculator, the web layer — never re-validates and never needs a defensive if (price > 0). Validation happens exactly once, at the boundary.
Immutability also buys free thread-safety. The service caches everything at startup and then serves concurrent HTTP requests. Because a record's fields are final and never change after construction, any number of request threads can read the same rows with no locks, no synchronization, no defensive copies. Safe publication of final fields is guaranteed by the Java memory model itself.
The road not taken: a mutable JavaBean
The classic alternative — and what a lot of legacy financial code looks like — is a getter/setter bean (illustrative, not from this codebase):
public class PriceBean { // what we deliberately avoided
private LocalDate date;
private String ticker;
private double closePrice;
public void setTicker(String t) { this.ticker = t; }
public void setClosePrice(double p) { this.closePrice = p; }
// ... getters, hand-written equals/hashCode ...
}Every failure mode of that shape is real and familiar:
- Partial construction. new PriceBean() then three setters — forget one and you have a row with a ticker but a 0.0 price, which is indistinguishable from data.
- Validation drift. With many doors in (setters, frameworks, deserializers), checks end up scattered or skipped. The record has one door.
- Accidental mutation in a cache. Hand a mutable bean to a shared map and any caller can call a setter on the cached instance, corrupting it for everyone — the classic aliasing bug. And if hashCode depends on a mutated field, the object is silently lost inside its own HashMap bucket.
- Unsafe concurrency. A mutable object shared across request threads needs synchronization or copying; the record needs neither.
🎛 Record validator
Constructed — the record now exists
PriceRecord[date=2024-06-03, ticker=AAPL, closePrice=189.99]
date
2024-06-03
ticker (normalized)
AAPL
closePrice
189.99
In the Java service this exact gate runs inside the record's compact constructor — construction either produces a fully valid, normalized row or throws. No half-valid PriceRecord can exist anywhere downstream, so the store, the calculators and the API layer never re-check these rules.
Things to try
- • Type aapl (lower case, even with spaces) as the ticker — the constructed record shows the normalized AAPL, exactly what the compact constructor does.
- • Set the close price to 0, then -5, then 1e309 — all three are rejected by the same !(closePrice > 0.0) || !Double.isFinite(…) check, with the value in the message.
- • Try the date 999-12-31 — a perfectly parseable date, still rejected: it would break the int epoch-day representation used by Module 2's store.
- • Blank out the ticker — note the message matches the Java exception word for word.