Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
- 21File I/O and Serialization
- 22Debugging: Errors, Stack Traces, the Debugger and Logging
- 23Maven: What It Does and How to Use It
- 24Git for Java Projects: Commits, Branches, Merging and Pull Requests
- 25Testing with JUnit and Mockito: Unit Tests, Mocks and Their Limits
- 26Project 1: Build an Expense Tracker CLI in Plain Java
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
- 36Security Fundamentals and Spring Security: Passwords, JWT, Access Control, CORS and Secrets
- 37Testing Spring Boot Applications: Slice Tests, MockMvc, Real Databases and the Test Pyramid
- 38Production Practices: Configuration, Logging, Docker and Health Checks for Spring Boot
- 39Project 3: The Job Application Tracker API, Assembled and Reviewed
Lesson 26 · Tools and Craft
Project 1: Build an Expense Tracker CLI in Plain Java
Build your first complete Java application: a command-line expense tracker with a clear design, money handled correctly, a file-based repository behind an interface, automated tests, a Maven build, and a runnable jar, then review it honestly.
For
Learners who have completed the first 25 articles and want to assemble the language, collections, files, tests and Maven into a real application.
Level
Before you start
Articles 1 to 25, in particular records and enums (article 9), exceptions (article 10), the Stream API (article 16), file I/O (article 21), Maven (article 23) and testing (article 25). Verified on JDK 21 and JDK 25 with Maven 3.9.12.
On this page
Key takeaways
- A real application is a set of small classes with one job each: a domain model, a repository interface with two implementations, a service holding the rules, and a CLI that only translates text to calls and back.
- Keeping the repository behind an interface lets the tests use an in-memory version, lets the program use a CSV file, and lets Project 2 swap in PostgreSQL without touching the service or the CLI.
- Money is held as whole cents and parsed with BigDecimal, and the current time arrives through an injected Clock, so the rules can be tested exactly.
- The program reports problems with a useful message and a non-zero exit code, and the tests prove those paths work.
- A project is not finished when it runs. Review it for what it does not handle, and write those limits down.
Time to build something real. This is the first of three projects in the course, and each one grows from the last. Here you build a command-line expense tracker: you type a command, and it records or reports your spending. In Project 2 the same domain moves to a PostgreSQL database, and in Project 3 you build a web API with Spring Boot.
Everything on this page was built and run: the project compiles, its 37 tests pass on JDK 21 and JDK 25, and the sample session below is the real output of the packaged jar.
What you will learn
#- How to turn a small set of requirements into a design before writing code.
- How to organise classes by responsibility, and put storage behind an interface.
- How to handle money, dates, user input and errors properly.
- How to test a whole program, not just single methods.
- How to package it with Maven and review it critically.
What you are building
#$ expenses add 12.50 food "lunch with Sam" --date 2026-09-03Added #1: 2026-09-03 12.50 FOOD$ expenses summary --month 2026-09Summary for 2026-09 FOOD 12.50 TRANSPORT 45.00 HOUSING 1200.00 TOTAL 1257.50Requirements
## | The program must |
|---|---|
R1 | Add an expense with an amount, a category, an optional description and an optional date (the default is today) |
R2 | List expenses, oldest first, optionally for one month, with a total |
R3 | Summarise spending per category, optionally for one month |
R4 | Delete an expense by its id |
R5 | Keep the data between runs in a file |
R6 | Reject bad input (amounts, categories, dates, ids) with a clear message and a non-zero exit code, without a stack trace |
R7 | Never lose or corrupt existing data when something goes wrong while saving |
Out of scope, on purpose
#Editing an expense, several currencies, budgets, user accounts, and concurrent use by several people. Leaving these out is a design decision. Project 2 and Project 3 will address some of them.
Design before code
#The pieces
# command line | v +---------+ +----------------+ +--------------------+ | Cli | ---> | ExpenseService | ---> | ExpenseRepository | (interface) +---------+ +----------------+ +--------------------+ text in/out rules: dates, ^ ^ filtering, totals | | CsvExpenseRepository InMemoryExpenseRepository (the real program) (the tests)Each class has one reason to change:
Class | Responsibility |
|---|---|
| Wires the real pieces together and exits with the right code. Almost no logic |
| Parses arguments, calls the service, prints results and errors. Knows nothing about files |
| The business rules: "no dates in the future", filtering by month, totals. Knows nothing about text or files |
| The interface for storing expenses |
| Stores expenses in a CSV file |
| Stores them in memory, for tests |
| The domain: what an expense is and what is valid |
Decisions, and why
#- Money is whole cents in a
long, parsed withBigDecimal. Floating-point numbers cannot represent most decimals exactly (article 2), so0.1 + 0.2is not0.3and totals drift. Whole cents are exact.BigDecimalis used only to parse and validate text such as12.50, because it can tell us exactly how many decimal places the user typed. - A repository interface. The service asks for expenses through
ExpenseRepository, so it does not care where they live. The tests use the in-memory one; the program uses the CSV one; Project 2 will add a database one. - The clock is injected. The service receives a
Clockinstead of callingLocalDate.now()(article 8), so a test can fix "today" and check that a date one day later is rejected. - Errors are classified. A user mistake (bad amount) is an
InvalidInputExceptionwhose message is written for the user, and the CLI prints it and returns exit code 1. A storage problem (unreadable or damaged file) returns exit code 2. Neither prints a stack trace, because a user cannot act on one (article 10). - Atomic saving. Writing straight over the data file would leave a half-written file if the program stopped mid-write. The repository writes a temporary file in the same folder and then moves it over the real file, which meets requirement R7 (article 21).
- A simple file format. One line per expense:
id,date,amountCents,category,description, with a header line. Descriptions that contain a comma or a quote are wrapped in quotes. This is the kind of format a spreadsheet can open, which is useful for a person. - Validation lives in the domain. The
Expenserecord rejects impossible values in its constructor, so an invalidExpensecan never exist, wherever it came from (user input or a hand-edited file).
Set up the project
#Create the folder, a Git repository (article 24), and the Maven layout (article 23):
expense-tracker/├── pom.xml├── .gitignore├── README.md└── src/ ├── main/java/com/example/expenses/ └── test/java/com/example/expenses/The pom.xml has a single dependency, JUnit for tests. The program itself needs no libraries, which is why the plain jar will run on its own:
<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>expense-tracker</artifactId> <version>1.0.0</version> <packaging>jar</packaging> <properties> <maven.compiler.release>21</maven.compiler.release> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <junit.version>6.1.3</junit.version> </properties> <dependencies> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>${junit.version}</version> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>3.5.6</version> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <version>3.4.2</version> <configuration> <archive> <manifest> <mainClass>com.example.expenses.Main</mainClass> </manifest> </archive> </configuration> </plugin> </plugins> </build></project>A .gitignore that keeps build output and IDE files out of the repository:
target/.idea/*.iml.vscode/A short README.md is enough for now:
# Expense Tracker CLIA command-line expense tracker. Build with `mvn clean verify`, run with`java -jar target/expense-tracker-1.0.0.jar`. Set EXPENSE_TRACKER_FILE to choose the data file.Make the first commit now (git add pom.xml .gitignore README.md, then git commit -m "Create the Maven project"), and commit after each milestone below.
Milestone 1: the domain
#Start with the smallest pieces that everything else depends on. First, the error type for user mistakes. It is unchecked, because the CLI is the one place that handles it:
// File: src/main/java/com/example/expenses/InvalidInputException.javapackage com.example.expenses;/** The user supplied something we cannot accept. The message is written for the user. */public class InvalidInputException extends RuntimeException { public InvalidInputException(String message) { super(message); }}The categories are a fixed set, so they are an enum (article 9). parse accepts any capitalisation and gives the user the list of valid values when they are wrong. Locale.ROOT makes the case conversion independent of the computer's language settings:
// File: src/main/java/com/example/expenses/Category.javapackage com.example.expenses;import java.util.Arrays;import java.util.Locale;public enum Category { FOOD, TRANSPORT, HOUSING, UTILITIES, HEALTH, ENTERTAINMENT, OTHER; public static Category parse(String text) { try { return valueOf(text.trim().toUpperCase(Locale.ROOT)); } catch (IllegalArgumentException e) { throw new InvalidInputException( "unknown category '" + text + "', choose one of " + Arrays.toString(values())); } }}Money turns the user's text into cents and cents into text. The order of the checks matters: first "is it a number", then "is it positive", then "does it have more than two decimal places" (after stripTrailingZeros, so 12.500 is fine but 12.505 is not), then "does it fit in a long":
// File: src/main/java/com/example/expenses/Money.javapackage com.example.expenses;import java.math.BigDecimal;import java.util.Locale;/** Amounts are held as a whole number of cents, never as double. */public final class Money { private Money() { } public static long parseCents(String text) { BigDecimal value; try { value = new BigDecimal(text.trim()); } catch (NumberFormatException e) { throw new InvalidInputException("'" + text + "' is not a valid amount"); } if (value.signum() <= 0) { throw new InvalidInputException("amount must be greater than zero"); } if (value.stripTrailingZeros().scale() > 2) { throw new InvalidInputException("amount can have at most 2 decimal places"); } try { return value.movePointRight(2).longValueExact(); } catch (ArithmeticException e) { throw new InvalidInputException("amount is too large"); } } public static String format(long cents) { return String.format(Locale.ROOT, "%d.%02d", cents / 100, cents % 100); }}Expense is a record, so equality, toString and accessors come for free. The compact constructor is the gatekeeper:
// File: src/main/java/com/example/expenses/Expense.javapackage com.example.expenses;import java.time.LocalDate;import java.util.Objects;public record Expense(long id, LocalDate date, long amountCents, Category category, String description) { public static final int MAX_DESCRIPTION_LENGTH = 100; public Expense { Objects.requireNonNull(date, "date"); Objects.requireNonNull(category, "category"); if (id <= 0) { throw new IllegalArgumentException("id must be positive: " + id); } if (amountCents <= 0) { throw new InvalidInputException("amount must be greater than zero"); } description = description == null ? "" : description.strip(); if (description.length() > MAX_DESCRIPTION_LENGTH) { throw new InvalidInputException("description is longer than " + MAX_DESCRIPTION_LENGTH + " characters"); } if (description.indexOf('\n') >= 0 || description.indexOf('\r') >= 0) { throw new InvalidInputException("description must be a single line"); } }}Test Money straight away, because it is pure logic, and money bugs are costly. The parameterised tests list valid inputs, invalid ones and formatting cases:
// File: src/test/java/com/example/expenses/MoneyTest.javapackage com.example.expenses;import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertThrows;import org.junit.jupiter.params.ParameterizedTest;import org.junit.jupiter.params.provider.CsvSource;import org.junit.jupiter.params.provider.ValueSource;class MoneyTest { @ParameterizedTest(name = "\"{0}\" is {1} cents") @CsvSource({ "12.50, 1250", "12.5, 1250", "12, 1200", "0.01, 1", "' 3.20 ', 320", "12.500, 1250" }) void parsesValidAmounts(String text, long expectedCents) { assertEquals(expectedCents, Money.parseCents(text)); } @ParameterizedTest(name = "\"{0}\" is rejected") @ValueSource(strings = {"0", "-5", "abc", "", "12.505", "1.2.3", "99999999999999999999"}) void rejectsInvalidAmounts(String text) { assertThrows(InvalidInputException.class, () -> Money.parseCents(text)); } @ParameterizedTest(name = "{0} cents is shown as {1}") @CsvSource({"1250, 12.50", "1, 0.01", "100, 1.00", "123456, 1234.56"}) void formatsCents(long cents, String expected) { assertEquals(expected, Money.format(cents)); }}Milestone 2: storage behind an interface
#The interface is the contract. It only says what is stored, not where:
// File: src/main/java/com/example/expenses/ExpenseRepository.javapackage com.example.expenses;import java.time.LocalDate;import java.util.List;/** Where expenses are stored. The rest of the program depends only on this interface. */public interface ExpenseRepository { Expense add(LocalDate date, long amountCents, Category category, String description); List<Expense> findAll(); boolean deleteById(long id);}The in-memory implementation is the simplest possible one. It uses a TreeMap so that lastKey() gives the highest id, and a new expense gets the next one:
// File: src/main/java/com/example/expenses/InMemoryExpenseRepository.javapackage com.example.expenses;import java.time.LocalDate;import java.util.ArrayList;import java.util.List;import java.util.TreeMap;public class InMemoryExpenseRepository implements ExpenseRepository { private final TreeMap<Long, Expense> byId = new TreeMap<>(); @Override public Expense add(LocalDate date, long amountCents, Category category, String description) { long id = byId.isEmpty() ? 1 : byId.lastKey() + 1; Expense expense = new Expense(id, date, amountCents, category, description); byId.put(id, expense); return expense; } @Override public List<Expense> findAll() { return new ArrayList<>(byId.values()); } @Override public boolean deleteById(long id) { return byId.remove(id) != null; }}The CSV implementation is the biggest class. Read it in this order: the three public methods (all of the form load, change, store), then load and parse, then store, and then the two small CSV helpers. Notice three things:
storewrites to a temporary file in the same directory and then moves it over the real file. A move within one directory is much safer than overwriting in place.parsewraps any problem in anIllegalStateExceptionthat names the file and the line, so a damaged data file produces a useful message.splitCsvis a small state machine that understands quoted fields, because a description such ascoffee, and a muffincontains a comma.
// File: src/main/java/com/example/expenses/CsvExpenseRepository.javapackage com.example.expenses;import java.io.IOException;import java.io.UncheckedIOException;import java.nio.charset.StandardCharsets;import java.nio.file.Files;import java.nio.file.Path;import java.nio.file.StandardCopyOption;import java.time.LocalDate;import java.util.ArrayList;import java.util.List;/** Stores expenses in a UTF-8 CSV file. Every operation reads or rewrites the whole file. */public class CsvExpenseRepository implements ExpenseRepository { static final String HEADER = "id,date,amountCents,category,description"; private final Path file; public CsvExpenseRepository(Path file) { this.file = file; } @Override public Expense add(LocalDate date, long amountCents, Category category, String description) { List<Expense> all = load(); long id = all.stream().mapToLong(Expense::id).max().orElse(0) + 1; Expense expense = new Expense(id, date, amountCents, category, description); all.add(expense); store(all); return expense; } @Override public List<Expense> findAll() { return load(); } @Override public boolean deleteById(long id) { List<Expense> all = load(); boolean removed = all.removeIf(e -> e.id() == id); if (removed) { store(all); } return removed; } private List<Expense> load() { if (!Files.exists(file)) { return new ArrayList<>(); } try { List<String> lines = Files.readAllLines(file, StandardCharsets.UTF_8); List<Expense> expenses = new ArrayList<>(); for (int i = 1; i < lines.size(); i++) { // line 0 is the header if (!lines.get(i).isBlank()) { expenses.add(parse(lines.get(i), i + 1)); } } return expenses; } catch (IOException e) { throw new UncheckedIOException("cannot read " + file, e); } } private Expense parse(String line, int lineNumber) { try { List<String> f = splitCsv(line); if (f.size() != 5) { throw new IllegalArgumentException("expected 5 fields but found " + f.size()); } return new Expense(Long.parseLong(f.get(0)), LocalDate.parse(f.get(1)), Long.parseLong(f.get(2)), Category.valueOf(f.get(3)), f.get(4)); } catch (RuntimeException e) { throw new IllegalStateException( "data file " + file + " is damaged at line " + lineNumber + ": " + e.getMessage(), e); } } private void store(List<Expense> expenses) { try { Path directory = file.toAbsolutePath().getParent(); Files.createDirectories(directory); List<String> lines = new ArrayList<>(); lines.add(HEADER); for (Expense e : expenses) { lines.add(e.id() + "," + e.date() + "," + e.amountCents() + "," + e.category() + "," + escape(e.description())); } Path temp = Files.createTempFile(directory, "expenses", ".tmp"); Files.write(temp, lines, StandardCharsets.UTF_8); Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING); // the old file is never half-written } catch (IOException e) { throw new UncheckedIOException("cannot write " + file, e); } } static String escape(String field) { if (field.contains(",") || field.contains("\"")) { return "\"" + field.replace("\"", "\"\"") + "\""; } return field; } static List<String> splitCsv(String line) { List<String> fields = new ArrayList<>(); StringBuilder current = new StringBuilder(); boolean quoted = false; for (int i = 0; i < line.length(); i++) { char c = line.charAt(i); if (quoted) { if (c == '"') { if (i + 1 < line.length() && line.charAt(i + 1) == '"') { current.append('"'); // "" inside quotes is one quote i++; } else { quoted = false; } } else { current.append(c); } } else if (c == '"') { quoted = true; } else if (c == ',') { fields.add(current.toString()); current.setLength(0); } else { current.append(c); } } fields.add(current.toString()); return fields; }}The tests use JUnit's @TempDir, which gives each test its own temporary folder that is deleted afterwards (article 25). They check that data survives a fresh repository object (so it really reached the file), that tricky descriptions round-trip, that a missing file and folder are handled, that deleting removes only one expense, and that damage is reported with a line number:
// File: src/test/java/com/example/expenses/CsvExpenseRepositoryTest.javapackage com.example.expenses;import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertFalse;import static org.junit.jupiter.api.Assertions.assertThrows;import static org.junit.jupiter.api.Assertions.assertTrue;import java.io.IOException;import java.nio.file.Files;import java.nio.file.Path;import java.time.LocalDate;import java.util.List;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;class CsvExpenseRepositoryTest { @TempDir Path directory; @Test void dataSurvivesANewRepositoryInstance() { Path file = directory.resolve("expenses.csv"); new CsvExpenseRepository(file).add(LocalDate.of(2026, 3, 1), 1250, Category.FOOD, "lunch"); List<Expense> loaded = new CsvExpenseRepository(file).findAll(); assertEquals(List.of(new Expense(1, LocalDate.of(2026, 3, 1), 1250, Category.FOOD, "lunch")), loaded); } @Test void descriptionsWithCommasAndQuotesRoundTrip() { Path file = directory.resolve("expenses.csv"); CsvExpenseRepository repository = new CsvExpenseRepository(file); repository.add(LocalDate.of(2026, 3, 1), 100, Category.OTHER, "tea, milk and \"biscuits\""); assertEquals("tea, milk and \"biscuits\"", repository.findAll().get(0).description()); } @Test void missingFileMeansNoExpensesAndTheFileAndFolderAreCreatedOnFirstAdd() { Path file = directory.resolve("nested").resolve("expenses.csv"); CsvExpenseRepository repository = new CsvExpenseRepository(file); assertTrue(repository.findAll().isEmpty()); repository.add(LocalDate.of(2026, 3, 1), 100, Category.OTHER, ""); assertTrue(Files.exists(file)); } @Test void deleteRemovesOnlyTheNamedExpense() { CsvExpenseRepository repository = new CsvExpenseRepository(directory.resolve("expenses.csv")); Expense first = repository.add(LocalDate.of(2026, 3, 1), 100, Category.FOOD, "a"); Expense second = repository.add(LocalDate.of(2026, 3, 2), 200, Category.FOOD, "b"); assertTrue(repository.deleteById(first.id())); assertFalse(repository.deleteById(first.id())); assertEquals(List.of(second), repository.findAll()); } @Test void aDamagedFileIsReportedWithItsLineNumber() throws IOException { Path file = directory.resolve("expenses.csv"); Files.writeString(file, CsvExpenseRepository.HEADER + "\n1,2026-03-01,not-a-number,FOOD,lunch\n"); IllegalStateException e = assertThrows(IllegalStateException.class, () -> new CsvExpenseRepository(file).findAll()); assertTrue(e.getMessage().contains("damaged at line 2"), e.getMessage()); } @Test void splitsQuotedFieldsCorrectly() { assertEquals(List.of("1", "a,b", "say \"hi\"", ""), CsvExpenseRepository.splitCsv("1,\"a,b\",\"say \"\"hi\"\"\",")); }}Milestone 3: the service
#The service holds the rules and nothing else. It receives its repository and clock through the constructor. Notice that list returns a sorted, filtered copy built with the Stream API (article 16), and that the sort puts equal dates in id order so the output is stable:
// File: src/main/java/com/example/expenses/ExpenseService.javapackage com.example.expenses;import java.time.Clock;import java.time.LocalDate;import java.time.YearMonth;import java.util.Comparator;import java.util.EnumMap;import java.util.List;import java.util.Map;public class ExpenseService { public record Summary(Map<Category, Long> byCategory, long totalCents) { } private final ExpenseRepository repository; private final Clock clock; public ExpenseService(ExpenseRepository repository, Clock clock) { this.repository = repository; this.clock = clock; } /** Adds an expense. A null date means today. */ public Expense add(long amountCents, Category category, String description, LocalDate date) { LocalDate today = LocalDate.now(clock); LocalDate when = date == null ? today : date; if (when.isAfter(today)) { throw new InvalidInputException("date is in the future: " + when); } return repository.add(when, amountCents, category, description); } /** Lists expenses oldest first. A null month means all months. */ public List<Expense> list(YearMonth month) { return repository.findAll().stream() .filter(e -> month == null || YearMonth.from(e.date()).equals(month)) .sorted(Comparator.comparing(Expense::date).thenComparingLong(Expense::id)) .toList(); } public Summary summary(YearMonth month) { Map<Category, Long> totals = new EnumMap<>(Category.class); long total = 0; for (Expense e : list(month)) { totals.merge(e.category(), e.amountCents(), Long::sum); total += e.amountCents(); } return new Summary(totals, total); } public boolean delete(long id) { return repository.deleteById(id); }}The service tests use the in-memory repository (a fake, in the vocabulary of article 25) and a fixed clock. No file is touched, so they run in milliseconds. The boundary test, "today is accepted, tomorrow is not", is the kind of test that catches off-by-one date bugs:
// File: src/test/java/com/example/expenses/ExpenseServiceTest.javapackage com.example.expenses;import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertFalse;import static org.junit.jupiter.api.Assertions.assertThrows;import static org.junit.jupiter.api.Assertions.assertTrue;import java.time.Clock;import java.time.Instant;import java.time.LocalDate;import java.time.YearMonth;import java.time.ZoneOffset;import java.util.List;import org.junit.jupiter.api.BeforeEach;import org.junit.jupiter.api.Test;class ExpenseServiceTest { private static final Clock FIXED = Clock.fixed(Instant.parse("2026-03-15T10:00:00Z"), ZoneOffset.UTC); private ExpenseService service; @BeforeEach void setUp() { service = new ExpenseService(new InMemoryExpenseRepository(), FIXED); } @Test void usesTodayWhenNoDateIsGiven() { Expense saved = service.add(450, Category.FOOD, "lunch", null); assertEquals(LocalDate.of(2026, 3, 15), saved.date()); assertEquals(1, saved.id()); } @Test void rejectsADateInTheFuture() { InvalidInputException e = assertThrows(InvalidInputException.class, () -> service.add(450, Category.FOOD, "lunch", LocalDate.of(2026, 3, 16))); assertEquals("date is in the future: 2026-03-16", e.getMessage()); } @Test void acceptsTodayAsTheLatestValidDate() { Expense saved = service.add(450, Category.FOOD, "lunch", LocalDate.of(2026, 3, 15)); assertEquals(LocalDate.of(2026, 3, 15), saved.date()); } @Test void listsOldestFirstAndFiltersByMonth() { service.add(1000, Category.FOOD, "b", LocalDate.of(2026, 3, 10)); service.add(2000, Category.HOUSING, "a", LocalDate.of(2026, 2, 28)); service.add(500, Category.FOOD, "c", LocalDate.of(2026, 3, 2)); List<String> all = service.list(null).stream().map(Expense::description).toList(); List<String> march = service.list(YearMonth.of(2026, 3)).stream().map(Expense::description).toList(); assertEquals(List.of("a", "c", "b"), all); assertEquals(List.of("c", "b"), march); } @Test void summarisesTotalsPerCategory() { service.add(1000, Category.FOOD, "", LocalDate.of(2026, 3, 10)); service.add(250, Category.FOOD, "", LocalDate.of(2026, 3, 11)); service.add(4000, Category.TRANSPORT, "", LocalDate.of(2026, 3, 12)); service.add(9999, Category.HOUSING, "", LocalDate.of(2026, 2, 1)); ExpenseService.Summary summary = service.summary(YearMonth.of(2026, 3)); assertEquals(1250L, summary.byCategory().get(Category.FOOD)); assertEquals(4000L, summary.byCategory().get(Category.TRANSPORT)); assertFalse(summary.byCategory().containsKey(Category.HOUSING)); assertEquals(5250, summary.totalCents()); } @Test void deleteReportsWhetherAnythingWasRemoved() { Expense saved = service.add(450, Category.FOOD, "", null); assertTrue(service.delete(saved.id())); assertFalse(service.delete(saved.id())); }}Milestone 4: the command line
#The Cli class is deliberately plain. run takes the arguments and returns an exit code; it writes to two PrintStreams that are passed in, which is what makes it testable. The structure is:
runchooses the command with aswitchexpression, and is the only place that catches exceptions and turns them into messages and exit codes.- Each command method parses its arguments (using the helpers at the bottom), calls the service, and prints.
takeOptionpulls--date 2026-09-03out of the argument list, wherever it appears.
// File: src/main/java/com/example/expenses/Cli.javapackage com.example.expenses;import java.io.PrintStream;import java.io.UncheckedIOException;import java.time.LocalDate;import java.time.YearMonth;import java.time.format.DateTimeParseException;import java.util.ArrayList;import java.util.Arrays;import java.util.List;import java.util.Locale;/** Turns command-line arguments into service calls and prints the result. */public class Cli { private static final String USAGE = """ Usage: expenses add <amount> <category> [description] [--date yyyy-MM-dd] expenses list [--month yyyy-MM] expenses summary [--month yyyy-MM] expenses delete <id> expenses categories """; private final ExpenseService service; private final PrintStream out; private final PrintStream err; public Cli(ExpenseService service, PrintStream out, PrintStream err) { this.service = service; this.out = out; this.err = err; } /** Runs one command and returns the process exit code: 0 success, 1 bad input, 2 storage problem. */ public int run(String[] args) { if (args.length == 0) { err.print(USAGE); return 1; } List<String> rest = new ArrayList<>(Arrays.asList(args).subList(1, args.length)); try { switch (args[0]) { case "add" -> add(rest); case "list" -> list(rest); case "summary" -> summary(rest); case "delete" -> delete(rest); case "categories" -> categories(rest); default -> { err.println("error: unknown command '" + args[0] + "'"); err.print(USAGE); return 1; } } return 0; } catch (InvalidInputException e) { err.println("error: " + e.getMessage()); return 1; } catch (UncheckedIOException | IllegalStateException e) { err.println("error: " + e.getMessage()); return 2; } } private void add(List<String> args) { String dateText = takeOption(args, "--date"); if (args.size() < 2 || args.size() > 3) { throw new InvalidInputException("add needs <amount> <category> and an optional description"); } long cents = Money.parseCents(args.get(0)); Category category = Category.parse(args.get(1)); String description = args.size() == 3 ? args.get(2) : ""; LocalDate date = dateText == null ? null : parseDate(dateText); Expense saved = service.add(cents, category, description, date); out.println("Added #" + saved.id() + ": " + saved.date() + " " + Money.format(saved.amountCents()) + " " + saved.category()); } private void list(List<String> args) { YearMonth month = parseMonth(takeOption(args, "--month")); noMoreArguments(args); List<Expense> expenses = service.list(month); if (expenses.isEmpty()) { out.println("No expenses found."); return; } out.printf(Locale.ROOT, "%4s %-10s %10s %-13s %s%n", "ID", "DATE", "AMOUNT", "CATEGORY", "DESCRIPTION"); long total = 0; for (Expense e : expenses) { out.printf(Locale.ROOT, "%4d %-10s %10s %-13s %s%n", e.id(), e.date(), Money.format(e.amountCents()), e.category(), e.description()); total += e.amountCents(); } out.printf(Locale.ROOT, "%d expense(s), total %s%n", expenses.size(), Money.format(total)); } private void summary(List<String> args) { YearMonth month = parseMonth(takeOption(args, "--month")); noMoreArguments(args); ExpenseService.Summary summary = service.summary(month); out.println(month == null ? "Summary (all time)" : "Summary for " + month); summary.byCategory().forEach((category, cents) -> out.printf(Locale.ROOT, " %-13s %10s%n", category, Money.format(cents))); out.printf(Locale.ROOT, " %-13s %10s%n", "TOTAL", Money.format(summary.totalCents())); } private void delete(List<String> args) { if (args.size() != 1) { throw new InvalidInputException("delete needs exactly one <id>"); } long id; try { id = Long.parseLong(args.get(0)); } catch (NumberFormatException e) { throw new InvalidInputException("'" + args.get(0) + "' is not a valid id"); } if (service.delete(id)) { out.println("Deleted #" + id); } else { throw new InvalidInputException("no expense with id " + id); } } private void categories(List<String> args) { noMoreArguments(args); for (Category category : Category.values()) { out.println(category); } } /** Removes "--name value" from the arguments and returns the value, or null if absent. */ private static String takeOption(List<String> args, String name) { int index = args.indexOf(name); if (index < 0) { return null; } if (index + 1 >= args.size()) { throw new InvalidInputException(name + " needs a value"); } String value = args.get(index + 1); args.remove(index + 1); args.remove(index); return value; } private static void noMoreArguments(List<String> args) { if (!args.isEmpty()) { throw new InvalidInputException("unexpected arguments: " + args); } } private static LocalDate parseDate(String text) { try { return LocalDate.parse(text); } catch (DateTimeParseException e) { throw new InvalidInputException("'" + text + "' is not a valid date, use yyyy-MM-dd"); } } private static YearMonth parseMonth(String text) { if (text == null) { return null; } try { return YearMonth.parse(text); } catch (DateTimeParseException e) { throw new InvalidInputException("'" + text + "' is not a valid month, use yyyy-MM"); } }}The Cli test drives the program from the outside: real service, real CSV repository in a temp folder, a fixed clock, and captured output. It checks both the printed text and the exit codes, which are part of the program's contract. One test builds a month summary and compares the exact output lines:
// File: src/test/java/com/example/expenses/CliTest.javapackage com.example.expenses;import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertTrue;import java.io.ByteArrayOutputStream;import java.io.PrintStream;import java.nio.charset.StandardCharsets;import java.nio.file.Path;import java.time.Clock;import java.time.Instant;import java.time.ZoneOffset;import java.util.List;import org.junit.jupiter.api.BeforeEach;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;/** Drives the whole program except the real clock and the real home directory. */class CliTest { private static final Clock FIXED = Clock.fixed(Instant.parse("2026-03-15T10:00:00Z"), ZoneOffset.UTC); @TempDir Path directory; private ByteArrayOutputStream outBytes; private ByteArrayOutputStream errBytes; private Cli cli; @BeforeEach void setUp() { outBytes = new ByteArrayOutputStream(); errBytes = new ByteArrayOutputStream(); ExpenseService service = new ExpenseService( new CsvExpenseRepository(directory.resolve("expenses.csv")), FIXED); cli = new Cli(service, new PrintStream(outBytes, true, StandardCharsets.UTF_8), new PrintStream(errBytes, true, StandardCharsets.UTF_8)); } private int run(String... args) { return cli.run(args); } private List<String> out() { return outBytes.toString(StandardCharsets.UTF_8).lines().toList(); } private List<String> err() { return errBytes.toString(StandardCharsets.UTF_8).lines().toList(); } @Test void addThenListShowsTheExpense() { assertEquals(0, run("add", "12.50", "food", "lunch with Sam", "--date", "2026-03-10")); assertEquals("Added #1: 2026-03-10 12.50 FOOD", out().get(0)); assertEquals(0, run("list")); List<String> lines = out(); assertTrue(lines.get(2).contains("lunch with Sam"), lines.toString()); assertEquals("1 expense(s), total 12.50", lines.get(lines.size() - 1)); } @Test void summaryTotalsByCategoryForAMonth() { run("add", "10", "food", "--date", "2026-03-01"); run("add", "2.50", "food", "--date", "2026-03-02"); run("add", "40", "transport", "--date", "2026-03-03"); run("add", "99", "housing", "--date", "2026-02-03"); outBytes.reset(); assertEquals(0, run("summary", "--month", "2026-03")); assertEquals(List.of( "Summary for 2026-03", " FOOD 12.50", " TRANSPORT 40.00", " TOTAL 52.50"), out()); } @Test void anInvalidAmountIsReportedWithExitCode1() { assertEquals(1, run("add", "abc", "food")); assertEquals(List.of("error: 'abc' is not a valid amount"), err()); } @Test void anUnknownCategoryListsTheValidOnes() { assertEquals(1, run("add", "5", "snacks")); assertTrue(err().get(0).startsWith("error: unknown category 'snacks', choose one of ["), err().toString()); } @Test void aFutureDateIsRejected() { assertEquals(1, run("add", "5", "food", "--date", "2026-03-16")); assertEquals(List.of("error: date is in the future: 2026-03-16"), err()); } @Test void deletingAnUnknownIdFails() { assertEquals(1, run("delete", "7")); assertEquals(List.of("error: no expense with id 7"), err()); } @Test void anUnknownCommandPrintsTheUsage() { assertEquals(1, run("remove", "1")); assertEquals("error: unknown command 'remove'", err().get(0)); assertEquals("Usage:", err().get(1)); } @Test void noArgumentsPrintsTheUsage() { assertEquals(1, run()); assertEquals("Usage:", err().get(0)); }}Milestone 5: Main and the first run
#Main is the composition root: the one place that creates the real objects and connects them. It reads the data file location from the environment variable EXPENSE_TRACKER_FILE, or defaults to a folder in the user's home directory:
// File: src/main/java/com/example/expenses/Main.javapackage com.example.expenses;import java.nio.file.Path;import java.time.Clock;public class Main { public static void main(String[] args) { Path file = dataFile(System.getenv("EXPENSE_TRACKER_FILE"), System.getProperty("user.home")); ExpenseService service = new ExpenseService(new CsvExpenseRepository(file), Clock.systemDefaultZone()); int exitCode = new Cli(service, System.out, System.err).run(args); System.exit(exitCode); } /** The file named by EXPENSE_TRACKER_FILE, or ~/.expense-tracker/expenses.csv. */ static Path dataFile(String override, String home) { if (override != null && !override.isBlank()) { return Path.of(override); } return Path.of(home, ".expense-tracker", "expenses.csv"); }}Build and test everything, and the result is a runnable jar. The program has no dependencies, so unlike the example in article 23, java -jar works directly:
mvn clean verifyThe build reports Tests run: 37, Failures: 0, Errors: 0, Skipped: 0 (17 in MoneyTest, 6 in each of the repository and service tests, and 8 in CliTest). Define a shortcut for typing, and try the commands. This is the real output of a session on both JDK 21 and JDK 25 (identical on both), using a temporary data file and explicit dates so the result is repeatable. Where a command failed, the program's exit code is shown in parentheses:
alias expenses='java -jar target/expense-tracker-1.0.0.jar'(That is Bash or Zsh syntax. In PowerShell, define a function that runs the same java -jar command and passes its arguments on.)
$ expenses categoriesFOODTRANSPORTHOUSINGUTILITIESHEALTHENTERTAINMENTOTHER$ expenses add 12.50 food "lunch with Sam" --date 2026-09-03Added #1: 2026-09-03 12.50 FOOD$ expenses add 45 transport "monthly metro pass" --date 2026-09-05Added #2: 2026-09-05 45.00 TRANSPORT$ expenses add 1200 housing "rent" --date 2026-09-01Added #3: 2026-09-01 1200.00 HOUSING$ expenses add 3.20 food "coffee, and a muffin" --date 2026-10-02Added #4: 2026-10-02 3.20 FOOD$ expenses list ID DATE AMOUNT CATEGORY DESCRIPTION 3 2026-09-01 1200.00 HOUSING rent 1 2026-09-03 12.50 FOOD lunch with Sam 2 2026-09-05 45.00 TRANSPORT monthly metro pass 4 2026-10-02 3.20 FOOD coffee, and a muffin4 expense(s), total 1260.70$ expenses list --month 2026-09 ID DATE AMOUNT CATEGORY DESCRIPTION 3 2026-09-01 1200.00 HOUSING rent 1 2026-09-03 12.50 FOOD lunch with Sam 2 2026-09-05 45.00 TRANSPORT monthly metro pass3 expense(s), total 1257.50$ expenses summary --month 2026-09Summary for 2026-09 FOOD 12.50 TRANSPORT 45.00 HOUSING 1200.00 TOTAL 1257.50$ expenses summarySummary (all time) FOOD 15.70 TRANSPORT 45.00 HOUSING 1200.00 TOTAL 1260.70$ expenses add 12.505 fooderror: amount can have at most 2 decimal places(exit code 1)$ expenses add 5 snackserror: unknown category 'snacks', choose one of [FOOD, TRANSPORT, HOUSING, UTILITIES, HEALTH, ENTERTAINMENT, OTHER](exit code 1)$ expenses add 5 food --date 2099-01-01error: date is in the future: 2099-01-01(exit code 1)$ expenses delete 2Deleted #2$ expenses delete 2error: no expense with id 2(exit code 1)$ expenses list --month 2026-09 ID DATE AMOUNT CATEGORY DESCRIPTION 3 2026-09-01 1200.00 HOUSING rent 1 2026-09-03 12.50 FOOD lunch with Sam2 expense(s), total 1212.50$ cat "$EXPENSE_TRACKER_FILE"id,date,amountCents,category,description1,2026-09-03,1250,FOOD,lunch with Sam3,2026-09-01,120000,HOUSING,rent4,2026-10-02,320,FOOD,"coffee, and a muffin"$ expenses Usage: expenses add <amount> <category> [description] [--date yyyy-MM-dd] expenses list [--month yyyy-MM] expenses summary [--month yyyy-MM] expenses delete <id> expenses categories(exit code 1)Read the session carefully, because it shows the requirements working:
- R1 and R2: the four
addcommands, thenlistsorted by date (id 3 is oldest although it was added third). - R2 and R3: the
--monthfilter changes both the list and the totals. The all-time summary has no--month. - R6: three different bad inputs give a one-line message and exit code 1, and the unknown-category message lists the valid values. No stack trace appears.
- R4:
delete 2works once and fails the second time with a clear message. - R5 and the file format:
catshows the file. The amounts are stored in cents (120000), and only the description that contains a comma is quoted.
Review the project honestly
#A project is not finished when it runs. A reviewer, or an interviewer, will ask what it does not do. Here is an honest list for this one:
Limitation | Consequence | How you would address it |
|---|---|---|
Every command rewrites the whole file | Fine for hundreds of expenses, wasteful for millions | A database (Project 2) |
Two programs running at the same time could each read, change and write, and one change would be lost | Data loss under concurrent use | A file lock, or a database with transactions |
New ids are | Surprising if ids are quoted elsewhere | A stored counter, or a database sequence |
| An unexpected input is accepted | Check the text against a regular expression such as |
One currency, no rounding rules, no budgets | Limited usefulness | New requirements |
The summary | Fine here, but harder to test in isolation | Return the lines from a method |
No | Mistakes need delete and add | Add |
Writing a table like this is a professional habit. It shows that you understand the difference between works and is finished, and it gives you the answer when someone asks "what would you improve?".
A self-review checklist
#- Does every class have one reason to change?
- Could
ExpenseServicerun without knowing about files or printing? (Yes: its tests prove it.) - Is every error path tested, and does each one give a message a user can act on?
- Are there magic numbers or strings that should be constants or enums?
- Is anything duplicated that could be a method?
- Would a new team member understand the code from the names alone?
- Does
mvn clean verifypass from a fresh clone?
Extension challenges
#edit: add anupdatemethod to the repository interface and anedit <id> ...command. Start with the tests.- Export: an
export --month 2026-09command that writes a report to a file withFiles.writeString. - Budgets: a monthly budget per category, with a warning from
summarywhen it is exceeded. - Fix the amount parser so that
1e3is rejected, with tests first (see Exercise 2). - Replace the CSV repository with JSON using only the standard library, then explain why you would not.
Common mistakes in projects like this
#- Putting all code in
Main. It works, and it cannot be tested or reused. - Using
doublefor money. - Printing stack traces to users, or swallowing exceptions and printing nothing.
- Writing the tests last, or never. The tests here caught a wrong expectation in the very first run, which was a mistake in the test and not in the code. That is a normal part of the process.
- Calling
LocalDate.now()inside business logic, which makes the date rules untestable. - Making the repository interface mirror one technology (for example methods that take file paths), which makes the next implementation impossible.
- Skipping the README. Write three lines: what it is, how to build it, how to run it.
- Committing everything at the end instead of after each milestone.
Exercises
#Exercise 1
Add a command
#Add a top command, expenses top, that prints the single category with the highest total (all time). Where do the rules and the printing each belong?
Exercise 2: Fix the amount parser
#expenses add 1e3 food records 1000.00. Decide whether that is acceptable, then change Money.parseCents so that only plain decimal notation is accepted. Which tests do you add first?
Exercise 3: Predict
#What does expenses list --month 2026-9 print, and why? What would you change?
Exercise 4: Explain the design
#Why does ExpenseService take an ExpenseRepository in its constructor instead of creating a CsvExpenseRepository itself? Name two things this makes possible.
Exercise 5: Spot the data-loss risk
#Describe a sequence of events in which one expense could be lost although each command finished successfully.
Show solutionHide solution
The rule (find the category with the greatest total) belongs in ExpenseService, for example a topCategory() method returning an Optional<Category>, built on the existing summary(null): take the entry with the maximum value. The printing (the wording, and what to say when there are no expenses) belongs in Cli, in a new top case in the switch. Write the service test first: with expenses in two categories, the larger total wins; with none, the result is empty.
Exercise 2
Show solutionHide solution
Whether it is acceptable is a requirement decision, and for a money tool, accepting 1e3 is surprising, so reject it. Add failing cases to the rejectsInvalidAmounts test first (1e3, 1E2), then check the trimmed text against a regular expression that allows only digits with an optional decimal part, such as \d+(\.\d+)?, before calling new BigDecimal(...). Keep the existing checks: the pattern deliberately allows any number of decimals so that 12.500 still parses and 12.505 still gets the precise "at most 2 decimal places" message. Then run all of MoneyTest again to make sure the valid cases (12.5, 12, 0.01, 12.500) behave as before, and note in the commit message that scientific notation is no longer accepted.
Exercise 3
Show solutionHide solution
YearMonth.parse("2026-9") does not accept a single-digit month, so the program prints error: '2026-9' is not a valid month, use yyyy-MM and exits with code 1. That is correct and consistent, and the message tells the user the format. An option would be to accept 2026-9 too, by parsing the two parts manually, but being strict with a clear message is simpler and safer.
Exercise 4
Show solutionHide solution
It is dependency injection by hand (article 31 develops it). It makes it possible to (1) test the service with an in-memory repository and a fixed clock, with no files, and (2) replace the storage later, with a database implementation in Project 2, without changing the service or the CLI. It also keeps the service free of knowledge about files, so it has fewer reasons to change.
Exercise 5
Show solutionHide solution
Two terminals run expenses add ... at nearly the same moment. Both read the file (say it holds 3 expenses). Each adds its own expense in memory and writes a file with 4 expenses. The second write replaces the first, so one of the new expenses is gone, although both commands printed "Added". Atomic moves protect against a half-written file, but not against this lost update. Fixes: lock the file during the whole load-change-store cycle, or use a database transaction.
Where to go next
#- Next: SQL and PostgreSQL Fundamentals. Several limitations in the review table (concurrency, ids, scale) are exactly what a database is for.
- Then: JDBC: Talking to a Database from Java, before Project 2 moves this domain into PostgreSQL behind the
ExpenseRepositoryinterface.
Sources & further reading
- Technology
- Software Engineering
- #command line
- #Java
- #Java backend
- #Java basics
- #Java interview
- #Java project
- #JPA
- #JUnit
- #learn Java
- #Maven
- #Spring Boot
- #SQL
Connected lessons
References
- Project 3: The Job Application Tracker API, Assembled and Reviewed
- Strings, StringBuilder, Text Blocks and Time
- JDBC: Talking to a Database from Java
Show all 14 →
- Testing with JUnit and Mockito: Unit Tests, Mocks and Their Limits
- Project 2: Move the Expense Tracker to PostgreSQL with JDBC
- Git for Java Projects: Commits, Branches, Merging and Pull Requests
- Streams in Practice (and When Not to Use Them)
- SQL and PostgreSQL Fundamentals for Java Developers
- Dependency Injection and Inversion of Control: The Idea Behind Spring
- Variables, Types, Operators and Arrays
- Exceptions: Handling Errors Properly
- Enums, Records and Sealed Types
- File I/O and Serialization
- Maven: What It Does and How to Use It
Keep reading
More in Technology →Guide · Technology
Java Essentials for DSA
Guide · Technology
Heaps and Priority Queues
Course · Technology
DSA From Zero to Job-Ready: A Practical Guide with Java

