The command line, and failing usefully
The domain refuses invalid data. The store reads and writes safely. What is left is the part the owner of the tiffin service actually touches — and the part where most command-line tools are unpleasant to use.
A good one does three things: it says what it can do, it fails in a way you can act on, and it tells the shell whether it worked.
main, and why it is two methods
public static void main(String[] args) {
try {
System.exit(run(args));
} catch (IllegalArgumentException e) {
System.err.println("Error: " + e.getMessage());
System.exit(2);
} catch (UncheckedIOException e) {
System.err.println("Error: " + e.getMessage());
System.exit(3);
}
}
main calls System.exit, so it cannot be tested — a test calling it would
terminate the JVM and take the test runner with it. run returns an int and
can be called from a test all day.
The two catch blocks are the boundary module 7 kept pointing at. Everything
below throws whatever is natural; exactly one place turns an exception into a
message and an exit code.
Errors go to System.err. A user running tiffin-tracker report > bill.txt
gets the bill in the file and the problems on screen, which is what they want and
what a single println cannot give them.
Note what is not here: no stack trace. Error: tiffins must be between 0 and 4, got 9 is what a user should see. The stack trace is for a developer, and a
--verbose flag is the usual way to offer it.
Dispatch
static int run(String[] args) {
if (args.length == 0 || args[0].equals("help")) {
usage();
return args.length == 0 ? 1 : 0;
}
Path dataFile = Path.of(System.getProperty("tiffin.data", "deliveries.csv"));
DeliveryStore store = new DeliveryStore(dataFile);
return switch (args[0]) {
case "add" -> add(store, args);
case "list" -> list(store);
case "report" -> report(store, args);
default -> {
System.err.println("Unknown command [" + args[0] + "]");
usage();
yield 1;
}
};
}
A switch expression returning the exit code, so every branch must produce one —
the compiler will not let you forget.
No arguments is an error; help is not. Running the tool with nothing is a
mistake and exits 1; asking for help is a success and exits 0. That distinction
matters to a script.
The data file is configuration, not a constant. System.getProperty with a
default means the tests and the demo can point it somewhere harmless, and the
owner can keep her file anywhere:
java -Dtiffin.data=/Users/priya/tiffin.csv -jar tiffin-tracker.jar report
The commands
private static int add(DeliveryStore store, String[] args) {
if (args.length != 4) {
System.err.println("Usage: add <yyyy-mm-dd> <customer> <tiffins>");
return 1;
}
Parsed<Delivery> parsed = store.load();
List<Delivery> all = new ArrayList<>(parsed.rows());
all.add(new Delivery(LocalDate.parse(args[1]), args[2], Integer.parseInt(args[3])));
store.save(all);
System.out.println("Added. " + all.size() + " deliveries on file.");
return 0;
}
Argument count checked first, with the usage line for this command rather than
the whole help text. Then the Delivery constructor does the validating — add
contains no rules of its own, because they belong on the record.
private static int list(DeliveryStore store) {
Parsed<Delivery> parsed = store.load();
parsed.rows().forEach(d ->
System.out.printf("%s %-20s %d%n", d.date(), d.customer(), d.tiffins()));
reportProblems(parsed);
return parsed.hasProblems() ? 4 : 0;
}
private static void reportProblems(Parsed<Delivery> parsed) {
if (parsed.hasProblems()) {
System.err.println(parsed.problems().size() + " row(s) could not be read:");
parsed.problems().forEach(p -> System.err.println(" " + p));
}
}
Exit code 4: it worked, and something was wrong. The rows that could be read were printed; the ones that could not were reported. A single success-or-failure code cannot express that, and this is the case that justifies having five.
private static int report(DeliveryStore store, String[] args) {
YearMonth month = args.length > 1 ? YearMonth.parse(args[1]) : YearMonth.now();
Parsed<Delivery> parsed = store.load();
reportProblems(parsed);
BillingService billing = new BillingService(List.of(
new Subscriber("Priya", "411207", Plan.VEG, LocalDate.of(2026, 9, 1)),
new Subscriber("Arjun", "411014", Plan.JAIN, LocalDate.of(2026, 9, 1)),
new Subscriber("Kavita", "411207", Plan.STUDENT, LocalDate.of(2026, 9, 1))));
System.out.print(billing.renderReport(parsed.rows(), month));
billing.busiestDay(parsed.rows())
.ifPresent(d -> System.out.println("Busiest day: " + d));
return parsed.hasProblems() ? 4 : 0;
}
The subscriber list is hard-coded, and that is a deliberate scope decision: the plan said add, list and report, with no subscriber management. The right place for a shortcut is one you could name, and this one is "subscribers are seeded in code until there is a command to manage them".
ifPresent means an empty month prints no busiest-day line rather than
Busiest day: null.
The help text
private static void usage() {
System.out.println("""
tiffin-tracker
add <yyyy-mm-dd> <customer> <tiffins> record a delivery
list show every delivery
report [yyyy-mm] bill for a month
help this message
Data file: -Dtiffin.data=<path> (default deliveries.csv)""");
}
A text block, aligned, with square brackets marking the optional argument. Help text is the user interface of a command-line tool — it is worth as much care as a screen would get.
Running it
java -jar target/tiffin-tracker.jar add 2026-09-01 Priya 2
java -jar target/tiffin-tracker.jar add 2026-09-01 Arjun 1
java -jar target/tiffin-tracker.jar add 2026-09-02 Kavita 3
Added. 1 deliveries on file.
Added. 2 deliveries on file.
Added. 3 deliveries on file.
The file it wrote:
date,customer,tiffins
2026-09-01,Priya,2
2026-09-01,Arjun,1
2026-09-02,Kavita,3
Readable, editable, and something the owner could open in a spreadsheet — which was the reason for choosing CSV in the plan.
java -jar target/tiffin-tracker.jar report 2026-09
Tiffin bill - 2026-09
=====================
Customer Tiffins Amount
Arjun 1 Rs 91.00
Kavita 3 Rs 222.36
Priya 2 Rs 164.70
Total Rs 478.06
Busiest day: 2026-09-01
Alphabetical, aligned, plan prices applied — Kavita is on the student plan at Rs 74.12, Arjun on Jain at Rs 91.
Failing usefully
Two bad rows appended to the file by hand:
2026-09-03,Amit,99
2026-09-04,Rahul
2 row(s) could not be read:
line 5: tiffins must be between 0 and 4, got 99
line 6: expected 3 fields, found 2
Tiffin bill - 2026-09
=====================
...
(exit code 4)
The report still ran. Both problems named, with line numbers the owner can find in her spreadsheet, and an exit code saying "done, but look at this".
An invalid argument:
java -jar target/tiffin-tracker.jar add 2026-09-05 Priya 9
Error: tiffins must be between 0 and 4, got 9
(exit code 2)
That message came from the Delivery compact constructor, four layers down,
unchanged. It names the rule, the value, and nothing else. Nothing in Main
knows what the tiffin limit is, which is why changing it means editing one
file.
An unknown command:
Unknown command [frobnicate]
tiffin-tracker
add <yyyy-mm-dd> <customer> <tiffins> record a delivery
...
(exit code 1)
The mistake is quoted in brackets — so a stray space is visible — and the help follows, because somebody who got the command wrong wants the list.
Check your work
Why does main delegate to run? main calls System.exit, which would
terminate a test runner. run returns an exit code and is testable.
Why do errors go to System.err? So a user redirecting output to a file
still sees the problems.
Why no stack trace for a bad argument? It is for a developer, not a user.
Error: tiffins must be between 0 and 4, got 9 is actionable; forty frames are
not.
Why is no argument exit 1 but help exit 0? Running the tool with nothing is
a mistake; asking for help is what the user intended. A script can tell the
difference.
Why is the data file a system property rather than a constant? Configuration is an argument. Hard-coded, the same build cannot run in two places, and the tests could not point it somewhere harmless.
What does exit code 4 mean here? The command succeeded and some rows were unreadable. A single failure code cannot express it.
Where does tiffins must be between 0 and 4 come from? The Delivery
compact constructor, unchanged through four layers. Main does not know the
limit.
Why ifPresent for the busiest day? An empty month has none, and printing
Busiest day: null would be worse than printing nothing.
Practice 3, the exit codes. echo $? gives 0 after a clean report, 1 after
an unknown command or no arguments, 2 after an invalid argument, 3 if the data
file cannot be read, and 4 after a report over a file with bad rows. If yours
returns 0 for the bad-rows case, the report looks successful to a CI job that
should have flagged it.
Practice 5, the redirect. report > bill.txt should put the bill in the file
and the problem list on the terminal. If the problems end up in the file, they
are going to System.out — and the owner's bill now has error messages in it.
Practice
-
Write
mainandrun. The switch, the two catch blocks, the exit codes. Confirmruncan be called from a test without ending the JVM. -
Write the three commands. Then run the full sequence: three
adds, alist, and areport. Compare your output with this lesson's. -
Check every exit code. Run each failure case and print
echo $?after it. All five should be distinct. -
Break the data file by hand. Append a row with 99 tiffins and one with a missing field. Confirm the report still runs, names both problems with the right line numbers, and exits 4.
-
Redirect the output.
report > bill.txtwith a broken row in the file. Confirm the bill is in the file and the problems are on the screen. -
Harder — add a command.
tiffin-tracker customerslisting the subscribers with their plans and prices. Then move the hard-coded subscriber list into its own CSV, loaded by aSubscriberStorethat reuses the sameParsed<T>and the same bad-row reporting. Notice how little of the existing code changes — and if a lot of it does, the layers were not as separate as they looked.
Next: the full test suite, and packaging a jar you can hand to somebody.
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