Smart Expense Tracker Engine

Build a CLI expense tracker using OOP, design patterns, and modern Java.

Introduction

30 Second Summary

Every time you buy coffee, catch a train, or grab a movie ticket, that spending falls into a category. The way software organizes and monitors those categories reveals the design patterns that power real applications.

In this project, you will build a command-line expense tracker engine using modern Java and three design patterns that dominate technical interviews. Each pattern solves a specific problem: creating the right expense type, sorting expenses in different orders, and alerting you when you overspend.

What You'll Build

A multi-file Java CLI application that tracks expenses by category, sorts them with interchangeable algorithms, and fires automatic budget alerts when spending exceeds your limit.

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

  • A sealed interface hierarchy with three record implementations modeling food, transport, and entertainment expenses.
  • Three working design patterns (Factory Method, Strategy, Observer) wired together in a single application.
  • A code structure demonstrating SOLID principles through natural design decisions, ready to walk an interviewer through.
  • Secret Mission: Add the Builder pattern to generate configurable expense reports with optional sections.

Are there any prerequisites?

This project builds on Java Core fundamentals from Project 1. You should be comfortable with classes, methods, arrays, and basic control flow in IntelliJ IDEA.

Set Up the Project and Domain Model

Every expense tracker needs a clear way to represent different types of spending. Food, transport, and entertainment each carry unique details, but they all share common properties like a description, amount, and date.

In this step, you'll create the IntelliJ IDEA project and define your domain model using sealed interfaces and records. These modern Java features give you a locked-down type hierarchy with zero boilerplate code.

In this step, get ready to:
  • Create the IntelliJ project with the required package structure.
  • Define a sealed interface that locks down all possible expense types.
  • Implement three record classes for food, transport, and entertainment expenses.
Create the project and package structure

Your project needs a clear folder structure that separates concerns. Each design pattern will live in its own package so the codebase stays organized as it grows.

  • In IntelliJ IDEA, click File in the top menu bar.
  • Click New, then Project.
  • In the Name field, type ExpenseTracker.
  • Make sure Language is set to Java and Build system is set to IntelliJ.
  • Make sure JDK shows version 25. If it doesn't, click the dropdown and select your JDK 25 installation.
  • Uncheck Add sample code if it is selected.
  • Click Create.

IntelliJ opens your new project. You should see a src folder in the Project tool window on the left.

  • Right-click the src folder in the Project tool window.
  • Select New, then Package.
  • Type com.expensetracker.model and press Enter.
  • Right-click src again and create another package: com.expensetracker.factory.
  • Repeat to create com.expensetracker.strategy.
  • Repeat to create com.expensetracker.observer.

What are all these packages for?

Each package maps to a design pattern you'll implement. The model package holds your domain types. The factory package will hold the Factory Method pattern. The strategy package handles sorting algorithms. The observer package manages budget alerts.

Separating patterns into packages is a form of the Single Responsibility Principle. Each package has one reason to change.

Don't see the packages?

Make sure you're right-clicking the src folder specifically, not the project root. If packages appear as flat dots (e.g. com.expensetracker.model on one line), click the gear icon in the Project tool window and uncheck Compact Middle Packages to see the full tree.

Help me set up my IntelliJ package structure

Create the sealed Expense interface

A sealed interface restricts which classes can implement it. The compiler knows every possible type, which enables exhaustive pattern matching later with no default case needed.

  • Right-click the com.expensetracker.model package in the Project tool window.
  • Select New, then Java Class.
  • Type Expense as the name and select Interface from the Kind dropdown.
  • Press Enter to create the file.
  • Replace all the generated code with the following:
package com.expensetracker.model;

import java.time.LocalDate;

// Sealed interface - only the types listed in 'permits' can implement this
public sealed interface Expense permits FoodExpense, TransportExpense, EntertainmentExpense {
    String description();
    double amount();
    LocalDate date();
}

What does this code do?

The sealed keyword locks this interface so only FoodExpense, TransportExpense, and EntertainmentExpense can implement it. No other class in your codebase can sneak in.

The three methods (description(), amount(), date()) are contracts that every expense type must fulfill. Because the compiler knows all permitted types, you can write switch expressions that cover every case without a default branch.

  • Save Expense.java with Cmd+S (macOS) or Ctrl+S (Windows).

You will see red underlines on FoodExpense, TransportExpense, and EntertainmentExpense in the permits clause. This is expected because those types don't exist yet.

Create the three record implementations

Records are special Java classes that auto-generate the constructor, accessor methods, equals(), hashCode(), and toString(). They are implicitly final, which satisfies the sealed interface contract.

  • Right-click com.expensetracker.model and select New, then Java Class.
  • Type FoodExpense as the name and select Record from the Kind dropdown.
  • Press Enter to create the file.
  • Replace all the generated code with the following:
package com.expensetracker.model;

import java.time.LocalDate;

// Record auto-generates constructor, accessors, equals, hashCode, and toString
public record FoodExpense(String description, double amount, LocalDate date, String mealType) implements Expense {
}

What does a record give you for free?

This single line declares a class with four fields, a constructor that takes all four, accessor methods matching each field name, and working equals()/hashCode()/toString() implementations. That's roughly 40 lines of boilerplate eliminated.

The mealType field is the category-specific detail. Each expense type adds one extra field beyond the shared description, amount, and date.

  • Save FoodExpense.java.
  • Create another file in the same package named TransportExpense with the Record kind.
  • Replace all the generated code with the following:
package com.expensetracker.model;

import java.time.LocalDate;

// Transport expenses track the vehicle type (bus, train, taxi, car)
public record TransportExpense(String description, double amount, LocalDate date, String vehicleType) implements Expense {
}
  • Save TransportExpense.java.
  • Create one more file in the same package named EntertainmentExpense with the Record kind.
  • Replace all the generated code with the following:
package com.expensetracker.model;

import java.time.LocalDate;

// Entertainment expenses track the venue name
public record EntertainmentExpense(String description, double amount, LocalDate date, String venue) implements Expense {
}
  • Save EntertainmentExpense.java.
  • Switch back to Expense.java by clicking its tab at the top of the editor.

The red underlines on the permits clause should now be gone. All three permitted types exist and implement the interface correctly.

Still seeing red underlines?

Check that all four files are in the same package (com.expensetracker.model). Sealed interfaces require permitted types to be in the same package. Also verify the class names match exactly (FoodExpense, TransportExpense, EntertainmentExpense) with correct capitalization.

Help me fix the red underlines in my sealed interface

✔️ Awesome, I've got everything!

Great. Double check you've saved all four files in the com.expensetracker.model package.

ⓧ I'd like to double check the full code

Here are all four files as they should look at this point:

package com.expensetracker.model;

import java.time.LocalDate;

public sealed interface Expense permits FoodExpense, TransportExpense, EntertainmentExpense {
    String description();
    double amount();
    LocalDate date();
}
package com.expensetracker.model;

import java.time.LocalDate;

public record FoodExpense(String description, double amount, LocalDate date, String mealType) implements Expense {
}
package com.expensetracker.model;

import java.time.LocalDate;

public record TransportExpense(String description, double amount, LocalDate date, String vehicleType) implements Expense {
}
package com.expensetracker.model;

import java.time.LocalDate;

public record EntertainmentExpense(String description, double amount, LocalDate date, String venue) implements Expense {
}

Your domain model is locked in. You have a sealed type hierarchy that the compiler can reason about exhaustively. Next up, you'll build a Factory that creates the right expense type from user input.

Implement the Factory Method Pattern

Right now, creating an expense means calling new FoodExpense(...) or new TransportExpense(...) directly wherever you need one. That works fine with three types, but imagine adding 10 more categories. Every place that creates an expense would need updating.

The Factory Method pattern solves this by centralizing object creation in one place. In this step, you will build an ExpenseFactory that takes a category string and returns the correct record type. Callers never need to know which concrete class they get back.

In this step, get ready to:
  • Create an ExpenseFactory class with a pattern-matching switch expression.
  • Handle invalid input with a defensive default case.
  • Test the factory in isolation with a temporary main method.
Create ExpenseFactory with pattern matching switch

The factory uses a switch expression (arrow syntax) to inspect the category string and return the matching record type. A switch expression returns a value directly, so you can assign the entire switch to a return statement.

  • In the Project tool window on the left, right-click the com.expensetracker.factory package.
  • Select New then Java Class.
  • Name it ExpenseFactory and press Enter.
  • Replace the generated contents with this code:
package com.expensetracker.factory;

import com.expensetracker.model.*;

import java.time.LocalDate;

public class ExpenseFactory {

    public static Expense createExpense(String category, String description, double amount, LocalDate date, String detail) {
        return switch (category.toLowerCase()) {
            case "food" -> new FoodExpense(description, amount, date, detail);
            case "transport" -> new TransportExpense(description, amount, date, detail);
            case "entertainment" -> new EntertainmentExpense(description, amount, date, detail);
            default -> throw new IllegalArgumentException("Unknown category: " + category);
        };
    }
}

What does this code do?

The createExpense method is static, so callers use ExpenseFactory.createExpense(...) without needing a factory instance.

  • category.toLowerCase() normalizes input so "Food", "FOOD", and "food" all work.
  • Each case uses the arrow (->) syntax to return a new record directly from the switch expression.
  • The import com.expensetracker.model.* wildcard import pulls in all four model types at once.
  • Save ExpenseFactory.java with Cmd+S (macOS) or Ctrl+S (Windows).
  • Check that IntelliJ shows no red underlines in the file. All imports should resolve and the switch expression should compile cleanly.

Seeing red underlines on the model imports?

Make sure ExpenseFactory.java is inside the com.expensetracker.factory package, not inside com.expensetracker.model. The wildcard import com.expensetracker.model.* only works from outside the model package.

Also confirm your Expense.java sealed interface and all three records from Step 1 compile without errors. The factory depends on them.

Help me debug my ExpenseFactory import errors.

Handle invalid input

Notice the default case in the switch expression. If someone passes a category like "groceries" that does not match any case, the factory throws an IllegalArgumentException immediately.

Why throw an exception instead of returning null?

Returning null would let bad data silently slip through. The caller would not find out until much later when a NullPointerException crashes the program somewhere unexpected.

Throwing at the point of creation gives you a clear error message ("Unknown category: groceries") and a stack trace that points directly to the factory call. This is called failing fast.

How does this demonstrate the Open/Closed Principle?

The Open/Closed Principle (OCP) says classes should be open for extension but closed for modification. To add a new expense type (like HealthExpense), you add one record, one case line in the factory, and one entry in the sealed permits clause. No other code in the application needs to change.

Test the factory in isolation

Before wiring the factory into a larger application, you should verify it works on its own. You will add a temporary main method directly inside ExpenseFactory to test each category and confirm the error handling.

  • In ExpenseFactory.java, place your cursor after the closing brace of the createExpense method.
  • Add this temporary main method inside the class body:
    // Temporary main method for testing - delete after verifying
    public static void main(String[] args) {
        // Test each category
        Expense food = createExpense("food", "Lunch at cafe", 12.50, LocalDate.now(), "lunch");
        System.out.println(food);

        Expense transport = createExpense("transport", "Bus to work", 2.75, LocalDate.now(), "bus");
        System.out.println(transport);

        Expense entertainment = createExpense("entertainment", "Movie night", 15.00, LocalDate.now(), "AMC Theater");
        System.out.println(entertainment);

        // Test invalid category
        try {
            createExpense("invalid", "Test", 5.00, LocalDate.now(), "none");
        } catch (IllegalArgumentException e) {
            System.out.println("Error: " + e.getMessage());
        }
    }

What does this test do?

  • It calls createExpense once for each valid category and prints the result. Since records auto-generate toString(), you will see all fields printed in a readable format.
  • It wraps the invalid category call in a try-catch to prove the IllegalArgumentException fires correctly instead of crashing the program.
  • Save the file with Cmd+S (macOS) or Ctrl+S (Windows).
  • Click the green triangle in the gutter next to the main method.
  • Select Run 'ExpenseFactory.main()' from the popup menu.

You should see three expense records printed with all their fields, followed by the error message:

FoodExpense[description=Lunch at cafe, amount=12.5, date=2026-06-12, mealType=lunch]
TransportExpense[description=Bus to work, amount=2.75, date=2026-06-12, vehicleType=bus]
EntertainmentExpense[description=Movie night, amount=15.0, date=2026-06-12, venue=AMC Theater]
Error: Unknown category: invalid

What should I see?

Each line shows the record class name followed by its fields in square brackets. This is the auto-generated toString() from Java records.

Your date will match today's date, which may differ from the example above. The last line confirms the factory correctly rejects unknown categories with a clear error message.

Seeing a compilation error in the main method?

Make sure the main method is inside the ExpenseFactory class body (between the class braces), not outside it. It should sit after the createExpense method's closing brace but before the class's final closing brace.

If you see an error about Expense or LocalDate not being found, confirm the import statements at the top of the file include import com.expensetracker.model.* and import java.time.LocalDate.

Help me fix this compilation error in my main method.

  • Now that the factory is verified, delete the entire temporary main method from ExpenseFactory.java.
  • Save the file. The factory is now clean and ready for use by other classes.

✔️ Awesome, I've got everything!

Great. Double check that you have removed the temporary main method and saved ExpenseFactory.java.

ⓧ I'd like to double check the full code

Here is the final ExpenseFactory.java without the temporary test code:

package com.expensetracker.factory;

import com.expensetracker.model.*;

import java.time.LocalDate;

public class ExpenseFactory {

    public static Expense createExpense(String category, String description, double amount, LocalDate date, String detail) {
        return switch (category.toLowerCase()) {
            case "food" -> new FoodExpense(description, amount, date, detail);
            case "transport" -> new TransportExpense(description, amount, date, detail);
            case "entertainment" -> new EntertainmentExpense(description, amount, date, detail);
            default -> throw new IllegalArgumentException("Unknown category: " + category);
        };
    }
}

Your factory is working. Any part of the application that needs an expense can call ExpenseFactory.createExpense(...) without knowing which concrete record type it gets back. Next up, you will build swappable sorting strategies so expenses can be viewed in different orders.

Implement the Strategy Pattern

Your factory now creates the right expense type from a single method call. Next, you'll make the expense list sortable in different orders without modifying the tracker itself.

The Strategy pattern lets you define a family of interchangeable algorithms behind a single interface. Instead of hardcoding one sort order, you'll create swappable strategy objects that can be plugged in at runtime.

In this step, get ready to:
  • Define a SortStrategy interface as the contract for all sorting algorithms.
  • Implement two concrete strategies: sort by date and sort by amount.
  • Verify the strategies are interchangeable on the same expense list.
Define the SortStrategy interface

Every sorting algorithm your tracker uses will implement one shared interface. This means the tracker depends on the abstraction, not on any specific sort implementation.

Why an interface instead of separate sort methods?

You could hardcode a sortByDate() and sortByAmount() method directly in the tracker. But every new sort order would force you to edit the tracker class.

With the Strategy pattern, adding a new sort (by category, by description) means creating one new class. The tracker never changes. This is the Open/Closed Principle in action.

  • In the com.expensetracker.strategy package, right-click and select New, then Java Class.
  • Select Interface from the kind dropdown and name it SortStrategy.
  • Replace the generated content with the following code:
package com.expensetracker.strategy;

import com.expensetracker.model.Expense;

import java.util.List;

// Contract that all sorting strategies must fulfill
public interface SortStrategy {
    List<Expense> sort(List<Expense> expenses);
}

What does this code do?

This interface declares a single method: sort(List<Expense> expenses). Any class that implements SortStrategy must provide its own version of this method.

This demonstrates two SOLID principles. The Dependency Inversion Principle (DIP) says depend on abstractions, not concrete classes. The Interface Segregation Principle (ISP) says keep interfaces focused on one job.

  • Save SortStrategy.java.
  • IntelliJ should show no red underlines on the file. You won't see a runnable change yet. The next chunk implements this interface.

✔️ Awesome, I've got everything!

Great. Make sure the file is saved before moving on.

ⓧ I'd like to double check the full code

package com.expensetracker.strategy;

import com.expensetracker.model.Expense;

import java.util.List;

public interface SortStrategy {
    List<Expense> sort(List<Expense> expenses);
}
Implement SortByDate and SortByAmount

Now you'll create two concrete strategies. Each one copies the input list, sorts the copy, and returns it. The original list stays untouched.

  • In the com.expensetracker.strategy package, create a new Java class named SortByDate.
  • Replace the generated content with the following code:
package com.expensetracker.strategy;

import com.expensetracker.model.Expense;

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

public class SortByDate implements SortStrategy {

    @Override
    public List<Expense> sort(List<Expense> expenses) {
        // Create a copy so the original list stays unchanged
        List<Expense> sorted = new ArrayList<>(expenses);
        // Sort the copy by date using a method reference
        sorted.sort(Comparator.comparing(Expense::date));
        return sorted;
    }
}

What does this code do?

  • new ArrayList<>(expenses) creates a fresh copy of the list. Sorting a copy means the caller's original list is never reordered.
  • Comparator.comparing(Expense::date) builds a comparator that extracts the date field from each expense and compares them in natural order (earliest first).

What is the :: syntax?

Expense::date is a method reference. It's shorthand for expense -> expense.date(). You're telling the comparator which method to call on each expense to get the value it should compare.

  • Save SortByDate.java.
  • Confirm IntelliJ shows no red underlines. The @Override annotation should have no errors, confirming the method signature matches SortStrategy.

Seeing a red underline on Expense::date?

  • Check your import at the top includes import com.expensetracker.model.Expense;. Without it, Java does not know which Expense type you mean.
  • Make sure the method parameter is List<Expense> and that you have import java.util.List; at the top.

Help me fix my SortByDate class

✔️ Awesome, I've got everything!

Great. Make sure the file is saved before moving on to SortByAmount.

ⓧ I'd like to double check the full code

package com.expensetracker.strategy;

import com.expensetracker.model.Expense;

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

public class SortByDate implements SortStrategy {

    @Override
    public List<Expense> sort(List<Expense> expenses) {
        List<Expense> sorted = new ArrayList<>(expenses);
        sorted.sort(Comparator.comparing(Expense::date));
        return sorted;
    }
}
  • In the same com.expensetracker.strategy package, create a new Java class named SortByAmount.
  • Replace the generated content with the following code:
package com.expensetracker.strategy;

import com.expensetracker.model.Expense;

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

public class SortByAmount implements SortStrategy {

    @Override
    public List<Expense> sort(List<Expense> expenses) {
        // Create a copy so the original list stays unchanged
        List<Expense> sorted = new ArrayList<>(expenses);
        // Sort the copy by amount (lowest first)
        sorted.sort(Comparator.comparingDouble(Expense::amount));
        return sorted;
    }
}

What's different here?

The only difference is the comparator. Comparator.comparingDouble(Expense::amount) is optimized for primitive double values, avoiding the overhead of boxing each number into a Double object.

  • Save SortByAmount.java.
  • IntelliJ should show no red underlines. Both strategy classes now appear in the strategy package alongside SortStrategy.java.

Seeing an error on comparingDouble?

  • Make sure you have import java.util.Comparator; at the top of the file.
  • Confirm the method reference uses Expense::amount (lowercase a), matching the record field name exactly.

Help me fix my SortByAmount class

✔️ Awesome, I've got everything!

Great. Make sure the file is saved before moving on to the test.

ⓧ I'd like to double check the full code

package com.expensetracker.strategy;

import com.expensetracker.model.Expense;

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

public class SortByAmount implements SortStrategy {

    @Override
    public List<Expense> sort(List<Expense> expenses) {
        List<Expense> sorted = new ArrayList<>(expenses);
        sorted.sort(Comparator.comparingDouble(Expense::amount));
        return sorted;
    }
}
Verify strategies are interchangeable

The key promise of the Strategy pattern is that you can swap algorithms without changing any other code. You'll prove this by sorting the same expense list two different ways.

  • In the com.expensetracker package, create a new Java class named Main (or replace your existing temporary test code from the previous step).
  • Paste the following test code to verify both strategies:
package com.expensetracker;

import com.expensetracker.factory.ExpenseFactory;
import com.expensetracker.model.Expense;
import com.expensetracker.strategy.SortByAmount;
import com.expensetracker.strategy.SortByDate;
import com.expensetracker.strategy.SortStrategy;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        // Create test expenses with different dates and amounts
        List<Expense> expenses = new ArrayList<>();
        expenses.add(ExpenseFactory.createExpense("food", "Sushi dinner", 45.00, LocalDate.of(2025, 6, 10), "dinner"));
        expenses.add(ExpenseFactory.createExpense("transport", "Uber to airport", 32.50, LocalDate.of(2025, 6, 8), "taxi"));
        expenses.add(ExpenseFactory.createExpense("entertainment", "Concert tickets", 120.00, LocalDate.of(2025, 6, 9), "Madison Square Garden"));

        // Sort using the date strategy
        SortStrategy dateStrategy = new SortByDate();
        List<Expense> byDate = dateStrategy.sort(expenses);
        System.out.println("Sorted by date:");
        for (Expense e : byDate) {
            System.out.println("  " + e.date() + " | " + e.description() + " | $" + e.amount());
        }

        // Sort using the amount strategy
        SortStrategy amountStrategy = new SortByAmount();
        List<Expense> byAmount = amountStrategy.sort(expenses);
        System.out.println("\nSorted by amount:");
        for (Expense e : byAmount) {
            System.out.println("  " + e.date() + " | " + e.description() + " | $" + e.amount());
        }
    }
}

What does this code do?

  • Both dateStrategy and amountStrategy are declared as type SortStrategy. The calling code never knows or cares which concrete implementation is behind the interface.
  • Both strategies call .sort(expenses) on the same original list. Because each strategy copies the list internally, the original is never modified.
  • This is polymorphism in action. Same method call, different behavior depending on the object behind the interface.
  • Save Main.java.
  • Right-click Main.java in the project explorer and select Run 'Main.main()'.
  • In the console output, you should see the same three expenses printed in two different orders. The date sort shows Uber first (June 8), Concert second (June 9), Sushi last (June 10). The amount sort shows Uber first ($32.50), Sushi second ($45.00), Concert last ($120.00).

Output not in the expected order?

  • If both outputs show the same order, check that the date-sort strategy uses Comparator.comparing(Expense::date) and the amount-sort uses Comparator.comparingDouble(Expense::amount).
  • If you see a compile error about incompatible types, make sure both strategy classes implement SortStrategy and that the return type is List<Expense>.

Help me debug my Strategy pattern output

Why does this matter in interviews?

Interviewers love the Strategy pattern because it demonstrates that you understand how to make code extensible. When asked "how would you add a sort-by-category feature?", you can answer: "create one new class that implements SortStrategy. Nothing else changes."

Your tracker now has interchangeable sorting algorithms. Next up, you'll add automatic budget monitoring using the Observer pattern. When spending crosses the budget threshold, alerts fire without the expense code knowing about budgets at all.

Implement the Observer Pattern

Your factory creates expenses and your strategies sort them. But right now, nothing watches what you spend. If you blow through your budget, the app stays silent.

The Observer pattern solves this by letting objects subscribe to events without the event source knowing who's listening. In this step, you'll build a budget monitoring system that fires alerts automatically when spending crosses a threshold.

In this step, get ready to:
  • Define the BudgetListener interface and its BudgetAlert implementation.
  • Build the ExpenseTracker class as the Observer subject that notifies listeners.
  • Wire the observer and verify budget alerts fire automatically.
Define the BudgetListener interface and BudgetAlert implementation

The Observer pattern has two sides: a listener interface that defines the callback contract, and concrete implementations that react when notified. You'll create both now in a new com.expensetracker.observer package.

  • In IntelliJ, right-click the com.expensetracker package in the Project sidebar.
  • Select New, then Package.
  • Name it observer and press Enter.
  • Create BudgetListener.java in the com.expensetracker.observer package by right-clicking the package, selecting New, then Java Class.
  • Name it BudgetListener and select Interface from the kind dropdown.
  • Replace the file contents with the following code:
package com.expensetracker.observer;

public interface BudgetListener {
    void onBudgetExceeded(double totalSpent, double budget);
}

What does this code do?

This interface defines the contract for any object that wants to be notified about budget violations. It declares a single method onBudgetExceeded() that receives the current total and the budget limit.

Any class that implements this interface becomes a valid observer. You could add logging observers, email notification observers, or analytics observers later without changing the tracker itself.

  • Save BudgetListener.java.
  • Create BudgetAlert.java in the same com.expensetracker.observer package by right-clicking the package, selecting New, then Java Class.
  • Name it BudgetAlert and leave the kind as Class.
  • Replace the file contents with the following code:
package com.expensetracker.observer;

public class BudgetAlert implements BudgetListener {

    @Override
    public void onBudgetExceeded(double totalSpent, double budget) {
        System.out.printf("ALERT: Budget exceeded! Spent: $%.2f / Budget: $%.2f%n", totalSpent, budget);
    }
}

What does this code do?

BudgetAlert is a concrete observer. When notified, it prints a formatted alert showing how much you've spent versus your budget.

The @Override annotation tells the compiler to verify this method actually fulfills the BudgetListener contract. If you misspell the method name, the compiler catches it immediately.

  • Save BudgetAlert.java.
  • Confirm both files compile without errors. IntelliJ should show no red underlines in either file.

Seeing red underlines?

  • Check that both files are in the com.expensetracker.observer package. The package statement at the top must match the folder structure.
  • Make sure BudgetAlert says implements BudgetListener in the class declaration.
  • Why are my observer files not compiling?
Build the ExpenseTracker class

The ExpenseTracker is the Subject in the Observer pattern. It manages expenses, holds a list of listeners, and notifies them when spending exceeds the budget. It also integrates the Strategy pattern by delegating sorting to a swappable SortStrategy.

  • Create ExpenseTracker.java in the com.expensetracker package (the root package, not a sub-package).
  • Add the class structure, fields, constructor, and observer methods by pasting the following code:
package com.expensetracker;

import com.expensetracker.model.*;
import com.expensetracker.observer.BudgetListener;
import com.expensetracker.strategy.SortStrategy;

import java.util.ArrayList;
import java.util.List;

public class ExpenseTracker {

    private final List<Expense> expenses = new ArrayList<>();
    private final List<BudgetListener> listeners = new ArrayList<>();
    private final double budget;
    private SortStrategy sortStrategy;

    public ExpenseTracker(double budget) {
        this.budget = budget;
    }

    // Adds an expense and checks if budget is exceeded
    public void addExpense(Expense expense) {
        expenses.add(expense);
        double total = getTotalSpent();
        if (total > budget) {
            notifyListeners(total);
        }
    }

    // Register an observer to receive budget alerts
    public void addListener(BudgetListener listener) {
        listeners.add(listener);
    }

    // Unregister an observer
    public void removeListener(BudgetListener listener) {
        listeners.remove(listener);
    }

    // Notify all registered listeners when budget is exceeded
    private void notifyListeners(double totalSpent) {
        for (BudgetListener listener : listeners) {
            listener.onBudgetExceeded(totalSpent, budget);
        }
    }

    // Set the sorting strategy at runtime
    public void setSortStrategy(SortStrategy strategy) {
        this.sortStrategy = strategy;
    }

    // Return expenses sorted by the current strategy
    public List<Expense> getSortedExpenses() {
        if (sortStrategy == null) {
            return new ArrayList<>(expenses);
        }
        return sortStrategy.sort(expenses);
    }

    // Calculate total spending across all expenses
    public double getTotalSpent() {
        double total = 0;
        for (Expense expense : expenses) {
            total += expense.amount();
        }
        return total;
    }

    public double getBudget() {
        return budget;
    }

    // Return a defensive copy of the expense list
    public List<Expense> getExpenses() {
        return new ArrayList<>(expenses);
    }
}

What does this code do?

  • The listeners list holds all registered observers. Any class implementing BudgetListener can subscribe.
  • addExpense() does two things: stores the expense, then checks if the total exceeds the budget. If it does, it calls notifyListeners(). The expense-adding code never knows what the listeners do with the alert.
  • getSortedExpenses() delegates to the current SortStrategy. If no strategy is set, it returns a copy of the list in insertion order.
  • getExpenses() returns a defensive copy so that callers cannot modify the internal list directly.

Why decouple with Observer instead of a simple if-check?

You could check the budget inside addExpense() and print directly. But that couples budget logic to expense management. With Observer, you could add logging, analytics, or push notification observers without touching ExpenseTracker at all.

This is the Single Responsibility Principle in action. The tracker tracks expenses. The alert handles alerting. Neither knows the details of the other.

  • Save ExpenseTracker.java. You won't see a change yet. The next chunk adds the display method that uses pattern matching.

Now add the displayExpenses() method. This method uses pattern matching on the sealed Expense interface to extract type-specific details without a default case.

  • In ExpenseTracker.java, add the following method inside the class, just before the closing } brace:
    // Display all expenses with type-specific details using pattern matching
    public void displayExpenses() {
        List<Expense> displayList = getSortedExpenses();
        System.out.println("\n--- Expense Report ---");
        System.out.printf("%-20s %-10s %-12s %-15s%n", "Description", "Amount", "Date", "Detail");
        System.out.println("-".repeat(60));
        for (Expense expense : displayList) {
            // Pattern matching on sealed type - no default needed
            String detail = switch (expense) {
                case FoodExpense f -> f.mealType();
                case TransportExpense t -> t.vehicleType();
                case EntertainmentExpense e -> e.venue();
            };
            System.out.printf("%-20s $%-9.2f %-12s %-15s%n",
                    expense.description(), expense.amount(), expense.date(), detail);
        }
        System.out.println("-".repeat(60));
        System.out.printf("Total: $%.2f / Budget: $%.2f%n", getTotalSpent(), budget);
    }

Why does this switch have no default case?

Because Expense is a sealed interface with exactly three permitted types, the compiler knows the switch is exhaustive. Every possible type is handled.

If you ever add a fourth expense type to the permits clause, the compiler will force you to handle it here. This is a key interview talking point about sealed types.

  • Save ExpenseTracker.java.
  • Confirm there are no red underlines anywhere in the file. IntelliJ should recognise all three record types from the sealed interface.

Seeing errors on the switch expression?

  • Check that your import statement says import com.expensetracker.model.*; at the top of the file. Without it, the compiler cannot resolve the record types.
  • Make sure your project SDK is set to JDK 25. Go to File > Project Structure > Project and verify the SDK.
  • Why is my pattern matching switch showing errors in IntelliJ?

✔️ Awesome, I've got everything!

Great. Make sure you've saved ExpenseTracker.java with both the core methods and the displayExpenses() method.

ⓧ I'd like to double check the full code

package com.expensetracker;

import com.expensetracker.model.*;
import com.expensetracker.observer.BudgetListener;
import com.expensetracker.strategy.SortStrategy;

import java.util.ArrayList;
import java.util.List;

public class ExpenseTracker {

    private final List<Expense> expenses = new ArrayList<>();
    private final List<BudgetListener> listeners = new ArrayList<>();
    private final double budget;
    private SortStrategy sortStrategy;

    public ExpenseTracker(double budget) {
        this.budget = budget;
    }

    public void addExpense(Expense expense) {
        expenses.add(expense);
        double total = getTotalSpent();
        if (total > budget) {
            notifyListeners(total);
        }
    }

    public void addListener(BudgetListener listener) {
        listeners.add(listener);
    }

    public void removeListener(BudgetListener listener) {
        listeners.remove(listener);
    }

    private void notifyListeners(double totalSpent) {
        for (BudgetListener listener : listeners) {
            listener.onBudgetExceeded(totalSpent, budget);
        }
    }

    public void setSortStrategy(SortStrategy strategy) {
        this.sortStrategy = strategy;
    }

    public List<Expense> getSortedExpenses() {
        if (sortStrategy == null) {
            return new ArrayList<>(expenses);
        }
        return sortStrategy.sort(expenses);
    }

    public double getTotalSpent() {
        double total = 0;
        for (Expense expense : expenses) {
            total += expense.amount();
        }
        return total;
    }

    public double getBudget() {
        return budget;
    }

    public List<Expense> getExpenses() {
        return new ArrayList<>(expenses);
    }

    public void displayExpenses() {
        List<Expense> displayList = getSortedExpenses();
        System.out.println("\n--- Expense Report ---");
        System.out.printf("%-20s %-10s %-12s %-15s%n", "Description", "Amount", "Date", "Detail");
        System.out.println("-".repeat(60));
        for (Expense expense : displayList) {
            String detail = switch (expense) {
                case FoodExpense f -> f.mealType();
                case TransportExpense t -> t.vehicleType();
                case EntertainmentExpense e -> e.venue();
            };
            System.out.printf("%-20s $%-9.2f %-12s %-15s%n",
                    expense.description(), expense.amount(), expense.date(), detail);
        }
        System.out.println("-".repeat(60));
        System.out.printf("Total: $%.2f / Budget: $%.2f%n", getTotalSpent(), budget);
    }
}
Wire the observer and verify budget alerts

Time to prove the Observer pattern works. You'll create a temporary main method that registers a BudgetAlert listener, adds expenses that exceed the budget, and confirms the alert fires automatically.

  • In ExpenseTracker.java, add a temporary main method inside the class (just before the closing } brace) by pasting the following code:
    // TEMPORARY - remove after testing
    public static void main(String[] args) {
        // Create a tracker with a $50 budget
        ExpenseTracker tracker = new ExpenseTracker(50.0);

        // Register the budget alert observer
        tracker.addListener(new com.expensetracker.observer.BudgetAlert());

        // Add expenses - the third one should push past budget
        tracker.addExpense(new com.expensetracker.model.FoodExpense("Lunch", 15.0, java.time.LocalDate.now(), "lunch"));
        tracker.addExpense(new com.expensetracker.model.TransportExpense("Taxi", 25.0, java.time.LocalDate.now(), "taxi"));
        tracker.addExpense(new com.expensetracker.model.EntertainmentExpense("Movie", 20.0, java.time.LocalDate.now(), "Cinema"));

        // Display all expenses
        tracker.displayExpenses();
    }

What does this test do?

  • It creates a tracker with a $50 budget and registers BudgetAlert as an observer.
  • The first two expenses total $40 (under budget, no alert). The third expense pushes the total to $60 (over budget). The observer fires automatically on that third add.
  • displayExpenses() then prints all expenses in a formatted table using pattern matching on the sealed type.
  • Save ExpenseTracker.java.
  • Right-click inside the main method and select Run 'ExpenseTracker.main()'.
  • In the console output, you should see the budget alert print when the third expense is added, followed by the formatted expense table.

What should I see?

The console should show something like:

ALERT: Budget exceeded! Spent: $60.00 / Budget: $50.00

--- Expense Report ---
Description          Amount     Date         Detail
------------------------------------------------------------
Lunch                $15.00     2026-06-12   lunch
Taxi                 $25.00     2026-06-12   taxi
Movie                $20.00     2026-06-12   Cinema
------------------------------------------------------------
Total: $60.00 / Budget: $50.00

The alert appears before the table because the observer fires inside addExpense(), at the moment the budget is exceeded. The date will match today's date.

Alert not printing?

  • Make sure tracker.addListener(new BudgetAlert()) is called before you add any expenses. Listeners must be registered before the event they're listening for.
  • Check that the expense amounts total more than the budget value (50.0). If the total is exactly 50 or under, the alert won't fire because the check is total > budget (strictly greater).
  • Why is my BudgetAlert not firing when I exceed the budget?
  • Once you've confirmed the alert prints, delete the entire temporary main method from ExpenseTracker.java.
  • Save the file. You'll build the real Main.java in the next step.

All three design patterns are now implemented and tested individually. Next up, you'll wire Factory, Strategy, and Observer together into a fully interactive CLI application.

Wire Everything Into the CLI

You now have three powerful design patterns sitting in separate packages. The Factory creates expenses, the Strategy sorts them, and the Observer watches spending. They just need a conductor to orchestrate them.

In this step, you will build the CLI menu that ties everything together into a working application. The user will interact with a simple numbered menu, and behind the scenes your patterns will do the heavy lifting.

In this step, get ready to:
  • Create the main method with a menu loop.
  • Implement the addExpense helper that delegates to your Factory.
  • Run and test all three patterns working together end-to-end.
Create Main.java with the menu loop

The Main class is the entry point that wires your Factory, Strategy, and Observer together. It prompts for a budget, registers the alert listener, and loops through a menu until the user exits.

  • In the com.expensetracker package, create a new Java class named Main.
  • Add the following code to Main.java:
package com.expensetracker;

import com.expensetracker.factory.ExpenseFactory;
import com.expensetracker.model.Expense;
import com.expensetracker.observer.BudgetAlert;
import com.expensetracker.strategy.SortByAmount;
import com.expensetracker.strategy.SortByDate;

import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.Scanner;

public class Main {

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);

        // Prompt user for their monthly budget
        System.out.print("Set your monthly budget: $");
        double budget = scanner.nextDouble();
        scanner.nextLine();

        // Create the tracker and register the budget observer
        ExpenseTracker tracker = new ExpenseTracker(budget);
        tracker.addListener(new BudgetAlert());

        // Menu loop
        boolean running = true;
        while (running) {
            System.out.println("\n--- Expense Tracker Menu ---");
            System.out.println("1. Add Expense");
            System.out.println("2. View Expenses (sorted by date)");
            System.out.println("3. View Expenses (sorted by amount)");
            System.out.println("4. View Total");
            System.out.println("5. Exit");
            System.out.print("Choice: ");

            String choice = scanner.nextLine();
            switch (choice) {
                case "1" -> addExpense(scanner, tracker);
                case "2" -> {
                    tracker.setSortStrategy(new SortByDate());
                    tracker.displayExpenses();
                }
                case "3" -> {
                    tracker.setSortStrategy(new SortByAmount());
                    tracker.displayExpenses();
                }
                case "4" -> System.out.printf("Total spent: $%.2f / Budget: $%.2f%n",
                        tracker.getTotalSpent(), tracker.getBudget());
                case "5" -> running = false;
                default -> System.out.println("Invalid choice.");
            }
        }
        scanner.close();
        System.out.println("Goodbye!");
    }
}

What does this code do?

The scanner.nextLine() after nextDouble() clears the leftover newline character from the input buffer. Without it, the first menu read would grab an empty string.

The switch expression on choice uses the arrow syntax you saw in the Factory. Each case delegates to the appropriate pattern. Option 2 sets the Strategy to SortByDate before displaying. Option 3 swaps it to SortByAmount. The tracker itself never changes.

You will see a red underline on addExpense(scanner, tracker) because that method does not exist yet. You will create it in the next substep.

Implement the addExpense helper method

The helper method handles all user input for a single expense. It collects the category, description, amount, date, and a category-specific detail, then delegates creation to your ExpenseFactory.

  • In Main.java, add the following method below the closing brace of main() but still inside the Main class:
    private static void addExpense(Scanner scanner, ExpenseTracker tracker) {
        System.out.print("Category (food/transport/entertainment): ");
        String category = scanner.nextLine();

        System.out.print("Description: ");
        String description = scanner.nextLine();

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

        // Parse the date with error handling
        System.out.print("Date (YYYY-MM-DD): ");
        String dateStr = scanner.nextLine();
        LocalDate date;
        try {
            date = LocalDate.parse(dateStr);
        } catch (DateTimeParseException e) {
            System.out.println("Invalid date format. Using today's date.");
            date = LocalDate.now();
        }

        // Switch expression picks the right detail prompt per category
        String detailPrompt = switch (category.toLowerCase()) {
            case "food" -> "Meal type (breakfast/lunch/dinner/snack): ";
            case "transport" -> "Vehicle type (bus/train/taxi/car): ";
            case "entertainment" -> "Venue: ";
            default -> "Detail: ";
        };
        System.out.print(detailPrompt);
        String detail = scanner.nextLine();

        // Delegate creation to the Factory and addition to the Tracker
        try {
            Expense expense = ExpenseFactory.createExpense(category, description, amount, date, detail);
            tracker.addExpense(expense);
            System.out.println("Expense added successfully!");
        } catch (IllegalArgumentException e) {
            System.out.println("Error: " + e.getMessage());
        }
    }

What does this code do?

The try/catch around LocalDate.parse() handles invalid date input gracefully. If the user enters a badly formatted date, the app falls back to today instead of crashing.

The switch expression on category.toLowerCase() customizes the detail prompt per expense type. This is a different use of switch expressions than the Factory. Here it returns a String value rather than constructing an object.

The outer try/catch around ExpenseFactory.createExpense() catches the IllegalArgumentException your Factory throws for unknown categories. The user sees a friendly error message instead of a stack trace.

  • Save Main.java.
  • Confirm IntelliJ shows no red underlines anywhere in the file.

Seeing red underlines on imports?

Make sure Main.java is in the com.expensetracker package (the same package as ExpenseTracker.java). If you accidentally created it in a sub-package, IntelliJ cannot resolve the import for ExpenseTracker.

Also confirm your earlier files compiled without errors. A broken ExpenseFactory or BudgetAlert will cascade red underlines into Main.java.

help me fix import errors in Main.java

Run and test the full application

All three patterns are now connected. Time to run the application and watch them work together.

  • Right-click Main.java in the IntelliJ project explorer.
  • Click Run 'Main.main()'.
  • Enter a budget of 100 when prompted.

Now test each pattern in sequence:

  • Choose option 1 to add a food expense. Enter food as the category, Sushi as the description, 25 as the amount, 2025-06-01 as the date, and lunch as the meal type.
  • Add another expense with category transport, description Uber, amount 15, date 2025-06-03, and vehicle type taxi.
  • Choose option 2 to view expenses sorted by date. Confirm Sushi appears before Uber.
  • Choose option 3 to view expenses sorted by amount. Confirm Uber (15) appears before Sushi (25).
  • Add an entertainment expense with amount 70 to push the total past your $100 budget. You should see the budget alert fire automatically.
  • Choose option 5 to exit. Confirm you see Goodbye! printed.

What just happened?

In a single run, you exercised all three design patterns working together. The Factory Method created the correct record type from a category string. The Strategy swapped sorting algorithms without modifying the tracker. The Observer fired automatically when spending crossed the budget.

None of these patterns know about each other. That independence is exactly what makes them powerful in real applications and what interviewers want to hear you explain.

Budget alert not firing?

Confirm your total exceeds the budget you entered. If you set $100 and your expenses total exactly $100, the alert will not fire. The check is total > budget (strictly greater than), so you need to go above $100.

Also verify that tracker.addListener(new BudgetAlert()) is called before any expenses are added. If the listener is registered after the overspend, it misses the notification.

help me debug my Observer pattern

✔️ Awesome, I've got everything!

Great. Make sure you have saved Main.java and that the application exits cleanly.

ⓧ I'd like to double check the full code

Here is the complete Main.java file:

package com.expensetracker;

import com.expensetracker.factory.ExpenseFactory;
import com.expensetracker.model.Expense;
import com.expensetracker.observer.BudgetAlert;
import com.expensetracker.strategy.SortByAmount;
import com.expensetracker.strategy.SortByDate;

import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.Scanner;

public class Main {

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);

        System.out.print("Set your monthly budget: $");
        double budget = scanner.nextDouble();
        scanner.nextLine();

        ExpenseTracker tracker = new ExpenseTracker(budget);
        tracker.addListener(new BudgetAlert());

        boolean running = true;
        while (running) {
            System.out.println("\n--- Expense Tracker Menu ---");
            System.out.println("1. Add Expense");
            System.out.println("2. View Expenses (sorted by date)");
            System.out.println("3. View Expenses (sorted by amount)");
            System.out.println("4. View Total");
            System.out.println("5. Exit");
            System.out.print("Choice: ");

            String choice = scanner.nextLine();
            switch (choice) {
                case "1" -> addExpense(scanner, tracker);
                case "2" -> {
                    tracker.setSortStrategy(new SortByDate());
                    tracker.displayExpenses();
                }
                case "3" -> {
                    tracker.setSortStrategy(new SortByAmount());
                    tracker.displayExpenses();
                }
                case "4" -> System.out.printf("Total spent: $%.2f / Budget: $%.2f%n",
                        tracker.getTotalSpent(), tracker.getBudget());
                case "5" -> running = false;
                default -> System.out.println("Invalid choice.");
            }
        }
        scanner.close();
        System.out.println("Goodbye!");
    }

    private static void addExpense(Scanner scanner, ExpenseTracker tracker) {
        System.out.print("Category (food/transport/entertainment): ");
        String category = scanner.nextLine();

        System.out.print("Description: ");
        String description = scanner.nextLine();

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

        System.out.print("Date (YYYY-MM-DD): ");
        String dateStr = scanner.nextLine();
        LocalDate date;
        try {
            date = LocalDate.parse(dateStr);
        } catch (DateTimeParseException e) {
            System.out.println("Invalid date format. Using today's date.");
            date = LocalDate.now();
        }

        String detailPrompt = switch (category.toLowerCase()) {
            case "food" -> "Meal type (breakfast/lunch/dinner/snack): ";
            case "transport" -> "Vehicle type (bus/train/taxi/car): ";
            case "entertainment" -> "Venue: ";
            default -> "Detail: ";
        };
        System.out.print(detailPrompt);
        String detail = scanner.nextLine();

        try {
            Expense expense = ExpenseFactory.createExpense(category, description, amount, date, detail);
            tracker.addExpense(expense);
            System.out.println("Expense added successfully!");
        } catch (IllegalArgumentException e) {
            System.out.println("Error: " + e.getMessage());
        }
    }
}

Secret mission

Builder Pattern for Expense Reports

Your expense tracker works, but what if someone asks for a report? Add the Builder pattern to generate configurable expense reports with optional sections like category breakdowns and summary totals. This is the 4th most asked design pattern in Java interviews.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files or delete them entirely.

Resources you used:

  • The ExpenseTracker IntelliJ IDEA project (local files on your machine).

Keep everything running

No action needed. Choose this if you plan to keep building on the project or want to reference it later.

  • Your ExpenseTracker project stays in IntelliJ IDEA and on your filesystem exactly as it is.
  • You can reopen it anytime from IntelliJ's welcome screen.

Pause - I'll come back to this later

Since this is a local project with no running processes, there is nothing to pause. Your files stay on disk until you delete them.

  • Close the project in IntelliJ IDEA by clicking File, then Close Project.
  • Your source files remain on your machine, ready to reopen whenever you want.

Delete - I don't want to use this again

Remove the project files from your machine entirely.

  • Close the project in IntelliJ IDEA by clicking File, then Close Project.
  • Delete the project folder from your filesystem by running the appropriate command for your OS:

macOS

rm -rf ~/IdeaProjects/ExpenseTracker

Windows

rmdir /s /q %USERPROFILE%\IdeaProjects\ExpenseTracker
  • Confirm the folder is gone by checking your IdeaProjects directory. The ExpenseTracker folder should no longer appear.

Nice Work!

Nice Work!

You just built a full command-line expense tracker using modern Java OOP and three of the most interview-relevant design patterns.

You've learned how to:

  • Model a domain with sealed interfaces and records, then use pattern matching in switch for exhaustive type handling with no default case needed.
  • Implement Factory Method to centralize object creation, Strategy to swap sorting algorithms at runtime, and Observer to decouple budget monitoring from expense management.
  • Practice SOLID principles (SRP, OCP, DIP, ISP) through natural design decisions that you can articulate in interviews.
  • Secret Mission: Implemented the Builder pattern with a fluent API for configurable report generation.

Ready to quiz yourself?