Skip to content
SparkTime, home
Java From Zero to Job-Ready
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
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
12 · Interviews and Readiness
Lesson 26 of 41

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.

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

#
Code
$ 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.50

Requirements

#

#

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

#
Code
   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

Main

Wires the real pieces together and exits with the right code. Almost no logic

Cli

Parses arguments, calls the service, prints results and errors. Knows nothing about files

ExpenseService

The business rules: "no dates in the future", filtering by month, totals. Knows nothing about text or files

ExpenseRepository

The interface for storing expenses

CsvExpenseRepository

Stores expenses in a CSV file

InMemoryExpenseRepository

Stores them in memory, for tests

Expense, Category, Money, InvalidInputException

The domain: what an expense is and what is valid

Decisions, and why

#
  1. Money is whole cents in a long, parsed with BigDecimal. Floating-point numbers cannot represent most decimals exactly (article 2), so 0.1 + 0.2 is not 0.3 and totals drift. Whole cents are exact. BigDecimal is used only to parse and validate text such as 12.50, because it can tell us exactly how many decimal places the user typed.
  2. 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.
  3. The clock is injected. The service receives a Clock instead of calling LocalDate.now() (article 8), so a test can fix "today" and check that a date one day later is rejected.
  4. Errors are classified. A user mistake (bad amount) is an InvalidInputException whose 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).
  5. 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).
  6. 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.
  7. Validation lives in the domain. The Expense record rejects impossible values in its constructor, so an invalid Expense can 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):

Code
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
<?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:

Code
target/.idea/*.iml.vscode/

A short README.md is enough for now:

Code
# 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:

Java
// 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:

Java
// 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":

Java
// 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:

Java
// 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:

Java
// 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:

Java
// 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:

Java
// 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:

  • store writes 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.
  • parse wraps any problem in an IllegalStateException that names the file and the line, so a damaged data file produces a useful message.
  • splitCsv is a small state machine that understands quoted fields, because a description such as coffee, and a muffin contains a comma.
Java
// 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:

Java
// 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:

Java
// 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:

Java
// 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:

  • run chooses the command with a switch expression, 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.
  • takeOption pulls --date 2026-09-03 out of the argument list, wherever it appears.
Java
// 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:

Java
// 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:

Java
// 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:

Shell
mvn clean verify

The 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:

Shell
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.)

Code
$ 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 add commands, then list sorted by date (id 3 is oldest although it was added third).
  • R2 and R3: the --month filter 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 2 works once and fails the second time with a clear message.
  • R5 and the file format: cat shows 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 max + 1, so deleting the highest id and adding again reuses that id

Surprising if ids are quoted elsewhere

A stored counter, or a database sequence

Money.parseCents accepts scientific notation: add 1e3 food records 1000.00

An unexpected input is accepted

Check the text against a regular expression such as \d+(\.\d+)? before parsing

One currency, no rounding rules, no budgets

Limited usefulness

New requirements

The summary forEach prints inside a lambda

Fine here, but harder to test in isolation

Return the lines from a method

No edit command

Mistakes need delete and add

Add update to the interface

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 ExpenseService run 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 verify pass from a fresh clone?

Extension challenges

#
  1. edit: add an update method to the repository interface and an edit <id> ... command. Start with the tests.
  2. Export: an export --month 2026-09 command that writes a report to a file with Files.writeString.
  3. Budgets: a monthly budget per category, with a warning from summary when it is exceeded.
  4. Fix the amount parser so that 1e3 is rejected, with tests first (see Exercise 2).
  5. 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 double for 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

#

Sources & further reading

  1. docs.oracle.com
  2. docs.oracle.com
  3. maven.apache.org
  • 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