Build a Java Expense Tracker with Analytics

Build a console expense tracker using OOP, Streams, and File I/O in Java.

Introduction

30 Second Summary

Everyone tracks their spending somehow. Whether it's a spreadsheet, a banking app, or a crumpled receipt in your pocket, the core question is always the same: where is my money actually going?

In this project, you will build a Personal Expense Tracker in Java that records transactions, categorizes spending, and generates analytics reports. Along the way, you'll get hands-on practice with 12+ core Java concepts that come up in almost every technical interview.

What You'll Build

A command-line expense tracking application built with core Java, featuring OOP data models, Collections-based storage, Streams-powered analytics, CSV file persistence, and multithreaded auto-save.

By the end of this project, you'll have:

  • A console-based expense tracker that records income and expenses, categorizes them, and persists data to CSV files.
  • An analytics engine powered by the Java Streams API that generates spending reports by category, by month, and by top expenses.
  • Hands-on experience with 12+ core Java interview topics including OOP, Collections, Generics, Exception Handling, File I/O, Streams, Multithreading, and Design Patterns.
  • A Budget Alert System (Secret Mission) that warns you when spending exceeds your limits, using Optional and custom functional interfaces.

Are there any prerequisites?

You should have basic Java knowledge (variables, loops, if-else, methods). You'll also need JDK 21 or newer and IntelliJ IDEA installed on your machine.

Set Up the Project in IntelliJ IDEA

Before writing any code, you need a well-organized project structure. A clean folder layout makes your code easier to navigate and maps directly to how professional Java applications are organized.

In this step, you'll create a new Java project in IntelliJ IDEA, set up packages that separate your code by responsibility, and run a quick test to make sure everything compiles.

In this step, get ready to:
  • Create a new Java project in IntelliJ IDEA.
  • Set up packages that organize your code by responsibility.
  • Write and run a main entry point to verify the project works.
Create a new Java project
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type IntelliJ IDEA and press Enter to open it.

You might land on the Welcome screen or on a previously opened project. Either way, the next step is the same.

  • If you see the Welcome screen, click New Project.
  • If you already have a project open, click File in the top menu bar, then New, then Project.

The New Project dialog opens. Configure it with these settings:

  • In the Name field, type ExpenseTracker.
  • Set Language to Java.
  • Set Build system to IntelliJ.
  • Set the JDK dropdown to 21 or newer.
  • Uncheck Add sample code.
  • Click Create.

What is a JDK?

The JDK (Java Development Kit) is the toolkit that lets you compile and run Java programs. JDK 21 is the current widely-used LTS (Long-Term Support) version, and all the APIs used in this project work across 21 and newer.

IntelliJ creates your project and opens it. You should see a Project panel on the left with an src folder inside your ExpenseTracker project.

Don't see a JDK option in the dropdown?

If the JDK dropdown is empty or shows no version 21+, click the dropdown and select Download JDK. Choose version 21 or newer from the vendor list, then click Download. IntelliJ installs it automatically.

Still stuck? Help me set up a JDK in IntelliJ IDEA so I can create a Java project.

Create the package structure

Java uses packages to organize classes into logical groups. Each package maps to a folder on disk and a namespace in your code.

  • In the Project panel on the left, right-click the src folder.
  • Select New, then Package.
  • Type model and press Enter.
  • Repeat this process to create five more packages: service, storage, exception, util, and app.

Your src folder should now contain six packages.

What does each package do?

This structure follows clean architecture by separating concerns into dedicated packages:

  • model holds data classes like Transaction and Category.
  • service holds business logic like the analytics engine and data repository.
  • storage handles reading and writing data to files.
  • exception contains custom exception classes for input validation.
  • util holds helper utilities like sorting and validation.
  • app contains the main entry point and console interface.

Packages not showing up under src?

Make sure you are right-clicking the src folder itself, not the project root or a file. If your src folder is blue, it is marked as a Sources Root and packages can be created inside it.

If src is grey, right-click it, select Mark Directory as, then choose Sources Root.

Help me fix my package structure in IntelliJ IDEA.

Create the main entry point

Every Java application needs a class with a main method. This is where the JVM starts running your program.

  • In the Project panel, right-click the app package.
  • Select New, then Java Class.
  • Type ExpenseTrackerApp and press Enter.
  • Replace everything in the file with this code:
package app;

public class ExpenseTrackerApp {
    public static void main(String[] args) {
        // Print a startup message to verify the project works
        System.out.println("Expense Tracker starting...");
    }
}

What does this code do?

  • package app; tells Java this class belongs to the app package you just created.
  • public static void main(String[] args) is the standard entry point signature that the JVM looks for when launching your program.
  • System.out.println() prints a message to the console so you can confirm the project runs correctly.
  • Save the file by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Click the green play button in the gutter next to public static void main.
  • Select Run 'ExpenseTrackerApp.main()'.

A Run panel opens at the bottom of IntelliJ. You should see Expense Tracker starting... printed in the console output.

Not seeing the green play button?

The green play button only appears after IntelliJ finishes indexing your project. Look for a progress bar at the bottom of the window. Wait for it to complete, then check the gutter again.

If you see a red underline on any code, double-check that the class is inside the app package and the package app; line at the top matches.

Help me run my ExpenseTrackerApp in IntelliJ IDEA.

Awesome, I've got everything!

Double check you've saved ExpenseTrackerApp.java and that the console printed your startup message.

I'd like to double check the full code

package app;

public class ExpenseTrackerApp {
    public static void main(String[] args) {
        // Print a startup message to verify the project works
        System.out.println("Expense Tracker starting...");
    }
}

Your project skeleton is in place. Next up, you'll fill the model package with the data classes that represent transactions and categories.

Build the Data Model with OOP

Your project is set up and ready to go. Now it's time to design the data structures that power your expense tracker.

Every transaction has a description, an amount, a category, and a date. In this step, you'll use core OOP principles to build Java classes that represent this data cleanly.

In this step, get ready to:
  • Create a Category enum that gives spending categories display names and behavior.
  • Build a Transaction class with encapsulation, sorting, and proper object identity.
  • Implement a TransactionBuilder that uses the Builder design pattern for flexible object creation.
Create the Category enum

Java enums can hold fields, constructors, and methods. Your spending categories need human-readable display names like "Food" instead of the raw constant FOOD, so you'll build an enum with behavior.

  • Right-click the model package in the Project panel.
  • Select New > Java Class.
  • Enter Category as the name.
  • In the Kind dropdown, select Enum.
  • Replace the generated code with the following:
package model;

// Enum representing spending categories with display names
public enum Category {
    FOOD("Food"),
    TRANSPORT("Transport"),
    ENTERTAINMENT("Entertainment"),
    UTILITIES("Utilities"),
    HEALTH("Health"),
    SHOPPING("Shopping"),
    INCOME("Income"),
    OTHER("Other");

    // Each enum constant stores a human-readable name
    private final String displayName;

    // Private constructor - only the enum constants above can call this
    Category(String displayName) {
        this.displayName = displayName;
    }

    public String getDisplayName() {
        return displayName;
    }

    // Static factory method to find a category from user input
    public static Category fromString(String input) {
        for (Category category : values()) {
            if (category.name().equalsIgnoreCase(input) || category.displayName.equalsIgnoreCase(input)) {
                return category;
            }
        }
        throw new IllegalArgumentException("Unknown category: " + input);
    }
}

What does this code do?

  • Each constant like FOOD("Food") passes a display name to the enum's private constructor. This means every category carries its own label.
  • getDisplayName() returns the human-readable version for printing in reports.
  • fromString() is a static factory method that converts user input (like "food") into the matching enum constant. If no match is found, it throws an exception.

Why not just use plain Strings for categories?

Strings are error-prone. A typo like "Foood" would silently create a new category instead of failing. Enums restrict values to a fixed set, so the compiler catches mistakes before your code even runs.

  • Save Category.java.
  • Check that IntelliJ shows no red error underlines in the file. If everything is clean, your enum compiles correctly.

Seeing red underlines?

  • Make sure the first line says package model; and matches the package folder you right-clicked.
  • Check that every enum constant ends with a comma except the last one, which ends with a semicolon.
  • help me fix the compilation error in my Category enum

Awesome, I've got everything!

Double check you've saved your file and there are no red underlines.

I'd like to double check the full code

package model;

public enum Category {
    FOOD("Food"),
    TRANSPORT("Transport"),
    ENTERTAINMENT("Entertainment"),
    UTILITIES("Utilities"),
    HEALTH("Health"),
    SHOPPING("Shopping"),
    INCOME("Income"),
    OTHER("Other");

    private final String displayName;

    Category(String displayName) {
        this.displayName = displayName;
    }

    public String getDisplayName() {
        return displayName;
    }

    public static Category fromString(String input) {
        for (Category category : values()) {
            if (category.name().equalsIgnoreCase(input) || category.displayName.equalsIgnoreCase(input)) {
                return category;
            }
        }
        throw new IllegalArgumentException("Unknown category: " + input);
    }
}
Create the Transaction class

The Transaction class is the core data model of your app. It demonstrates several concepts that come up constantly in Java interviews: encapsulation, the Comparable interface, the equals()/hashCode() contract, and StringBuilder.

You'll build this class in two chunks. First, the fields, constructor, and getters. Then, the method overrides.

  • Right-click the model package in the Project panel.
  • Select New > Java Class.
  • Enter Transaction as the name and leave the kind as Class.
  • Replace the generated code with the following:
package model;

import java.time.LocalDate;
import java.util.Objects;

// Transaction class implements Comparable for natural date-based sorting
public class Transaction implements Comparable<Transaction> {
    // Private fields enforce encapsulation - no outside code can change these
    private final String id;
    private final String description;
    private final double amount;
    private final Category category;
    private final LocalDate date;
    private final boolean income;

    // Constructor sets all fields - no setters means the object is immutable
    public Transaction(String id, String description, double amount, Category category, LocalDate date, boolean income) {
        this.id = id;
        this.description = description;
        this.amount = amount;
        this.category = category;
        this.date = date;
        this.income = income;
    }

    // Getters provide read-only access to private fields
    public String getId() { return id; }
    public String getDescription() { return description; }
    public double getAmount() { return amount; }
    public Category getCategory() { return category; }
    public LocalDate getDate() { return date; }
    public boolean isIncome() { return income; }
}

What does this code do?

  • All fields are private final. The private keyword means no outside class can access them directly. The final keyword means they can only be set once, in the constructor.
  • There are no setter methods. Once a Transaction is created, its data cannot change. This is immutability.
  • implements Comparable<Transaction> means this class promises to define a natural sort order. You'll implement the required compareTo() method in the next chunk.
  • Save Transaction.java.
  • You should see a red underline on the class name. That is expected because you declared implements Comparable<Transaction> but haven't added the compareTo() method yet.

Now you'll add the method overrides that fulfill the Comparable contract and give Transaction proper identity and display behavior.

  • In Transaction.java, find the last getter method isIncome(), then copy the following code below it, still inside the class braces:
    // Comparable: sort transactions by date (natural ordering)
    @Override
    public int compareTo(Transaction other) {
        return this.date.compareTo(other.date);
    }

    // equals: two transactions are equal if they have the same id
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Transaction that = (Transaction) o;
        return Objects.equals(id, that.id);
    }

    // hashCode: must match equals - use the same field (id)
    @Override
    public int hashCode() {
        return Objects.hash(id);
    }

    // toString: uses StringBuilder for efficient string concatenation
    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        sb.append(date).append(" | ");
        sb.append(income ? "+" : "-");
        sb.append(String.format("$%.2f", amount)).append(" | ");
        sb.append(category.getDisplayName()).append(" | ");
        sb.append(description);
        return sb.toString();
    }

What does this code do?

  • compareTo() compares transactions by date. This is the "natural ordering" that collections like TreeMap and methods like Collections.sort() use automatically.
  • equals() checks identity by the id field only. Two transactions with the same ID are considered the same transaction, even if other fields differ.
  • hashCode() must use the same field as equals(). If two objects are "equal", they must have the same hash code. Breaking this contract causes bugs in HashMap and HashSet.
  • toString() uses StringBuilder to build a formatted string. StringBuilder is more efficient than string concatenation with + because Java strings are immutable. Every + creates a new string in memory.
  • Save Transaction.java.
  • The red underline on the class name should be gone now. IntelliJ recognizes that you've fulfilled the Comparable contract.

Still seeing red underlines?

  • Make sure the four methods (compareTo, equals, hashCode, toString) are inside the class braces, not after the closing brace.
  • Check that the import java.util.Objects is at the top of the file. Without it, Objects.equals() and Objects.hash() won't resolve.
  • help me fix the compilation error in my Transaction class

Awesome, I've got everything!

Double check you've saved your file and there are no red underlines.

I'd like to double check the full code

package model;

import java.time.LocalDate;
import java.util.Objects;

public class Transaction implements Comparable<Transaction> {
    private final String id;
    private final String description;
    private final double amount;
    private final Category category;
    private final LocalDate date;
    private final boolean income;

    public Transaction(String id, String description, double amount, Category category, LocalDate date, boolean income) {
        this.id = id;
        this.description = description;
        this.amount = amount;
        this.category = category;
        this.date = date;
        this.income = income;
    }

    public String getId() { return id; }
    public String getDescription() { return description; }
    public double getAmount() { return amount; }
    public Category getCategory() { return category; }
    public LocalDate getDate() { return date; }
    public boolean isIncome() { return income; }

    @Override
    public int compareTo(Transaction other) {
        return this.date.compareTo(other.date);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Transaction that = (Transaction) o;
        return Objects.equals(id, that.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        sb.append(date).append(" | ");
        sb.append(income ? "+" : "-");
        sb.append(String.format("$%.2f", amount)).append(" | ");
        sb.append(category.getDisplayName()).append(" | ");
        sb.append(description);
        return sb.toString();
    }
}
Create the TransactionBuilder class

Creating a Transaction requires six parameters in the constructor. The Builder design pattern solves this by letting you set fields one at a time through a fluent API.

  • Right-click the model package in the Project panel.
  • Select New > Java Class.
  • Enter TransactionBuilder as the name and leave the kind as Class.
  • Replace the generated code with the following:
package model;

import java.time.LocalDate;
import java.util.UUID;

// Builder pattern: provides a fluent API for creating Transaction objects
public class TransactionBuilder {
    private String id;
    private String description;
    private double amount;
    private Category category;
    private LocalDate date;
    private boolean income;

    // Constructor sets sensible defaults so callers only set what they need
    public TransactionBuilder() {
        this.id = UUID.randomUUID().toString().substring(0, 8);
        this.date = LocalDate.now();
        this.income = false;
        this.category = Category.OTHER;
    }

    // Each setter returns 'this' so you can chain calls together
    public TransactionBuilder id(String id) {
        this.id = id;
        return this;
    }

    public TransactionBuilder description(String description) {
        this.description = description;
        return this;
    }

    public TransactionBuilder amount(double amount) {
        this.amount = amount;
        return this;
    }

    public TransactionBuilder category(Category category) {
        this.category = category;
        return this;
    }

    public TransactionBuilder date(LocalDate date) {
        this.date = date;
        return this;
    }

    public TransactionBuilder income(boolean income) {
        this.income = income;
        return this;
    }

    // Build creates the final immutable Transaction object
    public Transaction build() {
        return new Transaction(id, description, amount, category, date, income);
    }
}

What does this code do?

  • The constructor generates a unique 8-character ID using UUID.randomUUID() and defaults the date to today. You only need to set the fields that differ from the defaults.
  • Every setter method returns this, which is what enables method chaining like new TransactionBuilder().description("Lunch").amount(12.50).build().
  • The build() method creates and returns the final Transaction object. In the next step, you'll add input validation here so that invalid data gets caught before a transaction is created.
  • Save TransactionBuilder.java.
  • Click the green play button next to main() in ExpenseTrackerApp.java to run the project.
  • You should see Expense Tracker starting... in the console with no errors. This confirms all three model classes compile correctly.

Seeing a compilation error when you run?

  • Check that all three files (Category.java, Transaction.java, TransactionBuilder.java) are inside the model package and start with package model;.
  • If IntelliJ shows "cannot find symbol" for Category, make sure Category.java is saved and has no errors of its own.
  • help me fix the compilation error in my model classes

Awesome, I've got everything!

Double check you've saved all three files and the project runs without errors.

I'd like to double check the full code

package model;

import java.time.LocalDate;
import java.util.UUID;

public class TransactionBuilder {
    private String id;
    private String description;
    private double amount;
    private Category category;
    private LocalDate date;
    private boolean income;

    public TransactionBuilder() {
        this.id = UUID.randomUUID().toString().substring(0, 8);
        this.date = LocalDate.now();
        this.income = false;
        this.category = Category.OTHER;
    }

    public TransactionBuilder id(String id) {
        this.id = id;
        return this;
    }

    public TransactionBuilder description(String description) {
        this.description = description;
        return this;
    }

    public TransactionBuilder amount(double amount) {
        this.amount = amount;
        return this;
    }

    public TransactionBuilder category(Category category) {
        this.category = category;
        return this;
    }

    public TransactionBuilder date(LocalDate date) {
        this.date = date;
        return this;
    }

    public TransactionBuilder income(boolean income) {
        this.income = income;
        return this;
    }

    public Transaction build() {
        return new Transaction(id, description, amount, category, date, income);
    }
}

Your data model is solid. Next up, you'll add custom exceptions and input validation so the app rejects bad data before it ever reaches these classes.

Create Custom Exceptions and Input Validation

In the previous step, you built the data model with a Category enum, a Transaction class, and a TransactionBuilder. But right now, nothing stops someone from creating a transaction with a negative amount or a blank description.

In this step, you'll create custom exception classes and a validation utility that catches bad input before it reaches your data. This is one of the most common Java interview topics: the difference between checked and unchecked exceptions.

In this step, get ready to:
  • Create custom exception classes for invalid amounts, categories, and storage errors.
  • Build a reusable InputValidator utility class with static validation methods.
  • Wire validation into the TransactionBuilder so every transaction is validated at creation time.
Create the custom exception classes

Java exceptions fall into two categories. Unchecked exceptions extend RuntimeException (or one of its subclasses) and don't force callers to handle them. Checked exceptions extend Exception (or one of its subclasses like IOException) and force callers to use try-catch or declare throws.

You'll create two unchecked exceptions for input validation and one checked exception for file storage errors.

  • In IntelliJ, right-click the exception package in the Project panel on the left.
  • Select New, then Java Class.
  • Name it InvalidAmountException and press Enter.
  • Replace the file contents with this code:
package exception;

// Unchecked exception for invalid transaction amounts
public class InvalidAmountException extends IllegalArgumentException {
    public InvalidAmountException(String message) {
        super(message);
    }
}
  • Right-click the exception package again and create a second Java class named InvalidCategoryException.
  • Replace the file contents with this code:
package exception;

// Unchecked exception for unrecognized spending categories
public class InvalidCategoryException extends IllegalArgumentException {
    public InvalidCategoryException(String message) {
        super(message);
    }
}

What do these two exceptions have in common?

Both extend IllegalArgumentException, which is a subclass of RuntimeException. That makes them unchecked exceptions.

Unchecked exceptions represent programming mistakes (like passing a negative amount). The compiler doesn't force you to catch them because the fix is to correct the input, not handle the error at runtime.

  • Create one more Java class in the exception package named DataStorageException.
  • Replace the file contents with this code:
package exception;

import java.io.IOException;

// Checked exception for file I/O failures during data persistence
public class DataStorageException extends IOException {
    public DataStorageException(String message) {
        super(message);
    }

    // Overloaded constructor to preserve the original cause of the error
    public DataStorageException(String message, Throwable cause) {
        super(message, cause);
    }
}

Why is DataStorageException different?

This one extends IOException, which makes it a checked exception. The compiler forces any code that calls a method throwing DataStorageException to either catch it or declare throws in its own method signature.

File I/O errors (disk full, file locked, permissions denied) are unpredictable. Checked exceptions force developers to plan for these failures instead of ignoring them.

The second constructor accepts a Throwable cause parameter so you can wrap the original IOException inside your custom exception. This preserves the full error chain for debugging.

  • Save all three files by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Check the Project panel on the left. You should see all three files listed under the exception package with no red error indicators.

Seeing red errors in the exception files?

  • Make sure the package exception; line at the top of each file matches the package name exactly. It is case-sensitive.
  • For DataStorageException, confirm the import java.io.IOException; line is present. Without it, the compiler does not know what IOException is.
  • Check that the class names match the file names exactly. InvalidAmountException.java must contain a class named InvalidAmountException.
  • Still stuck? Help me fix red errors in my exception classes.
Create the InputValidator utility class

Your exception classes exist, but nothing throws them yet. You need a central place that validates user input and throws the right exception when something is wrong.

  • Right-click the util package in the Project panel.
  • Select New, then Java Class.
  • Name it InputValidator and press Enter.
  • Replace the file contents with this code:
package util;

import exception.InvalidAmountException;
import exception.InvalidCategoryException;
import model.Category;

public class InputValidator {

    // Reject zero or negative amounts
    public static void validateAmount(double amount) {
        if (amount <= 0) {
            throw new InvalidAmountException("Amount must be greater than zero. Got: " + amount);
        }
    }

    // Reject null or blank descriptions
    public static void validateDescription(String description) {
        if (description == null || description.isBlank()) {
            throw new IllegalArgumentException("Description cannot be null or empty.");
        }
    }

    // Convert a user-typed string into a Category enum value
    public static Category parseCategory(String input) {
        try {
            return Category.fromString(input);
        } catch (IllegalArgumentException e) {
            throw new InvalidCategoryException("Invalid category: '" + input + "'. Valid options: FOOD, TRANSPORT, ENTERTAINMENT, UTILITIES, HEALTH, SHOPPING, OTHER");
        }
    }
}

What does each method do?

  • validateAmount() checks if the amount is zero or negative. If it is, it throws your custom InvalidAmountException.
  • validateDescription() checks if the description is null or blank. The isBlank() method catches strings that are just whitespace.
  • parseCategory() wraps the Category.fromString() call from your enum. If the user types a category that doesn't exist, it catches the generic IllegalArgumentException and re-throws it as your more specific InvalidCategoryException with a helpful message listing valid options.

All three methods are static because they are pure utility functions. You call them directly on the class without creating an instance.

  • Save InputValidator.java.
  • Verify the file has no red error indicators. The imports for InvalidAmountException, InvalidCategoryException, and Category should all resolve to the classes you created earlier.

Seeing unresolved import errors?

  • Make sure your exception classes are in the exception package and your Category enum is in the model package. The import paths must match the package names exactly.
  • If IntelliJ shows a red underline on an import, hover over it to see the error message. A common cause is a typo in the class name or package name.
  • Try Build > Rebuild Project from the top menu bar to force IntelliJ to re-index everything.
  • Still stuck? Help me fix import errors in InputValidator.

Awesome, I've got everything!

Great. Double check you've saved InputValidator.java before moving on.

I'd like to double check the full code

package util;

import exception.InvalidAmountException;
import exception.InvalidCategoryException;
import model.Category;

public class InputValidator {

    public static void validateAmount(double amount) {
        if (amount <= 0) {
            throw new InvalidAmountException("Amount must be greater than zero. Got: " + amount);
        }
    }

    public static void validateDescription(String description) {
        if (description == null || description.isBlank()) {
            throw new IllegalArgumentException("Description cannot be null or empty.");
        }
    }

    public static Category parseCategory(String input) {
        try {
            return Category.fromString(input);
        } catch (IllegalArgumentException e) {
            throw new InvalidCategoryException("Invalid category: '" + input + "'. Valid options: FOOD, TRANSPORT, ENTERTAINMENT, UTILITIES, HEALTH, SHOPPING, OTHER");
        }
    }
}
Wire validation into the TransactionBuilder

Your validator methods are ready, but they are not called anywhere yet. The best place to enforce validation is inside the TransactionBuilder.build() method. This guarantees that every transaction goes through validation before it is created.

  • Open TransactionBuilder.java in the model package.
  • Add this import at the top of the file, below the existing package model; line:
import util.InputValidator;
  • Scroll down to the build() method at the bottom of the class.
  • Find this line:
    public Transaction build() {
        return new Transaction(id, description, amount, category, date, income);
    }
  • Replace the entire build() method with this updated version that validates before creating:
    public Transaction build() {
        // Validate inputs before creating the transaction
        InputValidator.validateDescription(description);
        InputValidator.validateAmount(amount);
        return new Transaction(id, description, amount, category, date, income);
    }

Why validate inside build()?

The Builder pattern gives you a single chokepoint for creating Transaction objects. By putting validation in build(), you guarantee that no invalid transaction can ever be constructed, no matter where in the app someone calls the builder.

If the description is blank, validateDescription() throws an IllegalArgumentException. If the amount is zero or negative, validateAmount() throws your custom InvalidAmountException. The transaction is only created if both checks pass.

  • Save TransactionBuilder.java.
  • Build the project by clicking Build > Build Project in the top menu bar, or press Cmd+F9 (macOS) or Ctrl+F9 (Windows).
  • Check the Build output panel at the bottom. You should see Build completed successfully with no errors.

Build failing with errors?

  • If you see cannot find symbol: InputValidator, make sure you added the import util.InputValidator; line at the top of TransactionBuilder.java.
  • If you see cannot find symbol: validateDescription, double check that your InputValidator class has the exact method name validateDescription (not validateDesc or another variation).
  • Still stuck? Help me fix my TransactionBuilder build errors.

Awesome, I've got everything!

Great. Double check you've saved TransactionBuilder.java before moving on.

I'd like to double check the full code

package model;

import util.InputValidator;
import java.time.LocalDate;
import java.util.UUID;

public class TransactionBuilder {
    private String id;
    private String description;
    private double amount;
    private Category category;
    private LocalDate date;
    private boolean income;

    public TransactionBuilder() {
        this.id = UUID.randomUUID().toString().substring(0, 8);
        this.date = LocalDate.now();
        this.income = false;
        this.category = Category.OTHER;
    }

    public TransactionBuilder id(String id) {
        this.id = id;
        return this;
    }

    public TransactionBuilder description(String description) {
        this.description = description;
        return this;
    }

    public TransactionBuilder amount(double amount) {
        this.amount = amount;
        return this;
    }

    public TransactionBuilder category(Category category) {
        this.category = category;
        return this;
    }

    public TransactionBuilder date(LocalDate date) {
        this.date = date;
        return this;
    }

    public TransactionBuilder income(boolean income) {
        this.income = income;
        return this;
    }

    public Transaction build() {
        InputValidator.validateDescription(description);
        InputValidator.validateAmount(amount);
        return new Transaction(id, description, amount, category, date, income);
    }
}

Your expense tracker now rejects bad data before it ever becomes a transaction. Next up, you'll build the data store using Collections and Generics to hold all your validated transactions in memory.

Build the Data Store with Collections and Generics

Your transaction model is validated and protected by custom exceptions. Now you need somewhere to actually store those transactions in memory so the rest of the app can access them.

In this step, you'll build the central data store using Java's Collections Framework and add a generic sorting utility that works with any comparable type.

In this step, get ready to:
  • Create the ExpenseRepository Singleton class with ArrayList and HashMap storage.
  • Add CRUD methods for adding, removing, and retrieving transactions.
  • Build a generic SortUtil class with Comparable and Comparator support.
Create the ExpenseRepository Singleton

Your expense tracker needs a single, central place to hold all transactions in memory. If different parts of the app could each create their own repository, data would be scattered and out of sync.

The Singleton pattern guarantees that only one instance of a class exists throughout the entire application. Every part of the code that calls getInstance() gets back the same object.

  • In the service package, create a new class called ExpenseRepository and add the following code:
package service;

import model.Category;
import model.Transaction;

import java.util.*;

public class ExpenseRepository {
    // Singleton: only one instance of the repository can exist
    private static ExpenseRepository instance;

    // Primary storage: all transactions in insertion order
    private final List<Transaction> transactions;
    // Secondary index: fast lookups by category
    private final Map<Category, List<Transaction>> categoryIndex;

    // Private constructor prevents outside instantiation
    private ExpenseRepository() {
        this.transactions = new ArrayList<>();
        this.categoryIndex = new HashMap<>();
    }

    // Thread-safe lazy initialization
    public static synchronized ExpenseRepository getInstance() {
        if (instance == null) {
            instance = new ExpenseRepository();
        }
        return instance;
    }
}

What does this code do?

  • The private constructor prevents any other class from creating an ExpenseRepository using new.
  • getInstance() creates the single instance the first time it is called, then returns that same instance on every subsequent call.
  • ArrayList<Transaction> stores all transactions in insertion order. This is the primary data store.
  • HashMap<Category, List<Transaction>> acts as a secondary index so you can retrieve transactions by category without scanning the entire list.

What is the Singleton pattern?

Singleton is a design pattern that restricts a class to exactly one instance. It is one of the most commonly asked design pattern questions in Java interviews. The three ingredients are: a private constructor, a static field holding the single instance, and a static method to access it.

Why ArrayList and HashMap together?

An ArrayList preserves insertion order and is great for iterating over all transactions. A HashMap provides O(1) average-time lookups by key, making category-based retrieval fast.

Using both means you trade a small amount of extra memory for significantly faster reads. This trade-off is a common topic in collection-related interview questions.

  • Save ExpenseRepository.java.

The file compiles but has no methods to manage data yet. You will add those next.

Add CRUD methods to the repository

CRUD stands for Create, Read, Update, Delete. Your repository needs methods to add transactions, remove them by ID, and retrieve them for display or analysis.

  • In ExpenseRepository.java, add the following addTransaction method inside the class, below the getInstance() method:
public void addTransaction(Transaction transaction) {
    transactions.add(transaction);
    // computeIfAbsent creates the list if this category has no entries yet
    categoryIndex
        .computeIfAbsent(transaction.getCategory(), k -> new ArrayList<>())
        .add(transaction);
}

What does this code do?

  • transactions.add(transaction) appends the transaction to the main list.
  • computeIfAbsent() checks if the HashMap already has a list for this category. If not, it creates one using the lambda k -> new ArrayList<>(). Then it adds the transaction to that category's list.
  • This keeps both data structures in sync with a single method call.
  • Below addTransaction, add the removeTransaction method:
public boolean removeTransaction(String id) {
    // Find the transaction by ID
    Transaction toRemove = null;
    for (Transaction t : transactions) {
        if (t.getId().equals(id)) {
            toRemove = t;
            break;
        }
    }
    // Remove from both data structures if found
    if (toRemove != null) {
        transactions.remove(toRemove);
        List<Transaction> catList = categoryIndex.get(toRemove.getCategory());
        if (catList != null) {
            catList.remove(toRemove);
        }
        return true;
    }
    return false;
}

What does this code do?

  • The method loops through the list to find a transaction whose id matches. This works because your Transaction class overrides equals() and hashCode() based on id (from Step 2).
  • If found, it removes the transaction from both the main list and the category index to keep them in sync.
  • The method returns true if the transaction was found and removed, or false if no match existed.
  • Below removeTransaction, add the two read methods that retrieve transactions:
public List<Transaction> getAllTransactions() {
    // Return an unmodifiable copy to prevent external modification
    return Collections.unmodifiableList(new ArrayList<>(transactions));
}

public List<Transaction> getByCategory(Category category) {
    // Return an unmodifiable list, defaulting to empty if no matches
    return Collections.unmodifiableList(
        categoryIndex.getOrDefault(category, Collections.emptyList())
    );
}

Why return an unmodifiable list?

If getAllTransactions() returned the raw transactions list directly, any code that received it could add or remove items, bypassing your repository's methods. That would break the category index.

Collections.unmodifiableList() wraps the list so any attempt to modify it throws an UnsupportedOperationException. This is called defensive copying, a key encapsulation technique in Java interviews.

  • Save ExpenseRepository.java.
  • To verify it compiles, open ExpenseTrackerApp.java and add these lines inside main(), below the existing print statement:
// Quick test: get the singleton instance
ExpenseRepository repo = ExpenseRepository.getInstance();
System.out.println("Repository ready. Transactions: " + repo.getAllTransactions().size());
  • Add import service.ExpenseRepository; at the top of ExpenseTrackerApp.java.
  • Run the app. You should see Repository ready. Transactions: 0 in the console output.

Seeing a compile error?

  • Check that the package service; declaration is on the first line of ExpenseRepository.java and the file is inside the service folder under src.
  • Make sure you have import java.util.*; at the top. This import covers ArrayList, HashMap, Collections, and the other collection classes you used.
  • Verify that your Transaction and Category classes from Step 2 compile without errors.

My ExpenseRepository won't compile. Can you help me debug it?

Awesome, I've got everything!

Great. Double check you have saved ExpenseRepository.java before moving on.

I'd like to double check the full code

package service;

import model.Category;
import model.Transaction;

import java.util.*;

public class ExpenseRepository {
    // Singleton: only one instance of the repository can exist
    private static ExpenseRepository instance;

    // Primary storage: all transactions in insertion order
    private final List<Transaction> transactions;
    // Secondary index: fast lookups by category
    private final Map<Category, List<Transaction>> categoryIndex;

    // Private constructor prevents outside instantiation
    private ExpenseRepository() {
        this.transactions = new ArrayList<>();
        this.categoryIndex = new HashMap<>();
    }

    // Thread-safe lazy initialization
    public static synchronized ExpenseRepository getInstance() {
        if (instance == null) {
            instance = new ExpenseRepository();
        }
        return instance;
    }

    public void addTransaction(Transaction transaction) {
        transactions.add(transaction);
        // computeIfAbsent creates the list if this category has no entries yet
        categoryIndex
            .computeIfAbsent(transaction.getCategory(), k -> new ArrayList<>())
            .add(transaction);
    }

    public boolean removeTransaction(String id) {
        // Find the transaction by ID
        Transaction toRemove = null;
        for (Transaction t : transactions) {
            if (t.getId().equals(id)) {
                toRemove = t;
                break;
            }
        }
        // Remove from both data structures if found
        if (toRemove != null) {
            transactions.remove(toRemove);
            List<Transaction> catList = categoryIndex.get(toRemove.getCategory());
            if (catList != null) {
                catList.remove(toRemove);
            }
            return true;
        }
        return false;
    }

    public List<Transaction> getAllTransactions() {
        // Return an unmodifiable copy to prevent external modification
        return Collections.unmodifiableList(new ArrayList<>(transactions));
    }

    public List<Transaction> getByCategory(Category category) {
        // Return an unmodifiable list, defaulting to empty if no matches
        return Collections.unmodifiableList(
            categoryIndex.getOrDefault(category, Collections.emptyList())
        );
    }
}
Create the generic SortUtil class

Your repository can store and retrieve transactions, but it cannot sort them. You need a way to sort transactions by date, amount, or category depending on what the user wants to see.

Java offers two interfaces for sorting. Comparable defines the natural ordering built into a class (your Transaction already sorts by date). Comparator defines an external, custom sort order that you can swap in on the fly.

  • In the util package, create a new class called SortUtil and add the following code:
package util;

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;

public class SortUtil {

    // Sort using natural ordering (Comparable)
    public static <T extends Comparable<T>> List<T> sort(List<T> list) {
        List<T> sorted = new ArrayList<>(list);
        Collections.sort(sorted);
        return sorted;
    }

    // Sort using a custom Comparator
    public static <T> List<T> sort(List<T> list, Comparator<T> comparator) {
        List<T> sorted = new ArrayList<>(list);
        sorted.sort(comparator);
        return sorted;
    }
}

What does this code do?

  • <T extends Comparable<T>> is a bounded type parameter. It means this method works with any type T as long as that type implements Comparable. Your Transaction class qualifies because it implements Comparable<Transaction>.
  • Both methods create a copy of the input list before sorting, so the original list is never modified.
  • The second method accepts a Comparator<T> parameter. This lets you sort by amount, category, or any custom criteria without changing the Transaction class itself.
  • Having two methods with the same name but different parameters is called method overloading.

What are Generics?

Generics let you write code that works with any data type while still catching type errors at compile time. Without generics, you would need separate sort methods for transactions, strings, numbers, and every other type.

The <T> syntax declares a type parameter. When you call SortUtil.sort(myTransactionList), Java automatically infers that T is Transaction.

Comparable vs Comparator: when to use each?

  • Use Comparable for the one "default" sort order that makes the most sense for a class (e.g. transactions sorted by date).
  • Use Comparator for every alternative sort order (e.g. by amount or by category).
  • You can also create Comparators on the fly with lambdas, like Comparator.comparing(Transaction::getAmount). You will use this pattern when you build the console menu later.
  • Save SortUtil.java.
  • To verify it works, add the following test lines to the bottom of your main() method in ExpenseTrackerApp.java:
// Quick test: add a transaction and verify sorting
Transaction lunch = new TransactionBuilder()
    .description("Lunch")
    .amount(12.50)
    .category(Category.FOOD)
    .build();
repo.addTransaction(lunch);
System.out.println("Added: " + lunch);
System.out.println("Sorted transactions: " + SortUtil.sort(repo.getAllTransactions()));
  • Add the necessary imports at the top of ExpenseTrackerApp.java:
import model.Category;
import model.Transaction;
import model.TransactionBuilder;
import util.SortUtil;
  • Run the app. You should see the repository message, the added transaction details, and the sorted list printed to the console.

Seeing a ClassCastException or compile error?

  • If you see a compile error about Comparable, make sure your Transaction class from Step 2 includes implements Comparable<Transaction> in its class declaration and has a compareTo() method.
  • If the import for SortUtil cannot be resolved, verify the file is saved inside the util package folder and has package util; as its first line.

My SortUtil or ExpenseRepository test isn't working. Can you help me debug?

Awesome, I've got everything!

Great. Double check you have saved SortUtil.java before moving on.

I'd like to double check the full code

package util;

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;

public class SortUtil {

    public static <T extends Comparable<T>> List<T> sort(List<T> list) {
        List<T> sorted = new ArrayList<>(list);
        Collections.sort(sorted);
        return sorted;
    }

    public static <T> List<T> sort(List<T> list, Comparator<T> comparator) {
        List<T> sorted = new ArrayList<>(list);
        sorted.sort(comparator);
        return sorted;
    }
}

Pro tip: clean up your test code

The test lines you added to ExpenseTrackerApp.java were just to verify your new classes work. You can delete them now. In the final step, you will build a proper console menu that handles all of this.

Your data layer is locked in. You have a Singleton repository backed by an ArrayList and a HashMap, with a generic sorting utility that handles both natural and custom ordering. Next up, you will put these collections to work by building an analytics engine with the Streams API.

Build the Analytics Engine with Streams API

In the previous step, you built a data store powered by HashMap and ArrayList to hold and organize transactions. The data is stored, indexed, and ready to query.

Now it is time to make that data useful. The Streams API lets you filter, transform, and aggregate collections in a declarative style. In this step, you will build an analytics engine that calculates totals, groups spending by category and month, and prints a formatted report to the console.

In this step, get ready to:
  • Create an AnalyticsEngine class with methods for calculating income, expense, and balance totals.
  • Add grouping and aggregation methods for spending breakdowns by category and month.
  • Build a formatted report that combines all analytics into a single console output.
Create the AnalyticsEngine class

Every method in this class receives a list of transactions and produces a result by chaining stream operations. The pattern is always the same: convert the list to a stream, filter for the transactions you care about, then reduce them to a number.

  • In the service package, create a new class called AnalyticsEngine.
  • Add the following code to set up the class with three methods for calculating totals:
package service;

import model.Category;
import model.Transaction;

import java.time.YearMonth;
import java.util.*;
import java.util.stream.Collectors;

public class AnalyticsEngine {

    // Filter out income, keep only expenses, and sum their amounts
    public double getTotalExpenses(List<Transaction> transactions) {
        return transactions.stream()
            .filter(t -> !t.isIncome())
            .mapToDouble(Transaction::getAmount)
            .sum();
    }

    // Filter for income transactions only and sum their amounts
    public double getTotalIncome(List<Transaction> transactions) {
        return transactions.stream()
            .filter(Transaction::isIncome)
            .mapToDouble(Transaction::getAmount)
            .sum();
    }

    // Balance is income minus expenses
    public double getBalance(List<Transaction> transactions) {
        return getTotalIncome(transactions) - getTotalExpenses(transactions);
    }
}

What does this code do?

  • .stream() converts the list into a stream, which is a sequence of elements you can process with chained operations.
  • .filter(t -> !t.isIncome()) keeps only the elements that match the condition. The arrow syntax t -> !t.isIncome() is a lambda expression that tests each transaction.
  • .mapToDouble(Transaction::getAmount) transforms each transaction into its amount. Transaction::getAmount is a method reference, which is shorthand for t -> t.getAmount().
  • .sum() is a terminal operation that adds up all the doubles and returns the total.

What is a method reference?

Notice that getTotalIncome uses Transaction::isIncome while getTotalExpenses uses t -> !t.isIncome(). A method reference like Transaction::isIncome works when you can pass the method directly without modifying its result. When you need to negate or transform the result, you need a full lambda expression.

  • Save AnalyticsEngine.java.
  • Check that IntelliJ shows no red underlines in the file. All three methods should compile without errors.

Seeing red underlines on the import statements?

  • Make sure the file is inside the service package and the package service; declaration at the top matches.
  • Verify that your Transaction class in the model package has an isIncome() getter and a getAmount() getter. The method references will fail if these methods do not exist.
  • Help me fix red underlines in my AnalyticsEngine class
Add grouping and aggregation methods

Totals are useful, but the real insights come from breaking spending down by category and by month. The Collectors.groupingBy() collector is one of the most commonly asked Streams questions in Java interviews.

  • In AnalyticsEngine.java, add the following method below getBalance():
    // Group all expenses by their category and sum the amounts per category
    public Map<Category, Double> getSpendingByCategory(List<Transaction> transactions) {
        return transactions.stream()
            .filter(t -> !t.isIncome())
            .collect(Collectors.groupingBy(
                Transaction::getCategory,
                Collectors.summingDouble(Transaction::getAmount)
            ));
    }

What does this code do?

  • Collectors.groupingBy(Transaction::getCategory, ...) splits the stream into groups based on each transaction's category. This is similar to a SQL GROUP BY.
  • The second argument, Collectors.summingDouble(Transaction::getAmount), is a downstream collector. Instead of collecting each group into a list, it sums the amounts within each group.
  • The result is a Map<Category, Double> where each key is a category and each value is the total spent in that category.
  • Save AnalyticsEngine.java and confirm IntelliJ shows no red underlines on the new method.

Red underline on Collectors or Map?

  • Check that your imports at the top of the file include java.util.stream.Collectors and java.util.*. Both are needed for this method.
  • Make sure the return type is Map<Category, Double> with a capital D. Java autoboxes the double primitive to Double objects for the map.
  • Help me fix my getSpendingByCategory method

The next method groups spending by month. It uses a three-argument version of groupingBy that lets you control the type of map that is created.

  • Add this method below getSpendingByCategory():
    // Group expenses by month, using TreeMap so months appear in chronological order
    public Map<YearMonth, Double> getMonthlySpending(List<Transaction> transactions) {
        return transactions.stream()
            .filter(t -> !t.isIncome())
            .collect(Collectors.groupingBy(
                t -> YearMonth.from(t.getDate()),
                TreeMap::new,
                Collectors.summingDouble(Transaction::getAmount)
            ));
    }

What's happening here?

  • t -> YearMonth.from(t.getDate()) extracts the year and month from each transaction's date. This is the classifier function that determines which group each transaction belongs to.
  • TreeMap::new is a map factory. A regular HashMap does not guarantee any ordering. By supplying TreeMap::new, the result is a TreeMap that sorts months in chronological order automatically.
  • The third argument is the same downstream collector as before: Collectors.summingDouble() sums the amounts within each monthly group.
  • Save AnalyticsEngine.java and confirm no red underlines appear on the new method.

The last analytics method finds the largest expenses. It uses sorted() with a reversed comparator and limit() to return just the top N results.

  • Add this method below getMonthlySpending():
    // Find the top N largest expenses, sorted from highest amount to lowest
    public List<Transaction> getTopExpenses(List<Transaction> transactions, int n) {
        return transactions.stream()
            .filter(t -> !t.isIncome())
            .sorted(Comparator.comparingDouble(Transaction::getAmount).reversed())
            .limit(n)
            .collect(Collectors.toList());
    }

What does this code do?

  • Comparator.comparingDouble(Transaction::getAmount) creates a comparator that sorts by amount in ascending order.
  • .reversed() flips it to descending order so the largest expenses come first.
  • .limit(n) truncates the stream to only the first n elements. Combined with the reversed sort, this gives you the top N largest expenses.
  • .collect(Collectors.toList()) gathers the stream results back into a regular List.
  • Save AnalyticsEngine.java and confirm IntelliJ shows no red underlines anywhere in the file.

Seeing an error on sorted() or reversed()?

  • Make sure you are using Comparator.comparingDouble() (not Comparator.comparing()). The comparingDouble variant avoids autoboxing overhead for double values.
  • Check that .reversed() is chained directly after comparingDouble(...) with no semicolon between them.
  • Help me fix my getTopExpenses method
Add the formatted report method

The individual analytics methods are useful on their own, but a single report that pulls everything together is much more practical. The generateReport() method calls each analytics method and prints the results in a clean, formatted layout using String.format().

  • Add this method below getTopExpenses() in AnalyticsEngine.java:
    // Combine all analytics into a single formatted console report
    public void generateReport(List<Transaction> transactions) {
        double totalIncome = getTotalIncome(transactions);
        double totalExpenses = getTotalExpenses(transactions);
        double balance = totalIncome - totalExpenses;

        // Print the summary header
        System.out.println("\n====== EXPENSE TRACKER REPORT ======");
        System.out.printf("Total Income:   $%.2f%n", totalIncome);
        System.out.printf("Total Expenses: $%.2f%n", totalExpenses);
        System.out.printf("Balance:        $%.2f%n", balance);

        // Print spending breakdown by category with percentages
        System.out.println("\n--- Spending by Category ---");
        Map<Category, Double> byCategory = getSpendingByCategory(transactions);
        byCategory.forEach((category, amount) -> {
            double percentage = (totalExpenses > 0) ? (amount / totalExpenses) * 100 : 0;
            System.out.printf("  %-15s $%.2f (%.1f%%)%n", category.getDisplayName(), amount, percentage);
        });

        // Print monthly spending trend in chronological order
        System.out.println("\n--- Monthly Spending Trend ---");
        Map<YearMonth, Double> monthly = getMonthlySpending(transactions);
        monthly.forEach((month, amount) ->
            System.out.printf("  %s: $%.2f%n", month, amount)
        );

        // Print the top 5 largest expenses
        System.out.println("\n--- Top 5 Expenses ---");
        List<Transaction> topExpenses = getTopExpenses(transactions, 5);
        for (int i = 0; i < topExpenses.size(); i++) {
            Transaction t = topExpenses.get(i);
            System.out.printf("  %d. %s%n", i + 1, t);
        }
        System.out.println("===================================="+ "\n");
    }

What does this code do?

  • System.out.printf() uses format specifiers to produce clean, aligned output. %.2f formats a double to two decimal places. %-15s left-aligns a string in a 15-character-wide column.
  • The percentage calculation (amount / totalExpenses) * 100 shows each category's share of total spending. The ternary check totalExpenses > 0 prevents a divide-by-zero error when there are no expenses.
  • .forEach() iterates over each entry in the map. The lambda receives both the key and value as parameters.
  • The top expenses section uses a traditional for loop instead of forEach because it needs the index i for numbering each expense.
  • Save AnalyticsEngine.java.
  • Confirm that IntelliJ shows no red underlines anywhere in the file. You should have seven methods in total: getTotalExpenses, getTotalIncome, getBalance, getSpendingByCategory, getMonthlySpending, getTopExpenses, and generateReport.

Seeing errors in generateReport()?

  • Double-check that the escape sequences in the format strings are correct. Use %n for newlines inside printf (not \n). Use %% to print a literal percent sign.
  • If you see an error on Map<Category, Double> or Map<YearMonth, Double>, verify the return types of getSpendingByCategory() and getMonthlySpending() match exactly.
  • Help me debug my generateReport method

Awesome, I've got everything!

Double check you have saved AnalyticsEngine.java before moving on.

I'd like to double check the full code

package service;

import model.Category;
import model.Transaction;

import java.time.YearMonth;
import java.util.*;
import java.util.stream.Collectors;

public class AnalyticsEngine {

    public double getTotalExpenses(List<Transaction> transactions) {
        return transactions.stream()
            .filter(t -> !t.isIncome())
            .mapToDouble(Transaction::getAmount)
            .sum();
    }

    public double getTotalIncome(List<Transaction> transactions) {
        return transactions.stream()
            .filter(Transaction::isIncome)
            .mapToDouble(Transaction::getAmount)
            .sum();
    }

    public double getBalance(List<Transaction> transactions) {
        return getTotalIncome(transactions) - getTotalExpenses(transactions);
    }

    public Map<Category, Double> getSpendingByCategory(List<Transaction> transactions) {
        return transactions.stream()
            .filter(t -> !t.isIncome())
            .collect(Collectors.groupingBy(
                Transaction::getCategory,
                Collectors.summingDouble(Transaction::getAmount)
            ));
    }

    public Map<YearMonth, Double> getMonthlySpending(List<Transaction> transactions) {
        return transactions.stream()
            .filter(t -> !t.isIncome())
            .collect(Collectors.groupingBy(
                t -> YearMonth.from(t.getDate()),
                TreeMap::new,
                Collectors.summingDouble(Transaction::getAmount)
            ));
    }

    public List<Transaction> getTopExpenses(List<Transaction> transactions, int n) {
        return transactions.stream()
            .filter(t -> !t.isIncome())
            .sorted(Comparator.comparingDouble(Transaction::getAmount).reversed())
            .limit(n)
            .collect(Collectors.toList());
    }

    public void generateReport(List<Transaction> transactions) {
        double totalIncome = getTotalIncome(transactions);
        double totalExpenses = getTotalExpenses(transactions);
        double balance = totalIncome - totalExpenses;

        System.out.println("\n====== EXPENSE TRACKER REPORT ======");
        System.out.printf("Total Income:   $%.2f%n", totalIncome);
        System.out.printf("Total Expenses: $%.2f%n", totalExpenses);
        System.out.printf("Balance:        $%.2f%n", balance);

        System.out.println("\n--- Spending by Category ---");
        Map<Category, Double> byCategory = getSpendingByCategory(transactions);
        byCategory.forEach((category, amount) -> {
            double percentage = (totalExpenses > 0) ? (amount / totalExpenses) * 100 : 0;
            System.out.printf("  %-15s $%.2f (%.1f%%)%n", category.getDisplayName(), amount, percentage);
        });

        System.out.println("\n--- Monthly Spending Trend ---");
        Map<YearMonth, Double> monthly = getMonthlySpending(transactions);
        monthly.forEach((month, amount) ->
            System.out.printf("  %s: $%.2f%n", month, amount)
        );

        System.out.println("\n--- Top 5 Expenses ---");
        List<Transaction> topExpenses = getTopExpenses(transactions, 5);
        for (int i = 0; i < topExpenses.size(); i++) {
            Transaction t = topExpenses.get(i);
            System.out.printf("  %d. %s%n", i + 1, t);
        }
        System.out.println("====================================\n");
    }
}

Your analytics engine is complete. It can calculate totals, break spending down by category and month, find the biggest expenses, and print it all in one report. Next up, you will build the file persistence layer so your transaction data survives between sessions.

Add File Persistence with CSV I/O

Your analytics engine can crunch numbers across every transaction in memory. There is one problem: all your data vanishes the moment you close the application.

In this step, you'll add file persistence using CSV files. Java's BufferedWriter and BufferedReader classes handle efficient character I/O, and try-with-resources ensures files are always closed properly.

In this step, get ready to:
  • Write a save method that exports transactions to a CSV file.
  • Write a load method that reads transactions back from CSV on startup.
  • Wire file persistence into the repository and main app so data survives between sessions.
Create the CsvFileHandler class with save logic

Saving data to a file requires writing each transaction as a line of text. CSV (comma-separated values) is one of the simplest file formats for structured data.

  • Right-click the storage package in the Project sidebar.
  • Select New then Java Class.
  • Name it CsvFileHandler and press Enter.
  • Replace the file contents with the following code:
package storage;

import exception.DataStorageException;
import model.Category;
import model.Transaction;
import model.TransactionBuilder;

import java.io.*;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;

public class CsvFileHandler {
    // CSV header row defining the column order
    private static final String HEADER = "id,description,amount,category,date,isIncome";

    public static void saveTransactions(List<Transaction> transactions, String filePath) throws DataStorageException {
        // BufferedWriter wraps FileWriter for efficient character output
        // try-with-resources ensures the writer is closed automatically
        try (BufferedWriter writer = new BufferedWriter(new FileWriter(filePath))) {
            writer.write(HEADER);
            writer.newLine();
            // Write each transaction as a CSV row
            for (Transaction t : transactions) {
                String line = String.join(",",
                    t.getId(),
                    "\"" + t.getDescription() + "\"",  // Wrap in quotes to handle commas
                    String.valueOf(t.getAmount()),
                    t.getCategory().name(),
                    t.getDate().toString(),
                    String.valueOf(t.isIncome())
                );
                writer.write(line);
                writer.newLine();
            }
        } catch (IOException e) {
            // Wrap the low-level IOException in our custom checked exception
            throw new DataStorageException("Failed to save transactions to " + filePath, e);
        }
    }
}

What does this code do?

  • BufferedWriter wraps a FileWriter to buffer characters in memory before writing to disk. This is much more efficient than writing one character at a time.
  • The try (BufferedWriter writer = ...) syntax is called try-with-resources. It automatically calls writer.close() when the block finishes, even if an exception is thrown.
  • Descriptions are wrapped in quotes to handle commas inside the text. Without this, a description like "Pizza, soda" would break the CSV column structure.
  • The method throws DataStorageException (your custom checked exception from Step 3) instead of the raw IOException. This gives callers a meaningful error specific to your domain.

Why BufferedWriter instead of Files.newBufferedWriter()?

Modern Java (18+) recommends the java.nio.file.Files API for new code. This project intentionally uses BufferedWriter and FileWriter because these are the classes asked about in Java interviews. Knowing both approaches gives you an edge when discussing tradeoffs.

  • Save CsvFileHandler.java.
  • Check IntelliJ's editor for red underlines. You should see none.

You won't be able to test saving yet. The next substep adds the load method to complete the round trip.

Seeing red underlines on DataStorageException?

  • Make sure you created DataStorageException.java in the exception package back in Step 3. It must extend IOException and have both a single-message and a message-plus-cause constructor.
  • Check that the import exception.DataStorageException; line at the top matches your package name exactly.

Why can't IntelliJ find my DataStorageException class?

Add the load method and CSV parser

Saving is only half the story. You need a load method that reads the CSV file on startup and reconstructs Transaction objects from each line.

  • In CsvFileHandler.java, find the closing brace of the saveTransactions method.
  • Add the following loadTransactions method below it:
    public static List<Transaction> loadTransactions(String filePath) throws DataStorageException {
        List<Transaction> transactions = new ArrayList<>();
        File file = new File(filePath);
        // If no file exists yet, return an empty list instead of crashing
        if (!file.exists()) {
            return transactions;
        }
        // BufferedReader wraps FileReader for efficient character input
        try (BufferedReader reader = new BufferedReader(new FileReader(filePath))) {
            String line = reader.readLine(); // skip the header row
            while ((line = reader.readLine()) != null) {
                try {
                    Transaction t = parseLine(line);
                    transactions.add(t);
                } catch (Exception e) {
                    // Skip malformed lines instead of crashing the whole load
                    System.err.println("Skipping malformed line: " + line);
                }
            }
        } catch (IOException e) {
            throw new DataStorageException("Failed to load transactions from " + filePath, e);
        }
        return transactions;
    }

What does this code do?

  • BufferedReader wraps a FileReader for efficient character input. The readLine() method returns one line at a time, or null when it reaches the end of the file.
  • The first readLine() call discards the header row. The while loop reads every remaining line.
  • The inner try-catch is a defensive programming pattern. If one CSV line is corrupted, the method logs it and keeps loading the rest instead of failing entirely.
  • If the file does not exist at all (first run), the method returns an empty list. No crash, no error.
  • Save CsvFileHandler.java.

IntelliJ will show a red underline on parseLine because you have not created that method yet. You will fix this in the next chunk.

  • Add the following parseLine helper method below loadTransactions:
    private static Transaction parseLine(String line) {
        // Handle quoted descriptions that might contain commas
        String[] parts;
        if (line.contains("\"")) {
            int firstQuote = line.indexOf("\"");
            int lastQuote = line.indexOf("\"", firstQuote + 1);
            String beforeQuote = line.substring(0, firstQuote);
            String quoted = line.substring(firstQuote + 1, lastQuote);
            String afterQuote = line.substring(lastQuote + 1);

            String[] beforeParts = beforeQuote.isEmpty() ? new String[]{} : beforeQuote.split(",");
            String[] afterParts = afterQuote.isEmpty() ? new String[]{} : afterQuote.substring(1).split(",");

            // Reassemble the parts with the quoted description intact
            List<String> allParts = new ArrayList<>();
            for (String p : beforeParts) {
                if (!p.isEmpty()) allParts.add(p);
            }
            allParts.add(quoted);
            for (String p : afterParts) {
                if (!p.isEmpty()) allParts.add(p);
            }
            parts = allParts.toArray(new String[0]);
        } else {
            parts = line.split(",");
        }

        // Reconstruct a Transaction using the Builder pattern
        return new TransactionBuilder()
            .id(parts[0].trim())
            .description(parts[1].trim())
            .amount(Double.parseDouble(parts[2].trim()))
            .category(Category.valueOf(parts[3].trim()))
            .date(LocalDate.parse(parts[4].trim()))
            .income(Boolean.parseBoolean(parts[5].trim()))
            .build();
    }

What does parseLine do?

  • The method handles a tricky edge case: descriptions wrapped in quotes. A simple line.split(",") would break if the description itself contains a comma.
  • When quotes are found, the method extracts the quoted portion separately and reassembles the parts array.
  • The method uses TransactionBuilder to reconstruct each Transaction object. This is the Builder pattern from Step 2 paying off.
  • Save CsvFileHandler.java.
  • Confirm that all red underlines are gone. The parseLine reference in loadTransactions should now resolve.

Still seeing red underlines?

  • Make sure parseLine is inside the CsvFileHandler class (before the final closing brace), not outside it.
  • Check that parseLine returns Transaction and not void. The method signature should be private static Transaction parseLine(String line).
  • Verify the imports at the top of the file include import model.TransactionBuilder; and import java.time.LocalDate;.

Help me debug my CsvFileHandler class.

Awesome, I've got everything!

Great. Double check you have saved CsvFileHandler.java with no red underlines before moving on.

I'd like to double check the full code

package storage;

import exception.DataStorageException;
import model.Category;
import model.Transaction;
import model.TransactionBuilder;

import java.io.*;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;

public class CsvFileHandler {
    private static final String HEADER = "id,description,amount,category,date,isIncome";

    public static void saveTransactions(List<Transaction> transactions, String filePath) throws DataStorageException {
        try (BufferedWriter writer = new BufferedWriter(new FileWriter(filePath))) {
            writer.write(HEADER);
            writer.newLine();
            for (Transaction t : transactions) {
                String line = String.join(",",
                    t.getId(),
                    "\"" + t.getDescription() + "\"",
                    String.valueOf(t.getAmount()),
                    t.getCategory().name(),
                    t.getDate().toString(),
                    String.valueOf(t.isIncome())
                );
                writer.write(line);
                writer.newLine();
            }
        } catch (IOException e) {
            throw new DataStorageException("Failed to save transactions to " + filePath, e);
        }
    }

    public static List<Transaction> loadTransactions(String filePath) throws DataStorageException {
        List<Transaction> transactions = new ArrayList<>();
        File file = new File(filePath);
        if (!file.exists()) {
            return transactions;
        }
        try (BufferedReader reader = new BufferedReader(new FileReader(filePath))) {
            String line = reader.readLine(); // skip header
            while ((line = reader.readLine()) != null) {
                try {
                    Transaction t = parseLine(line);
                    transactions.add(t);
                } catch (Exception e) {
                    System.err.println("Skipping malformed line: " + line);
                }
            }
        } catch (IOException e) {
            throw new DataStorageException("Failed to load transactions from " + filePath, e);
        }
        return transactions;
    }

    private static Transaction parseLine(String line) {
        // Handle quoted descriptions containing commas
        String[] parts;
        if (line.contains("\"")) {
            int firstQuote = line.indexOf("\"");
            int lastQuote = line.indexOf("\"", firstQuote + 1);
            String beforeQuote = line.substring(0, firstQuote);
            String quoted = line.substring(firstQuote + 1, lastQuote);
            String afterQuote = line.substring(lastQuote + 1);

            String[] beforeParts = beforeQuote.isEmpty() ? new String[]{} : beforeQuote.split(",");
            String[] afterParts = afterQuote.isEmpty() ? new String[]{} : afterQuote.substring(1).split(",");

            List<String> allParts = new ArrayList<>();
            for (String p : beforeParts) {
                if (!p.isEmpty()) allParts.add(p);
            }
            allParts.add(quoted);
            for (String p : afterParts) {
                if (!p.isEmpty()) allParts.add(p);
            }
            parts = allParts.toArray(new String[0]);
        } else {
            parts = line.split(",");
        }

        return new TransactionBuilder()
            .id(parts[0].trim())
            .description(parts[1].trim())
            .amount(Double.parseDouble(parts[2].trim()))
            .category(Category.valueOf(parts[3].trim()))
            .date(LocalDate.parse(parts[4].trim()))
            .income(Boolean.parseBoolean(parts[5].trim()))
            .build();
    }
}
Wire persistence into the repository and main app

Your CsvFileHandler can save and load transactions, but nothing calls it yet. You need to connect the persistence layer to ExpenseRepository and the main application.

  • Open ExpenseRepository.java in the service package.
  • Add the following loadAll method at the bottom of the class, before the final closing brace:
    public synchronized void loadAll(List<Transaction> loaded) {
        // Clear existing data and rebuild from the loaded list
        transactions.clear();
        categoryIndex.clear();
        for (Transaction t : loaded) {
            addTransaction(t);
        }
    }

What does this code do?

  • loadAll clears the repository's current data and re-populates it from a list of transactions loaded from the CSV file.
  • It reuses addTransaction() for each item so the category index stays in sync automatically.
  • The synchronized keyword ensures this method is thread-safe. You will learn exactly why this matters in the next step when you add multithreading.
  • Save ExpenseRepository.java.

Now connect the persistence to your main app entry point.

  • Open ExpenseTrackerApp.java in the app package.
  • Replace the entire file contents with the following code:
package app;

import exception.DataStorageException;
import service.ExpenseRepository;
import storage.CsvFileHandler;

public class ExpenseTrackerApp {
    // File path constant for the CSV data file
    private static final String DATA_FILE = "expenses.csv";

    public static void main(String[] args) {
        System.out.println("Expense Tracker starting...");

        // Get the singleton repository instance
        ExpenseRepository repository = ExpenseRepository.getInstance();

        // Load existing data from CSV on startup
        try {
            var loaded = CsvFileHandler.loadTransactions(DATA_FILE);
            repository.loadAll(loaded);
            System.out.printf("Loaded %d transactions from %s%n", loaded.size(), DATA_FILE);
        } catch (DataStorageException e) {
            System.err.println("Warning: Could not load data: " + e.getMessage());
        }
    }
}

What does this code do?

  • DATA_FILE is a constant that holds the CSV file path. Using a constant avoids hardcoding "expenses.csv" in multiple places.
  • On startup, the app calls CsvFileHandler.loadTransactions() to read any existing CSV data, then feeds it into the repository via loadAll().
  • The try-catch handles the checked DataStorageException. If the file is missing or corrupted, the app prints a warning and continues with an empty data set.
  • Save ExpenseTrackerApp.java.
  • Run the app by clicking the green play button next to the main method, or by pressing Ctrl+Shift+R (macOS) or Shift+F10 (Windows).

In the console output, you should see two lines. The first says Expense Tracker starting... and the second says Loaded 0 transactions from expenses.csv.

Why 0 transactions?

There is no expenses.csv file yet, so loadTransactions() returns an empty list. That is exactly the defensive behavior you coded: no file means no crash.

Seeing an error instead of the loaded message?

  • If you see Warning: Could not load data, check that DataStorageException has the two-argument constructor (String message, Throwable cause) from Step 3.
  • If IntelliJ says Cannot resolve symbol 'loadAll', make sure you added the loadAll method inside ExpenseRepository.java and saved the file.
  • If you see a ClassNotFoundException, try Build then Rebuild Project from the top menu.

Help me fix my ExpenseTrackerApp startup error.

Awesome, I've got everything!

Great. Confirm you saved both ExpenseRepository.java and ExpenseTrackerApp.java before moving on.

I'd like to double check the full code

Here is ExpenseTrackerApp.java as it should look now:

package app;

import exception.DataStorageException;
import service.ExpenseRepository;
import storage.CsvFileHandler;

public class ExpenseTrackerApp {
    private static final String DATA_FILE = "expenses.csv";

    public static void main(String[] args) {
        System.out.println("Expense Tracker starting...");

        ExpenseRepository repository = ExpenseRepository.getInstance();

        // Load existing data
        try {
            var loaded = CsvFileHandler.loadTransactions(DATA_FILE);
            repository.loadAll(loaded);
            System.out.printf("Loaded %d transactions from %s%n", loaded.size(), DATA_FILE);
        } catch (DataStorageException e) {
            System.err.println("Warning: Could not load data: " + e.getMessage());
        }
    }
}

Your expense tracker now persists data to a CSV file and loads it back on startup. Next up, you will add a background auto-save feature using multithreading so the app saves automatically every 30 seconds.

Add Multithreading with Background Auto-Save

Your expense tracker saves data to a CSV file, but only when you explicitly trigger a save. If the app crashes or the user forgets to exit cleanly, any transactions added since the last save are lost.

The solution is a background thread that automatically saves your data every 30 seconds. This introduces multithreading, which also means you need to protect your shared data from being read and written at the same time.

In this step, get ready to:
  • Create an AutoSaveManager that saves data every 30 seconds in the background.
  • Make ExpenseRepository thread-safe with synchronized methods.
  • Wire auto-save and a shutdown hook into ExpenseTrackerApp.
Create the AutoSaveManager class

Java's ScheduledExecutorService lets you schedule a task to run repeatedly on a timer. You will use it to fire a save task every 30 seconds on a background thread.

  • In the service package, create a new Java class called AutoSaveManager.
  • Add the class skeleton with its fields, constructor, and the run() method by copying this code into the file:
package service;

import exception.DataStorageException;
import storage.CsvFileHandler;

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

// Implements Runnable so it can be executed on a background thread
public class AutoSaveManager implements Runnable {
    private final ExpenseRepository repository;
    private final String filePath;
    private ScheduledExecutorService executor;

    public AutoSaveManager(ExpenseRepository repository, String filePath) {
        this.repository = repository;
        this.filePath = filePath;
    }

    // This method is called every time the scheduled task fires
    @Override
    public void run() {
        try {
            CsvFileHandler.saveTransactions(repository.getAllTransactions(), filePath);
        } catch (DataStorageException e) {
            System.err.println("Auto-save failed: " + e.getMessage());
        }
    }
}

What does this code do?

  • AutoSaveManager implements Runnable. The Runnable interface requires a run() method, which defines the work that runs on a separate thread.
  • The run() method calls the same CsvFileHandler.saveTransactions() you built in the previous step. If saving fails, it prints the error instead of crashing the background thread.
  • The class stores references to the repository and file path so the run() method knows what data to save and where to save it.

You won't see a change yet. The next chunk adds the start() and stop() methods that schedule and control the background task.

  • In AutoSaveManager.java, add the start() and stop() methods inside the class, below the run() method:
    // Schedules the auto-save task to run every 30 seconds
    public void start() {
        executor = Executors.newSingleThreadScheduledExecutor(r -> {
            Thread t = new Thread(r, "auto-save-thread");
            t.setDaemon(true);
            return t;
        });
        executor.scheduleAtFixedRate(this, 30, 30, TimeUnit.SECONDS);
        System.out.println("Auto-save enabled (every 30 seconds)");
    }

    // Gracefully shuts down the background thread
    public void stop() {
        if (executor != null && !executor.isShutdown()) {
            executor.shutdown();
            try {
                executor.awaitTermination(5, TimeUnit.SECONDS);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }

What does this code do?

  • start() creates a ScheduledExecutorService with a single background thread. The scheduleAtFixedRate() call tells it to run the run() method every 30 seconds, starting after an initial 30-second delay.
  • t.setDaemon(true) marks the thread as a daemon thread. Daemon threads are automatically stopped when the main program exits, so they won't keep the app running if the user closes it.
  • stop() calls shutdown() to stop accepting new tasks, then waits up to 5 seconds for the current save to finish. This prevents data corruption from cutting off a write mid-way.
  • Save AutoSaveManager.java.
  • Confirm there are no red underlines or compilation errors in the file. IntelliJ shows errors in the gutter on the left side of the editor.

Seeing a compilation error?

  • Check that your imports at the top of the file match the code above. IntelliJ may auto-import the wrong Executors class.
  • Make sure the start() and stop() methods are inside the class body (before the closing }), not outside it.
  • Verify that CsvFileHandler and ExpenseRepository exist in their respective packages from the previous steps.
  • Help me fix a compilation error in my AutoSaveManager class.

Awesome, I've got everything!

Double check you have saved AutoSaveManager.java.

I'd like to double check the full code

package service;

import exception.DataStorageException;
import storage.CsvFileHandler;

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class AutoSaveManager implements Runnable {
    private final ExpenseRepository repository;
    private final String filePath;
    private ScheduledExecutorService executor;

    public AutoSaveManager(ExpenseRepository repository, String filePath) {
        this.repository = repository;
        this.filePath = filePath;
    }

    @Override
    public void run() {
        try {
            CsvFileHandler.saveTransactions(repository.getAllTransactions(), filePath);
        } catch (DataStorageException e) {
            System.err.println("Auto-save failed: " + e.getMessage());
        }
    }

    public void start() {
        executor = Executors.newSingleThreadScheduledExecutor(r -> {
            Thread t = new Thread(r, "auto-save-thread");
            t.setDaemon(true);
            return t;
        });
        executor.scheduleAtFixedRate(this, 30, 30, TimeUnit.SECONDS);
        System.out.println("Auto-save enabled (every 30 seconds)");
    }

    public void stop() {
        if (executor != null && !executor.isShutdown()) {
            executor.shutdown();
            try {
                executor.awaitTermination(5, TimeUnit.SECONDS);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }
}
Make ExpenseRepository thread-safe

Your auto-save thread reads transactions from ExpenseRepository while the main thread writes new ones. If both threads access the list at the same time, you get a race condition. Data can be corrupted or the app can crash.

What is a race condition?

A race condition happens when two threads access shared data at the same time and the outcome depends on which thread gets there first. Imagine the main thread is adding a transaction to the list while the background thread is reading that same list to save it. The reader might see a half-written state.

Java's synchronized keyword prevents this by locking the method. Only one thread can execute a synchronized method at a time. The second thread waits until the first one finishes.

You need to add the synchronized keyword to every public method in ExpenseRepository that reads or writes the shared transaction data.

  • Open ExpenseRepository.java in the service package.
  • Find the getInstance() method. It currently looks like this:
public static ExpenseRepository getInstance() {
  • Add the synchronized keyword between static and the return type, so the line looks like this:
public static synchronized ExpenseRepository getInstance() {
  • Repeat this for the remaining five public methods. Add synchronized between public and the return type on each method signature:
public synchronized void addTransaction(Transaction transaction) {

public synchronized boolean removeTransaction(String id) {

public synchronized List<Transaction> getAllTransactions() {

public synchronized List<Transaction> getByCategory(Category category) {

public synchronized void loadAll(List<Transaction> loaded) {

Why synchronize all of these methods?

Every method that touches the transactions list or the categoryIndex map needs protection. If you only synchronized addTransaction() but left getAllTransactions() unlocked, the auto-save thread could still read a half-modified list.

getInstance() is synchronized for a different reason. Without it, two threads could both see instance == null at the same time and create two separate Singleton instances.

  • Save ExpenseRepository.java.
  • Confirm there are no compilation errors. Every method signature should now include the synchronized keyword.

Seeing a syntax error after adding synchronized?

  • Make sure synchronized appears between public and the return type. For example, public synchronized void is correct. synchronized public void also compiles but is non-standard.
  • Check that you did not accidentally delete the return type or method name while editing the line.
  • Help me fix a synchronized keyword error in my ExpenseRepository.

Awesome, I've got everything!

Double check you have saved ExpenseRepository.java.

I'd like to double check the full code

package service;

import model.Category;
import model.Transaction;

import java.util.*;

public class ExpenseRepository {
    private static ExpenseRepository instance;

    private final List<Transaction> transactions;
    private final Map<Category, List<Transaction>> categoryIndex;

    private ExpenseRepository() {
        this.transactions = new ArrayList<>();
        this.categoryIndex = new HashMap<>();
    }

    public static synchronized ExpenseRepository getInstance() {
        if (instance == null) {
            instance = new ExpenseRepository();
        }
        return instance;
    }

    public synchronized void addTransaction(Transaction transaction) {
        transactions.add(transaction);
        categoryIndex
            .computeIfAbsent(transaction.getCategory(), k -> new ArrayList<>())
            .add(transaction);
    }

    public synchronized boolean removeTransaction(String id) {
        Transaction toRemove = null;
        for (Transaction t : transactions) {
            if (t.getId().equals(id)) {
                toRemove = t;
                break;
            }
        }
        if (toRemove != null) {
            transactions.remove(toRemove);
            List<Transaction> catList = categoryIndex.get(toRemove.getCategory());
            if (catList != null) {
                catList.remove(toRemove);
            }
            return true;
        }
        return false;
    }

    public synchronized List<Transaction> getAllTransactions() {
        return Collections.unmodifiableList(new ArrayList<>(transactions));
    }

    public synchronized List<Transaction> getByCategory(Category category) {
        return Collections.unmodifiableList(
            categoryIndex.getOrDefault(category, Collections.emptyList())
        );
    }

    public synchronized void loadAll(List<Transaction> loaded) {
        transactions.clear();
        categoryIndex.clear();
        for (Transaction t : loaded) {
            addTransaction(t);
        }
    }
}
Wire auto-save and a shutdown hook into ExpenseTrackerApp

The auto-save manager exists, and the repository is thread-safe. Now you need to start the auto-save when the app launches and make sure data is saved one final time when the app shuts down.

  • Open ExpenseTrackerApp.java in the app package.
  • Find the comment // Load existing data in the main() method. After the closing brace of that try-catch block, add the following code:
        // Start auto-save
        AutoSaveManager autoSave = new AutoSaveManager(repository, DATA_FILE);
        autoSave.start();

        // Shutdown hook for graceful cleanup
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            autoSave.stop();
            try {
                CsvFileHandler.saveTransactions(repository.getAllTransactions(), DATA_FILE);
                System.out.println("Data saved successfully.");
            } catch (DataStorageException e) {
                System.err.println("Failed to save on shutdown: " + e.getMessage());
            }
        }));

What does this code do?

  • The first two lines create an AutoSaveManager and start the 30-second save loop on a background thread.
  • Runtime.getRuntime().addShutdownHook() registers a thread that the JVM runs automatically when the program exits. This happens whether the user types "Exit" in the menu, presses Ctrl+C, or the app terminates normally.
  • Inside the shutdown hook, autoSave.stop() shuts down the background thread, and then a final saveTransactions() call ensures nothing is lost.
  • Make sure IntelliJ has added the AutoSaveManager import at the top of the file. If it has not, add this line with the other imports:
import service.AutoSaveManager;
  • Save ExpenseTrackerApp.java.
  • Click the green Run button (or press Ctrl+Shift+R on macOS or Shift+F10 on Windows) to run the app.
  • In the console output, you should see Auto-save enabled (every 30 seconds) printed after the app starts.

Don't see the auto-save message?

  • Check that the autoSave.start() call is placed after the data loading try-catch block but before the ConsoleUI code.
  • Make sure you are running ExpenseTrackerApp (the class with the main method), not another class.
  • If you see an error about AutoSaveManager not being found, verify the import statement is present and the class is in the service package.
  • Help me debug my auto-save setup in ExpenseTrackerApp.

Awesome, I've got everything!

Double check you have saved ExpenseTrackerApp.java.

I'd like to double check the full code

package app;

import exception.DataStorageException;
import model.Category;
import model.TransactionBuilder;
import service.AnalyticsEngine;
import service.AutoSaveManager;
import service.ExpenseRepository;
import storage.CsvFileHandler;

import java.util.List;

public class ExpenseTrackerApp {
    private static final String DATA_FILE = "expenses.csv";

    public static void main(String[] args) {
        System.out.println("Expense Tracker starting...");

        ExpenseRepository repository = ExpenseRepository.getInstance();
        AnalyticsEngine analytics = new AnalyticsEngine();

        // Load existing data
        try {
            var loaded = CsvFileHandler.loadTransactions(DATA_FILE);
            repository.loadAll(loaded);
            System.out.printf("Loaded %d transactions from %s%n", loaded.size(), DATA_FILE);
        } catch (DataStorageException e) {
            System.err.println("Warning: Could not load data: " + e.getMessage());
        }

        // Start auto-save
        AutoSaveManager autoSave = new AutoSaveManager(repository, DATA_FILE);
        autoSave.start();

        // Shutdown hook for graceful cleanup
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            autoSave.stop();
            try {
                CsvFileHandler.saveTransactions(repository.getAllTransactions(), DATA_FILE);
                System.out.println("Data saved successfully.");
            } catch (DataStorageException e) {
                System.err.println("Failed to save on shutdown: " + e.getMessage());
            }
        }));

        // Start the console UI
        ConsoleUI ui = new ConsoleUI(repository, analytics);
        ui.start();
    }
}

Your expense tracker now saves data automatically in the background and cleans up gracefully when the app exits. Next up, you will build the interactive console menu that ties everything together into a usable application.

Build the Interactive Console Menu

Your auto-save thread is running in the background, quietly persisting data every 30 seconds. The engine is built. Now your app needs a driver's seat.

In this step, you will build an interactive console menu that ties every component together. The user will be able to add transactions, browse them, run analytics reports, and sort data, all from a text-based menu powered by Scanner and a switch statement.

In this step, get ready to:
  • Create a ConsoleUI class with a main menu loop and error handling.
  • Build the add expense and add income flows with category selection.
  • Implement view, analytics report, and sort menu options.
Create the ConsoleUI class and menu loop

Every interactive console app follows the same core pattern: print a menu, read the user's choice, execute the matching action, and repeat. A while loop handles the repetition, and a switch statement routes each choice to the right method.

  • In the app package, right-click and select New, then Java Class.
  • Name it ConsoleUI and press Enter.
  • Replace everything in the file with the following code:
package app;

import model.Category;
import model.Transaction;
import model.TransactionBuilder;
import service.AnalyticsEngine;
import service.ExpenseRepository;
import util.SortUtil;

import java.util.Comparator;
import java.util.InputMismatchException;
import java.util.List;
import java.util.Scanner;

public class ConsoleUI {
    // Scanner reads text input from the console
    private final Scanner scanner;
    // Repository gives us access to stored transactions
    private final ExpenseRepository repository;
    // Analytics engine generates reports from transaction data
    private final AnalyticsEngine analytics;

    public ConsoleUI(ExpenseRepository repository, AnalyticsEngine analytics) {
        this.scanner = new Scanner(System.in);
        this.repository = repository;
        this.analytics = analytics;
    }

    // Displays the numbered menu options to the user
    private void printMenu() {
        System.out.println("\n=== Expense Tracker ===");
        System.out.println("1. Add Expense");
        System.out.println("2. Add Income");
        System.out.println("3. View All Transactions");
        System.out.println("4. View by Category");
        System.out.println("5. Analytics Report");
        System.out.println("6. Sort Transactions");
        System.out.println("7. Exit");
        System.out.print("Choose an option: ");
    }

    // Main loop: show menu, read choice, execute action, repeat
    public void start() {
        boolean running = true;
        while (running) {
            printMenu();
            try {
                // Parse the user's menu choice as an integer
                int choice = Integer.parseInt(scanner.nextLine().trim());
                switch (choice) {
                    case 1 -> addTransaction(false);
                    case 2 -> addTransaction(true);
                    case 3 -> viewAllTransactions();
                    case 4 -> viewByCategory();
                    case 5 -> analytics.generateReport(repository.getAllTransactions());
                    case 6 -> sortTransactions();
                    case 7 -> {
                        running = false;
                        System.out.println("Goodbye!");
                    }
                    default -> System.out.println("Invalid option. Please try again.");
                }
            } catch (NumberFormatException e) {
                // Catches non-numeric input like "abc"
                System.out.println("Please enter a valid number.");
            } catch (Exception e) {
                // Catches custom exceptions and anything else unexpected
                System.out.println("Error: " + e.getMessage());
            }
        }
    }
}

What does this code do?

  • The constructor takes in ExpenseRepository and AnalyticsEngine as dependencies. The UI doesn't create these itself. It receives them from the outside.
  • printMenu() displays a numbered list of options. System.out.print (no "ln") keeps the cursor on the same line so the user types next to "Choose an option:".
  • start() uses Integer.parseInt(scanner.nextLine().trim()) instead of scanner.nextInt(). This avoids a common Scanner bug where leftover newline characters cause the next nextLine() call to return an empty string.
  • The switch uses Java 21's arrow syntax (case 1 ->), which is cleaner than the traditional colon-and-break style.

IntelliJ will show red errors for the methods referenced in the switch (addTransaction, viewAllTransactions, etc.) because they don't exist yet. You will add them in the next two substeps.

Build the add expense and income flows

Both the "Add Expense" and "Add Income" options collect the same information: a description, an amount, and a category. The only difference is that income transactions automatically use the INCOME category instead of prompting the user to pick one.

  • In ConsoleUI.java, add the following addTransaction method below the start() method, inside the class body:
    private void addTransaction(boolean isIncome) {
        // Prompt for the transaction description
        System.out.print("Description: ");
        String description = scanner.nextLine().trim();

        // Prompt for the dollar amount
        System.out.print("Amount: $");
        double amount = Double.parseDouble(scanner.nextLine().trim());

        // Income always uses the INCOME category
        // Expenses show a numbered list for the user to pick from
        Category category;
        if (isIncome) {
            category = Category.INCOME;
        } else {
            System.out.println("Categories:");
            Category[] categories = Category.values();
            for (int i = 0; i < categories.length; i++) {
                if (categories[i] != Category.INCOME) {
                    System.out.printf("  %d. %s%n", i + 1, categories[i].getDisplayName());
                }
            }
            System.out.print("Choose category number: ");
            int catChoice = Integer.parseInt(scanner.nextLine().trim()) - 1;
            category = categories[catChoice];
        }

        // Build the transaction using the Builder pattern
        // The builder's build() method calls InputValidator internally
        Transaction transaction = new TransactionBuilder()
            .description(description)
            .amount(amount)
            .category(category)
            .income(isIncome)
            .build();

        // Store it in the repository
        repository.addTransaction(transaction);
        System.out.printf("%s added: %s%n", isIncome ? "Income" : "Expense", transaction);
    }

What does this code do?

  • The isIncome boolean lets one method handle both expense and income flows. When true, the method skips the category selection and defaults to Category.INCOME.
  • The category picker loops through Category.values() and skips INCOME so it does not appear as an expense category option.
  • You do not need explicit try-catch here. If the user enters a negative amount, the TransactionBuilder.build() method calls InputValidator.validateAmount(), which throws your custom InvalidAmountException. That exception bubbles up to the catch (Exception e) block in start(), which displays the error message and loops back to the menu.

IntelliJ will still show red errors for the remaining undefined methods. You will add those next.

Why does the exception handling work this way?

Your custom exceptions (InvalidAmountException, InvalidCategoryException) extend IllegalArgumentException, which is an unchecked exception. This means they propagate automatically without needing throws declarations on every method in the call chain.

The single catch (Exception e) in start() acts as a safety net. It catches any unchecked exception, prints a user-friendly message, and returns the user to the menu instead of crashing the app.

Implement the remaining menu options

The remaining four options bring together every component you have built so far. Viewing transactions uses the repository. The analytics report uses the Streams-powered engine. Sorting uses your generic SortUtil and Comparator implementations.

  • In ConsoleUI.java, add the following three methods below the addTransaction method, still inside the class body:
    private void viewAllTransactions() {
        // Use SortUtil with natural ordering (Comparable) to sort by date
        List<Transaction> all = SortUtil.sort(repository.getAllTransactions());
        if (all.isEmpty()) {
            System.out.println("No transactions yet.");
            return;
        }
        System.out.println("\n--- All Transactions ---");
        for (Transaction t : all) {
            System.out.println("  " + t);
        }
    }

    private void viewByCategory() {
        // Show all categories as a numbered list
        System.out.println("Categories:");
        Category[] categories = Category.values();
        for (int i = 0; i < categories.length; i++) {
            System.out.printf("  %d. %s%n", i + 1, categories[i].getDisplayName());
        }
        System.out.print("Choose category number: ");
        int choice = Integer.parseInt(scanner.nextLine().trim()) - 1;
        Category category = categories[choice];

        // Use the repository's category index for fast lookup
        List<Transaction> filtered = repository.getByCategory(category);
        if (filtered.isEmpty()) {
            System.out.println("No transactions in " + category.getDisplayName());
            return;
        }
        System.out.println("\n--- " + category.getDisplayName() + " Transactions ---");
        for (Transaction t : filtered) {
            System.out.println("  " + t);
        }
    }

    private void sortTransactions() {
        // Let the user choose how to sort
        System.out.println("Sort by:");
        System.out.println("  1. Date (default)");
        System.out.println("  2. Amount (highest first)");
        System.out.println("  3. Category");
        System.out.print("Choose: ");
        int choice = Integer.parseInt(scanner.nextLine().trim());

        // Use SortUtil with different Comparators based on the user's choice
        List<Transaction> sorted;
        switch (choice) {
            case 2 -> sorted = SortUtil.sort(
                repository.getAllTransactions(),
                Comparator.comparingDouble(Transaction::getAmount).reversed()
            );
            case 3 -> sorted = SortUtil.sort(
                repository.getAllTransactions(),
                Comparator.comparing(Transaction::getCategory)
            );
            default -> sorted = SortUtil.sort(repository.getAllTransactions());
        }

        System.out.println("\n--- Sorted Transactions ---");
        for (Transaction t : sorted) {
            System.out.println("  " + t);
        }
    }

What does this code do?

  • viewAllTransactions() calls SortUtil.sort() with no Comparator argument. This triggers the natural ordering path, which uses the Comparable implementation you wrote on Transaction (sort by date).
  • viewByCategory() calls repository.getByCategory(), which uses the HashMap category index you built in Step 4 for fast lookups.
  • sortTransactions() demonstrates the power of Comparator. Comparator.comparingDouble(Transaction::getAmount).reversed() sorts by amount from highest to lowest, and Comparator.comparing(Transaction::getCategory) groups transactions alphabetically by category.
  • The "Analytics Report" option (case 5 in start()) calls analytics.generateReport() directly. No new code is needed here because the AnalyticsEngine you built in Step 5 handles all the formatting.
  • Save ConsoleUI.java.
  • Confirm that all red errors in the file have disappeared. IntelliJ should show no compilation errors now that every method referenced in the switch statement exists.

Still seeing red errors?

  • Check that every method name matches exactly: addTransaction, viewAllTransactions, viewByCategory, sortTransactions. Java is case-sensitive.
  • Make sure all three methods are inside the ConsoleUI class body (between the class opening brace and the final closing brace). A misplaced closing brace is the most common cause.
  • Verify that the import statements at the top of the file include import util.SortUtil; and import java.util.Comparator;.

Why is my ConsoleUI class showing compilation errors even though I added all the methods?

Now run the app to test your console menu.

  • Open ExpenseTrackerApp.java and click the green play button next to the main method (or press Ctrl+Shift+R on macOS or Shift+F10 on Windows).
  • You should see the startup messages in the console, followed by the "=== Expense Tracker ===" menu.
  • Type 1 and press Enter to test adding an expense.
  • Enter a description like Lunch, an amount like 12.50, and pick a category number.
  • You should see a confirmation line like Expense added: 2026-04-24 | -$12.50 | Food | Lunch.
  • Type 3 to view all transactions and confirm your expense appears.
  • Type 5 to run the analytics report. You should see the full report with totals, category breakdown, and your top expenses.
  • Type 7 to exit. You should see "Goodbye!" followed by "Data saved successfully." from the shutdown hook.

App crashes or menu does not appear?

  • If you see a NullPointerException on startup, check that ExpenseTrackerApp.java passes ExpenseRepository.getInstance() and a new AnalyticsEngine() to the ConsoleUI constructor.
  • If typing abc crashes the app instead of showing "Please enter a valid number," make sure the catch (NumberFormatException e) block is present in your start() method.
  • If the console output looks garbled, make sure you are clicking inside the IntelliJ console panel before typing your input.

Help me debug why my Expense Tracker console menu is not working correctly.

Awesome, I've got everything!

Double check you have saved ConsoleUI.java.

I'd like to double check the full code

package app;

import model.Category;
import model.Transaction;
import model.TransactionBuilder;
import service.AnalyticsEngine;
import service.ExpenseRepository;
import util.SortUtil;

import java.util.Comparator;
import java.util.InputMismatchException;
import java.util.List;
import java.util.Scanner;

public class ConsoleUI {
    private final Scanner scanner;
    private final ExpenseRepository repository;
    private final AnalyticsEngine analytics;

    public ConsoleUI(ExpenseRepository repository, AnalyticsEngine analytics) {
        this.scanner = new Scanner(System.in);
        this.repository = repository;
        this.analytics = analytics;
    }

    public void start() {
        boolean running = true;
        while (running) {
            printMenu();
            try {
                int choice = Integer.parseInt(scanner.nextLine().trim());
                switch (choice) {
                    case 1 -> addTransaction(false);
                    case 2 -> addTransaction(true);
                    case 3 -> viewAllTransactions();
                    case 4 -> viewByCategory();
                    case 5 -> analytics.generateReport(repository.getAllTransactions());
                    case 6 -> sortTransactions();
                    case 7 -> {
                        running = false;
                        System.out.println("Goodbye!");
                    }
                    default -> System.out.println("Invalid option. Please try again.");
                }
            } catch (NumberFormatException e) {
                System.out.println("Please enter a valid number.");
            } catch (Exception e) {
                System.out.println("Error: " + e.getMessage());
            }
        }
    }

    private void printMenu() {
        System.out.println("\n=== Expense Tracker ===");
        System.out.println("1. Add Expense");
        System.out.println("2. Add Income");
        System.out.println("3. View All Transactions");
        System.out.println("4. View by Category");
        System.out.println("5. Analytics Report");
        System.out.println("6. Sort Transactions");
        System.out.println("7. Exit");
        System.out.print("Choose an option: ");
    }

    private void addTransaction(boolean isIncome) {
        System.out.print("Description: ");
        String description = scanner.nextLine().trim();

        System.out.print("Amount: $");
        double amount = Double.parseDouble(scanner.nextLine().trim());

        Category category;
        if (isIncome) {
            category = Category.INCOME;
        } else {
            System.out.println("Categories:");
            Category[] categories = Category.values();
            for (int i = 0; i < categories.length; i++) {
                if (categories[i] != Category.INCOME) {
                    System.out.printf("  %d. %s%n", i + 1, categories[i].getDisplayName());
                }
            }
            System.out.print("Choose category number: ");
            int catChoice = Integer.parseInt(scanner.nextLine().trim()) - 1;
            category = categories[catChoice];
        }

        Transaction transaction = new TransactionBuilder()
            .description(description)
            .amount(amount)
            .category(category)
            .income(isIncome)
            .build();

        repository.addTransaction(transaction);
        System.out.printf("%s added: %s%n", isIncome ? "Income" : "Expense", transaction);
    }

    private void viewAllTransactions() {
        List<Transaction> all = SortUtil.sort(repository.getAllTransactions());
        if (all.isEmpty()) {
            System.out.println("No transactions yet.");
            return;
        }
        System.out.println("\n--- All Transactions ---");
        for (Transaction t : all) {
            System.out.println("  " + t);
        }
    }

    private void viewByCategory() {
        System.out.println("Categories:");
        Category[] categories = Category.values();
        for (int i = 0; i < categories.length; i++) {
            System.out.printf("  %d. %s%n", i + 1, categories[i].getDisplayName());
        }
        System.out.print("Choose category number: ");
        int choice = Integer.parseInt(scanner.nextLine().trim()) - 1;
        Category category = categories[choice];

        List<Transaction> filtered = repository.getByCategory(category);
        if (filtered.isEmpty()) {
            System.out.println("No transactions in " + category.getDisplayName());
            return;
        }
        System.out.println("\n--- " + category.getDisplayName() + " Transactions ---");
        for (Transaction t : filtered) {
            System.out.println("  " + t);
        }
    }

    private void sortTransactions() {
        System.out.println("Sort by:");
        System.out.println("  1. Date (default)");
        System.out.println("  2. Amount (highest first)");
        System.out.println("  3. Category");
        System.out.print("Choose: ");
        int choice = Integer.parseInt(scanner.nextLine().trim());

        List<Transaction> sorted;
        switch (choice) {
            case 2 -> sorted = SortUtil.sort(
                repository.getAllTransactions(),
                Comparator.comparingDouble(Transaction::getAmount).reversed()
            );
            case 3 -> sorted = SortUtil.sort(
                repository.getAllTransactions(),
                Comparator.comparing(Transaction::getCategory)
            );
            default -> sorted = SortUtil.sort(repository.getAllTransactions());
        }

        System.out.println("\n--- Sorted Transactions ---");
        for (Transaction t : sorted) {
            System.out.println("  " + t);
        }
    }
}

Secret mission

Build a Budget Alert System

Your expense tracker records spending, but it does not warn you when you are about to blow your budget. In this mission, you will build a budget alert system that lets users set spending limits per category and get real-time warnings when they approach or exceed those limits. You will use Optional, custom functional interfaces, and lambda expressions to make it happen.

Before you go

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • IntelliJ IDEA project (ExpenseTracker).
  • Java source files across 6 packages (model, service, storage, exception, util, app).
  • Generated CSV data file (expenses.csv).
  • Optional budgets.csv file (if you completed the Secret Mission).

Keep everything running

No action needed. Choose this if you are still actively building or want to extend the project further.

  • Your IntelliJ IDEA project and all source files stay exactly where they are.
  • Your expenses.csv file will persist your transaction data for future sessions.
  • Everything runs locally, so there is nothing incurring costs in the background.

Pause - I'll come back to this later

Shut down any running processes to free up memory. Your project files and data will still be there when you come back.

  • Stop the Expense Tracker if it is still running in IntelliJ IDEA by clicking the red stop button.
  • Close IntelliJ IDEA to free up system memory.
  • Your project files and expenses.csv data will remain on your machine for next time.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • Stop the Expense Tracker if it is still running.
  • In IntelliJ IDEA, right-click the ExpenseTracker project in the Project panel and select Remove Module.
  • Delete the project folder from your file system. This includes all Java source files across the model, service, storage, exception, util, and app packages.
  • Delete the expenses.csv file (and budgets.csv if you completed the Secret Mission) from the project root directory.

Mission accomplished

That's a Wrap!

Nice work! You just built a fully functional Personal Expense Tracker in Java, complete with analytics, file persistence, and background auto-saving. Along the way, you got hands-on practice with the core concepts that come up in almost every Java technical interview.

Here's what you learned:

  • Designed a data model using OOP principles (encapsulation, enums with behavior, Builder pattern), built custom exceptions for input validation, and implemented the equals()/hashCode() contract for transaction deduplication.
  • Built a Streams-powered analytics engine using filter, groupingBy, summingDouble, method references, and lambda expressions to generate spending reports by category and month.
  • Added CSV file persistence with BufferedReader/BufferedWriter and try-with-resources, plus a background auto-save feature using ScheduledExecutorService and synchronized methods for thread safety.
  • Completed the Secret Mission by building a Budget Alert System using Optional, a custom @FunctionalInterface, and lambda expressions to warn when spending exceeds category limits.

Ready to quiz yourself?