Primitive obsession clinic
Common value objects
Common value objects — Money, Duration, Plate, Email — wrap primitives so invalid states are impossible and equality is by value. Example: storing a fee as double looks fine until three hours at 12.50 should be 37.50 and the receipt prints 37.499999. The gate rounds the wrong way. The interviewer already knows what went wrong.
Which primitives are domain ideas in disguise?
is never a double. Binary floating point cannot hold decimal rupees and paise exactly. Use an integer of the minor unit — paise, cents — plus a currency. Addition and subtraction check the currency. Mixing INR and USD is a thrown error, not a silent sum.
is not a raw long. A long might be millis, seconds, or nanos. Wrap it. Prefer seconds and a name that does not collide with java.time.Duration. Ask it for billable hours for pricing. is a point in time. StayDuration is a length. Do not pass both as long and hope the names of the parameters save you.
is not a free String. Validate once in the constructor. Keep it immutable. Then every class can trust it. , Currency, VehicleType, SpotType, — same move. One small type. One place for the rule.
Money
Paise + currency. plus, times, requireSameCurrency.
StayDuration
Seconds in StayDuration. billableHours for fees.
Instant
Entry and exit instants. Not a duration in disguise.
One constructor. One normalized string.
Quantity
Non-negative count. Shares, seats, items.
Coordinates
Lat/lng together. Distance lives here.
Money — integer minor units, never double
These types look like extra classes. They are cheaper than hunting a fee bug across ParkingLot, Receipt, and a util. Splitwise, parking fees, and movie tickets all live or die on Money.
final class Money {
private final long paise;
private final String currency;
Money(long paise, String currency) {
if (paise < 0) throw new IllegalArgumentException("negative money");
this.paise = paise;
this.currency = Objects.requireNonNull(currency);
}
static Money inr(long rupees, int paise) {
return new Money(rupees * 100 + paise, "INR");
}
Money plus(Money other) {
requireSameCurrency(other);
return new Money(paise + other.paise, currency);
}
Money times(int hours) {
return new Money(Math.multiplyExact(paise, hours), currency);
}
private void requireSameCurrency(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException(currency + " vs " + other.currency);
}
}
}StayDuration and Email as values
StayDuration.between(start, end) is the only place that decides the unit — store seconds, not truncated minutes. Email lowercases once. Callers stop inventing if (!email.contains("@")) in three services.
final class StayDuration {
private final long seconds;
StayDuration(long seconds) {
if (seconds < 0) throw new IllegalArgumentException("duration");
this.seconds = seconds;
}
static StayDuration between(Instant start, Instant end) {
long seconds = ChronoUnit.SECONDS.between(start, end);
return new StayDuration(seconds);
}
/** Started-hour ceiling. Do not use java.time.Duration.toHours() — it truncates. */
long billableHours() {
return Math.max(1, seconds / 3600 + (seconds % 3600 == 0 ? 0 : 1));
}
}
final class Email {
private final String value;
Email(String value) {
if (value == null || !value.contains("@") || value.startsWith("@")) {
throw new IllegalArgumentException("email");
}
this.value = value.toLowerCase(Locale.ROOT);
}
String value() { return value; }
}Why 0.1 + 0.2 is a design bug
Binary floating point is a scientific approximation. Money is a legal quantity. 0.1 + 0.2 is not 0.3 in IEEE-754. Fees, balances, and Splitwise shares will drift. Rounding “at the end” does not save you if two paths rounded differently. Integer paise makes equality exact and tests boring — which is the goal.
BigDecimal is acceptable if you lock the scale and rounding mode in the type. A bare BigDecimal passed around is still primitive obsession with a longer name: every caller chooses scale again. Prefer a Money that hides the representation.
Factory names carry units
Money.inr(20, 50) is readable. new Money(2050, "INR") is fine once. StayDuration.ofSeconds(5400) beats new StayDuration(5400) if someone might pass minutes. Put the unit in the factory name so the constructor can stay private later.
Closed sets can be enums
Currency can be an enum if you only support a few. SpotType, VehicleType, Powertrain — same. An open set of currencies in a payments product may stay a validated string inside Money. Do not invent a class per ISO code.
Two habits that keep coming back
Double for money: 0.1 + 0.2 is not 0.3. Use integer minor units. String and long for everything else: email, durationMs, and currencyCode as primitives push validation into every caller. A value object validates once. That is the same lesson as entity versus value object and the reason equals must use every defining field.
Where these types earn their keep
Parking fees, movie tickets, and Splitwise all compute money. Ride matching wants Coordinates. A calendar wants Instant plus StayDuration. A user directory wants Email. If you skip the type, the first follow-up — “support USD,” “price by the minute,” “reject bad emails” — rewrites three classes instead of one constructor.
Keep values immutable so they stay safe as map keys and as fields on entities. That story continues in immutability. Do not let a service mutate paise through a setter. Replace the Money. The ticket is still the same ticket.
Quantity and Coordinates, same shape
final class Quantity {
private final int count;
Quantity(int count) {
if (count < 0) throw new IllegalArgumentException("quantity");
this.count = count;
}
Quantity plus(Quantity other) {
return new Quantity(Math.addExact(count, other.count));
}
int count() { return count; }
}
final class Coordinates {
private final double lat;
private final double lng;
Coordinates(double lat, double lng) {
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) {
throw new IllegalArgumentException("coordinates");
}
this.lat = lat;
this.lng = lng;
}
}Instant is not StayDuration in a trench coat
Entry at 10:03 and exit at 13:03 are two Instants. The stay is a StayDuration. Pricing wants the StayDuration. Auditing wants the Instants. If you store only a long “minutes parked,” you cannot answer “when did they enter?” and you already threw away sub-minute seconds before ceiling. If you store only two longs called start and end, every caller guesses the unit. Wrap both ideas. Ask StayDuration for billableHours. Ask Instant nothing except “who is later.”
Inject a Clock so tests can freeze noon and advance three hours. System.currentTimeMillis() scattered through park and exit is how flaky tests and “it passed on my laptop” fees are born.
A checklist before you type double
- Is this money? Integer minor units. Never double.
- Is this a length of time? StayDuration with seconds (or nanos).
- Is this a point in time? Instant, from a Clock.
- Is this an address people will type wrong? Email, validated once.
- Is this a count that must not go negative? Quantity.
- Is this a pair that always travels together? Coordinates, Money’s paise+currency, Size+Powertrain.