JAVA / CAPSTONE PROJECTS
Project: a command-line expense tracker with file storage
Build a command-line expense tracker that keeps a plain-text file as its source of truth, loading it at startup and rewriting it safely after every change.
What you will learn
- Round-trip an Expense record through one delimited text line with toLine and fromLine
- Keep money in BigDecimal so 3.40 is written and read back as 3.40, not 3.4
- Save by writing a sibling .tmp file and ATOMIC_MOVE-ing it over the data file
- Turn missing files, blank lines and bad input into messages, not stack traces
Understanding Project: a command-line expense tracker with file storage
This tracker has no server and no database, so the file on disk is the entire system of record; the List<Expense> in memory only exists between the moment you read the file and the moment you write it back. That shapes every command into the same cycle: load the file, change the list, write the file. Because a text file has no notion of a partial update, 'change one expense' really means 'produce a new complete file', which is why save takes the whole list rather than a single entry.
The line format is a contract you must honour in both directions: toLine and fromLine have to be exact inverses, or yesterday's file becomes unreadable today. LocalDate.toString already emits ISO-8601 text like 2026-03-01 that LocalDate.parse reads back with no formatter, and BigDecimal preserves the scale you typed, so 3.40 stays 3.40 instead of collapsing to 3.4 or drifting the way a double does when 0.1 + 0.2 lands on 0.30000000000000004. Choose a delimiter that cannot appear in the data (a tab is convenient because shell arguments almost never contain one) and split with a limit of -1, so an empty trailing field stays a field instead of disappearing.
Files.write truncates the target before it writes the first byte, so between the truncate and the final flush there is a window in which the file holds neither the old data nor the new. The fix is to write the new snapshot to a temp file beside the original and then rename it over the top with REPLACE_EXISTING and ATOMIC_MOVE, because a rename inside one directory is a single filesystem operation: the next run sees either the old complete file or the new complete file. Reading deserves the same care in the other direction, where a missing file on first run is normal and should yield an empty list, while a line that refuses to parse is worth reporting with its line number instead of being silently skipped.
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
public class Tracker {
record Expense(LocalDate date, String category, BigDecimal amount) {
String toLine() {
return date + "\t" + category + "\t" + amount.toPlainString();
}
static Expense fromLine(String line) {
String[] f = line.split("\t", -1);
if (f.length != 3) {
throw new IllegalArgumentException("expected 3 fields, got " + f.length);
}
return new Expense(LocalDate.parse(f[0]), f[1], new BigDecimal(f[2]));
}
}
static List<Expense> load(Path file) throws IOException {
List<Expense> expenses = new ArrayList<>();
if (Files.notExists(file)) {
return expenses;
}
List<String> lines = Files.readAllLines(file);
for (int i = 0; i < lines.size(); i++) {
String line = lines.get(i);
if (line.isBlank()) {
continue;
}
try {
expenses.add(Expense.fromLine(line));
} catch (RuntimeException e) {
throw new IOException("line " + (i + 1) + ": " + e.getMessage(), e);
}
}
return expenses;
}
static void save(Path file, List<Expense> expenses) throws IOException {
List<String> lines = new ArrayList<>();
for (Expense e : expenses) {
lines.add(e.toLine());
}
Files.write(file, lines);
}
public static void main(String[] args) throws IOException {
Path file = Files.createTempDirectory("tracker").resolve("expenses.tsv");
List<Expense> expenses = load(file);
System.out.println("first run, loaded " + expenses.size() + " expenses");
expenses.add(new Expense(LocalDate.of(2026, 3, 1), "coffee", new BigDecimal("3.40")));
expenses.add(new Expense(LocalDate.of(2026, 3, 2), "books", new BigDecimal("18.99")));
expenses.add(new Expense(LocalDate.of(2026, 3, 2), "coffee", new BigDecimal("3.40")));
save(file, expenses);
List<Expense> reloaded = load(file);
System.out.println("second run, loaded " + reloaded.size() + " expenses");
Map<String, BigDecimal> byCategory = new TreeMap<>();
BigDecimal total = BigDecimal.ZERO;
for (Expense e : reloaded) {
byCategory.merge(e.category(), e.amount(), BigDecimal::add);
total = total.add(e.amount());
}
byCategory.forEach((category, sum) -> System.out.printf("%-8s %6s%n", category, sum));
System.out.printf("%-8s %6s%n", "TOTAL", total);
}
}
The text file, not the in-memory list, is the tracker's source of truth, so every command must leave behind a complete file that the next run can parse.
Worked examples
Saving without a window of corruption
Replaces the plain Files.write in save with a temp file plus an atomic rename, so a crash mid-save cannot leave a truncated expense file.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
public class AtomicSave {
static void save(Path file, List<String> lines) throws IOException {
Path tmp = file.resolveSibling(file.getFileName() + ".tmp");
Files.write(tmp, lines);
Files.move(tmp, file,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
}
public static void main(String[] args) throws IOException {
Path file = Files.createTempDirectory("tracker").resolve("expenses.tsv");
save(file, List.of("2026-03-01\tcoffee\t3.40"));
System.out.println("after first save: " + Files.readAllLines(file).size() + " line(s)");
save(file, List.of("2026-03-01\tcoffee\t3.40", "2026-03-02\tbooks\t18.99"));
System.out.println("after second save: " + Files.readAllLines(file).size() + " line(s)");
System.out.println("temp file still there: "
+ Files.exists(file.resolveSibling("expenses.tsv.tmp")));
}
}
Example explained
Line 1resolveSibling puts the temp file in the same directory as the data file, which ATOMIC_MOVE needs because it cannot rename across filesystems.
Line 2Files.write(tmp, lines) builds the new snapshot while expenses.tsv is still complete and readable.
Line 3REPLACE_EXISTING matters from the second save onwards, since by then the target already exists.
Line 4The final false shows the move renamed rather than copied, so no stray .tmp file is left for the next load to trip over.
Validating the add command
Turns one typed command line into an Expense, answering bad input with a message the CLI loop can print instead of an exception that kills the process.
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
public class AddCommand {
record Expense(LocalDate date, String category, BigDecimal amount) {}
static String handle(String line) {
String[] p = line.split(" +");
if (p.length != 4 || !p[0].equals("add")) {
return "usage: add <yyyy-mm-dd> <category> <amount>";
}
try {
LocalDate date = LocalDate.parse(p[1]);
BigDecimal amount = new BigDecimal(p[3]).setScale(2);
if (amount.signum() <= 0) {
return "amount must be positive";
}
return "added " + new Expense(date, p[2], amount);
} catch (DateTimeParseException e) {
return "bad date: " + p[1];
} catch (NumberFormatException e) {
return "bad amount: " + p[3];
}
}
public static void main(String[] args) {
System.out.println(handle("add 2026-03-01 coffee 3.4"));
System.out.println(handle("add 2026-13-01 coffee 3.40"));
System.out.println(handle("add 2026-03-01 coffee 3,40"));
System.out.println(handle("add 2026-03-01 coffee"));
}
}
Example explained
Line 1split(" +") treats a run of spaces as one separator, so a double space between fields does not create an empty category.
Line 2LocalDate.parse rejects month 13 with DateTimeParseException, which keeps an impossible date out of the file rather than discovering it on the next load.
Line 3new BigDecimal("3,40") throws NumberFormatException, and catching it separately lets the message name the field that was wrong.
Line 4setScale(2) normalizes 3.4 to 3.40 so stored lines are uniform; an input like 3.456 would throw ArithmeticException here, which is where you decide to reject or round it.
Append for add, full rewrite for delete
Shows why adding an expense can touch only the end of the file while deleting one has to rewrite all of it.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.List;
public class AppendVsRewrite {
public static void main(String[] args) throws IOException {
Path file = Files.createTempFile("expenses", ".csv");
Files.write(file, List.of("2026-03-01,coffee,3.40", "2026-03-02,books,18.99"));
Files.writeString(file, "2026-03-03,coffee,3.40" + System.lineSeparator(),
StandardOpenOption.APPEND);
System.out.println("lines after add: " + Files.readAllLines(file).size());
List<String> kept = new ArrayList<>();
for (String line : Files.readAllLines(file)) {
if (!line.endsWith(",books,18.99")) {
kept.add(line);
}
}
Files.write(file, kept);
System.out.println("lines after delete: " + Files.readAllLines(file).size());
System.out.println(Files.readAllLines(file).get(1));
}
}
Example explained
Line 1StandardOpenOption.APPEND writes one line at the end without reading or rewriting the rest, which is all the add command needs.
Line 2Files.write with no options defaults to CREATE, TRUNCATE_EXISTING and WRITE, which is exactly right for the delete path and exactly wrong for an append.
Line 3readAllLines strips the line terminators, so endsWith compares clean text with no trailing newline.
Line 4Deleting has to write every surviving line back because a text file has no fixed-size records that could be blanked out in place.
Important notes
ATOMIC_MOVE works only within one filesystem, so build the temp path with resolveSibling next to the data file rather than in the system temp directory, or the move can fail with AtomicMoveNotSupportedException.
Files.write and Files.readAllLines both use UTF-8; if one code path switches to FileWriter or PrintWriter without a charset on a JDK older than 18, a category like café is written in the platform charset and reloads garbled.
Common mistakes
Holding amounts in a double: the file then stores 3.4 instead of 3.40, and sums pick up artifacts like 0.1 + 0.2 becoming 0.30000000000000004, so the reported total stops matching the receipts.
Using Files.write to append one expense and forgetting StandardOpenOption.APPEND: the default TRUNCATE_EXISTING means the add command silently erases every earlier expense.
Parsing with line.split(",") instead of split(",", -1): a line whose last field is an empty note comes back with fewer fields, and the next load dies with ArrayIndexOutOfBoundsException on a file that is actually intact.
Try it yourself
Change, predict, then run
Extend the main example with a delete step: load the file, remove the expense at a given index, save the whole list back, and print the new total. Confirm that an out-of-range index prints an error and leaves the file's line count unchanged.
Open the Java workspaceCheck your understanding
Why does save write the expenses to a sibling .tmp file and rename it, instead of writing straight into expenses.tsv?
- Files.write refuses to overwrite a file that already exists, so a fresh name is needed each time.
- Renaming avoids encoding the lines as UTF-8 a second time, so large files save faster.
- The rename swaps the new file in as one filesystem operation, so a crash mid-write leaves the previous complete file rather than a truncated one.
- Writing to a temp file guarantees the bytes have reached the disk, so no flush is needed.
Show answer
Writing directly opens a window in which the real file has been truncated but not yet fully written; a rename is a single operation, so any reader or later run sees one complete version or the other. Option 0 is false, because Files.write defaults to CREATE plus TRUNCATE_EXISTING and happily overwrites. Option 3 is the tempting one: the rename controls the order in which the new content becomes visible, but it does not force the data blocks to stable storage, which would need FileChannel.force.