The domain: records, enums and validation
The domain is the part of a program that models the problem: what a delivery is, what a plan costs, what makes a subscriber valid. Get it right and the rest of the application has very little to do.
The standard to aim for: an invalid object should be impossible to create, not merely unlikely.
Setting up
tiffin-tracker/
├── pom.xml
└── src/
├── main/java/com/riztech/tiffin/
└── test/java/com/riztech/tiffin/
The POM from module 10 — Java 21, UTF-8, Jackson, JUnit in test scope, and the
shade plugin with com.riztech.tiffin.Main as the main class.
Money
Money is long paise everywhere. This class is where that decision lives.
package com.riztech.tiffin;
/** Money is always paise. Never a double. */
public final class Money {
private Money() {
}
public static String format(long paise) {
String sign = paise < 0 ? "-" : "";
long abs = Math.abs(paise);
return "%sRs %,d.%02d".formatted(sign, abs / 100, abs % 100);
}
public static long parseRupees(String text) {
String trimmed = text.strip().replace(",", "");
if (!trimmed.matches("-?\\d+(\\.\\d{1,2})?")) {
throw new IllegalArgumentException("not an amount in rupees: [" + text + "]");
}
boolean negative = trimmed.startsWith("-");
String digits = negative ? trimmed.substring(1) : trimmed;
int dot = digits.indexOf('.');
long rupees = Long.parseLong(dot < 0 ? digits : digits.substring(0, dot));
long paise = 0;
if (dot >= 0) {
String fraction = (digits.substring(dot + 1) + "00").substring(0, 2);
paise = Long.parseLong(fraction);
}
long total = rupees * 100 + paise;
return negative ? -total : total;
}
}
Four decisions in thirty lines.
final with a private constructor. A class of static methods is a namespace.
Making it uninstantiable says so, and module 3 argued this is one of the few
places where all-static is right.
Math.abs before splitting the halves. Without it, -1250 prints as
Rs -12.-50, because both / 100 and % 100 come out negative. Credits and
refunds are exactly where that bites.
%02d on the paise. Without it, 5 paise prints as Rs 0.5.
The regex validates before parsing, and the message quotes the input in brackets so a trailing space is visible.
The (digits.substring(dot + 1) + "00").substring(0, 2) handles 82.3 meaning
830 paise rather than 83. Padding on the right, not the left — the kind of detail
that is obvious once seen and wrong in a lot of real code.
Plan
package com.riztech.tiffin;
import java.util.Arrays;
public enum Plan {
VEG("Vegetarian", 8_235),
JAIN("Jain (no onion)", 9_100),
STUDENT("Student", 7_412),
TRIAL("Trial", 0);
private final String label;
private final int pricePaise;
Plan(String label, int pricePaise) {
this.label = label;
this.pricePaise = pricePaise;
}
public String label() {
return label;
}
public int pricePaise() {
return pricePaise;
}
public static Plan parse(String raw) {
if (raw == null || raw.isBlank()) {
throw new IllegalArgumentException("plan must not be blank");
}
try {
return Plan.valueOf(raw.strip().toUpperCase());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(
"unknown plan [" + raw + "], expected one of " + Arrays.toString(values()));
}
}
}
The price lives with the plan, so there is no second place to forget. And
parse is the enums lesson's worked answer: tolerant of case and whitespace, and
its failure message lists the valid values, which is the difference between a bug
report and a fix.
Subscriber
package com.riztech.tiffin;
import java.time.LocalDate;
import java.util.Objects;
public record Subscriber(String name, String pincode, Plan plan, LocalDate startedOn) {
public Subscriber {
Objects.requireNonNull(name, "name must not be null");
Objects.requireNonNull(pincode, "pincode must not be null");
Objects.requireNonNull(plan, "plan must not be null");
Objects.requireNonNull(startedOn, "startedOn must not be null");
name = name.strip();
if (name.isBlank()) {
throw new IllegalArgumentException("name must not be blank");
}
if (!pincode.matches("[1-9]\\d{5}")) {
throw new IllegalArgumentException("pincode must be six digits not starting with zero, got [" + pincode + "]");
}
}
public long pricePerTiffinPaise() {
return plan.pricePaise();
}
}
The compact constructor does three jobs, in order: reject nulls by name,
normalise, then validate. Assigning to the parameter — name = name.strip() —
changes what gets stored, which is the records lesson's one piece of unusual
syntax.
Objects.requireNonNull with a message means a null name fails at the line it
arrived with name must not be null, rather than three methods later as a bare
NullPointerException.
Delivery
package com.riztech.tiffin;
import java.time.LocalDate;
import java.util.Objects;
public record Delivery(LocalDate date, String customer, int tiffins) {
public static final int MAX_PER_DAY = 4;
public Delivery {
Objects.requireNonNull(date, "date must not be null");
Objects.requireNonNull(customer, "customer must not be null");
customer = customer.strip();
if (customer.isBlank()) {
throw new IllegalArgumentException("customer must not be blank");
}
if (tiffins < 0 || tiffins > MAX_PER_DAY) {
throw new IllegalArgumentException(
"tiffins must be between 0 and " + MAX_PER_DAY + ", got " + tiffins);
}
}
}
MAX_PER_DAY sits on the class whose rule it is, not in a shared Constants
bucket — and the message builds from the constant, so changing the limit changes
the message too.
There is now no way to construct a Delivery of 99 tiffins. Not a check the
caller might forget. No way.
Parsed<T>
package com.riztech.tiffin;
import java.util.List;
/** Every good row, and every bad one with the line number and the reason. */
public record Parsed<T>(List<T> rows, List<String> problems) {
public Parsed {
rows = List.copyOf(rows);
problems = List.copyOf(problems);
}
public boolean hasProblems() {
return !problems.isEmpty();
}
}
Twelve lines, and two modules' lessons in them. It is generic, so it works
for deliveries, subscribers or anything else. And the compact constructor calls
List.copyOf, which is the records lesson's shallow-immutability fix — without
it, whoever passed the list in could still modify the record's contents
afterwards.
The tests that make it real
@Test
void rejectsAShortPincodeAndQuotesIt() {
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
() -> new Subscriber("Priya", "41120", Plan.VEG, START));
assertEquals("pincode must be six digits not starting with zero, got [41120]", e.getMessage());
}
@Test
void stripsWhitespaceFromTheName() {
assertEquals("Priya", new Subscriber(" Priya ", "411207", Plan.VEG, START).name());
}
@Test
void unknownPlanListsTheValidOnes() {
IllegalArgumentException e =
assertThrows(IllegalArgumentException.class, () -> Plan.parse("keto"));
assertTrue(e.getMessage().contains("keto"), e.getMessage());
assertTrue(e.getMessage().contains("VEG"), e.getMessage());
}
The messages are asserted, not just the exception type. Module 7 argued a good message is most of an exception's value; this is how it stays good when somebody edits the class next year.
And Money, parameterised:
@ParameterizedTest
@CsvSource({
"82.35, 8235",
"82.3, 8230",
"82, 8200",
"0.05, 5",
"1234.56, 123456",
"-12.50, -1250"
})
void parsesRupees(String text, long expectedPaise) {
assertEquals(expectedPaise, Money.parseRupees(text));
}
@Test
void roundTrips() {
for (long paise : new long[]{0, 5, 100, 8235, 214110, 123456789}) {
String formatted = Money.format(paise).replace("Rs ", "");
assertEquals(paise, Money.parseRupees(formatted), "round trip failed for " + paise);
}
}
The round trip is the test that matters most: it asserts format and
parseRupees agree, which neither tested alone would establish.
mvn test
[INFO] Running com.riztech.tiffin.MoneyTest
[INFO] Tests run: 16, Failures: 0, Errors: 0, Skipped: 0
[INFO] Running com.riztech.tiffin.SubscriberTest
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
Check your work
Why is Money final with a private constructor? It is a namespace of static
methods, not a thing. Making it uninstantiable says so.
What does Math.abs prevent in format? -1250 printing as Rs -12.-50,
because both the division and the remainder come out negative.
Why %02d for the paise? Without it, 5 paise prints as Rs 0.5.
Why does 82.3 need padding on the right? Three-tenths of a rupee is 30
paise, not 3. (fraction + "00").substring(0, 2) gives 30.
What are the three jobs of a compact constructor, in order? Reject nulls, normalise, validate.
Why Objects.requireNonNull with a message? It fails at the line the null
arrived and names the field, rather than producing a bare
NullPointerException further on.
Why does Parsed<T> call List.copyOf? Records are shallowly immutable —
without the copy, whoever passed the list in could modify the record's contents
afterwards.
Why assert exception messages and not just types? The message is most of an exception's value, and asserting it is what keeps it good when somebody edits the class later.
Practice 3, the impossible object. Every attempt — 99 tiffins, a blank customer, a five-digit pincode, a null date — throws from the constructor with a message naming the field and the value. If any of them succeeded, the validation is in the wrong place: it belongs in the compact constructor, not in the caller.
Practice 5, the round trip. format then parseRupees must return the
original paise for every value tested, including 5 and 0. If 5 fails, the
%02d is missing; if a negative fails, the Math.abs is. The round trip finds
both without you having to predict either.
Practice
-
Create the project. POM, directories,
.gitignore, README. Confirmmvn clean testruns with no tests. -
Write
Money. Both methods. Then write the parameterised tests, including0.05and a negative. -
Write
Plan,SubscriberandDelivery. Then try to construct an invalid one of each — 99 tiffins, a blank customer, a five-digit pincode, a null date. Confirm every attempt throws with a message naming the field. -
Write
Parsed<T>. Then pass it a mutable list, modify that list afterwards, and confirm the record did not change. Remove theList.copyOfand confirm it does. -
Round trip your money. Format then parse every value from 0 to 123456789 in the list above. Fix whatever fails.
-
Harder — add a rule. A subscriber on the
TRIALplan may not have started more than fourteen days ago. Decide where that rule lives — the compact constructor cannot see today's date without aClock— implement it, and write the test. The answer involves module 8'sClock, and working out why the constructor is the wrong place is most of the exercise.
Next: storage — reading and writing the CSV, and reporting every bad row.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship