Skip to content

How to Use Java Records: Build an Expense Tracker (Java 21)

Build a small expense tracker with Java 21 records: compact constructors, validation, defensive copies, with-ers and the array trap, all compiled and run.

how-to-tutorials modern-java core-java
Gopi Gorantala
Reading Progress

On This Page

Start with the finished app

This is what the tracker prints when you run it, and the whole thing is four small files:

Expense[description=Team lunch, cost=42.50 USD, category=FOOD, date=2026-10-20, tags=[team]]
true
CategoryTotal[category=FOOD, total=47.00 USD]
CategoryTotal[category=TRAVEL, total=89.00 USD]
CategoryTotal[category=SOFTWARE, total=16.90 USD]

No getters, no equals, no toString, no builder. Every one of those lines comes from a record. If you're wondering how to use Java records beyond the one-line record Point(int x, int y) {} from the release notes, this is the version with validation, money, collections and the mistakes I made along the way.

I took about forty minutes end to end. Most of that went on one bug: 42.5 and 42.50 were not equal, and I'll show you where that bit me.

You should be comfortable with classes, List, and streams. You don't need a build tool for this one. A JDK and a terminal are enough.

Tools and versions

Tool Version I used Check it with
JDK OpenJDK 21.0.12.1 (Ubuntu build) java -version
Compiler javac 21.0.12.1 javac -version
Build tool none, plain javac n/a

Records became a final language feature in Java 16, so anything from 16 up works. I'd use 21 because it's the LTS most teams run. If javac -version prints anything below 16, jump to the troubleshooting section first.

I didn't use JUnit here. My build environment couldn't download dependencies this run, so the tests in this article are a plain Java class with a few assertions that I did run. The JUnit version is in the going-further section, clearly marked as untested by me.

Create the project

I used the command line as the primary path, because it shows exactly which files exist.

mkdir -p expense-tracker/src/main/java/com/example/expenses
mkdir -p expense-tracker/src/test/java/com/example/expenses
cd expense-tracker

In IntelliJ IDEA

Choose File → New → Project, pick Java, set the JDK to 21 and leave the build system as IntelliJ. Create the package com.example.expenses under src. I couldn't click through this myself, so the menu path is from the vendor documentation, not from a run.

In Eclipse

Choose File → New → Java Project, set the execution environment to JavaSE-21, then right-click src and pick New → Package. Same caveat: not executed by me.

The tree you're aiming for:

expense-tracker/
└── src/
    ├── main/java/com/example/expenses/
    │   ├── App.java
    │   ├── Category.java
    │   ├── Expense.java
    │   └── Money.java
    └── test/java/com/example/expenses/
        └── ExpenseChecks.java

Write the code

Step 1: Add the category enum

Goal: something simple to group expenses by, so the later steps have something to total.

src/main/java/com/example/expenses/Category.java

package com.example.expenses;

public enum Category {
    FOOD, TRAVEL, SOFTWARE, OTHER
}

Checkpoint: run javac -d out src/main/java/com/example/expenses/Category.java. No output means it compiled.

Step 2: Write Money as a record

Goal: a value type that keeps its amount rounded the right way for its currency.

src/main/java/com/example/expenses/Money.java

package com.example.expenses;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Currency;

public record Money(BigDecimal amount, Currency currency) {

    // Compact constructor: no parameter list, no field assignments.
    // Fields are assigned for you after this block finishes.
    public Money {
        if (amount == null || currency == null) {
            throw new IllegalArgumentException("amount and currency are required");
        }
        // Reassigning the parameter changes what gets stored.
        amount = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.HALF_UP);
    }

    // Extra constructors must delegate to the canonical one.
    public Money(String amount, String currencyCode) {
        this(new BigDecimal(amount), Currency.getInstance(currencyCode));
    }

    public static Money zero(Currency currency) {
        return new Money(BigDecimal.ZERO, currency);
    }

    public Money plus(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException(
                    "Cannot add " + other.currency + " to " + currency);
        }
        return new Money(amount.add(other.amount), currency);
    }

    @Override
    public String toString() {
        return amount.toPlainString() + " " + currency.getCurrencyCode();
    }
}

The header, record Money(BigDecimal amount, Currency currency), declares two components. The compiler turns them into two private final fields, two accessor methods named amount() and currency(), a canonical constructor, plus equals, hashCode and toString.

The block that looks like a constructor with no parentheses is the compact constructor. It runs first, and you validate or normalise the parameters inside it. When it ends, the compiler assigns the fields from the (possibly reassigned) parameters. That's why amount = amount.setScale(...) works without a this..

The rounding line is the one that matters for equality. BigDecimal treats 42.5 and 42.50 as different values in equals, because their scales differ. Forcing the scale in the constructor makes two Money values equal whenever they represent the same amount. I found that out the slow way, when my first equality test printed false.

I overrode toString because the default prints Money[amount=42.50, currency=USD], which is noisy in logs. Overriding a generated method is allowed; you write it out.

Checkpoint: compile both files together.

javac -d out src/main/java/com/example/expenses/Category.java src/main/java/com/example/expenses/Money.java

You should see no output.

Step 3: Write Expense with validation and a defensive copy

Goal: the main record, with rules that make an invalid expense impossible to construct.

src/main/java/com/example/expenses/Expense.java

package com.example.expenses;

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

public record Expense(String description, Money cost, Category category,
                      LocalDate date, List<String> tags) {

    public Expense {
        if (description == null || description.isBlank()) {
            throw new IllegalArgumentException("description must not be blank");
        }
        if (cost.amount().signum() <= 0) {
            throw new IllegalArgumentException("cost must be positive, got " + cost);
        }
        // Defensive copy: List.copyOf is immutable and rejects null elements.
        tags = List.copyOf(tags);
    }

    // A "with-er": records are immutable, so you build a changed copy.
    public Expense withCategory(Category newCategory) {
        return new Expense(description, cost, newCategory, date, tags);
    }
}

A record is shallowly immutable: the fields can't be reassigned, but a List component is still whatever list the caller handed you. Without List.copyOf, a caller could keep their reference and change your tags later. The copy fixes that, and as a bonus the stored list rejects add.

Records have no setters, so changing one value means making a new record. The withCategory method is the usual pattern. If you need ten of them, a builder or a library like Lombok's @With starts to look attractive.

Step 4: Total expenses per category

Goal: show a record doing a second job as a throwaway result type.

src/main/java/com/example/expenses/App.java

package com.example.expenses;

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

public class App {

    // A local-ish helper record: one-off shape for a stream result.
    record CategoryTotal(Category category, Money total) {}

    static List<CategoryTotal> totals(List<Expense> expenses) {
        Map<Category, Money> byCategory = new EnumMap<>(Category.class);
        for (Expense e : expenses) {
            byCategory.merge(e.category(), e.cost(), Money::plus);
        }
        return byCategory.entrySet().stream()
                .map(en -> new CategoryTotal(en.getKey(), en.getValue()))
                .collect(Collectors.toList());
    }

    public static void main(String[] args) {
        var lunch = new Expense("Team lunch", new Money("42.50", "USD"),
                Category.FOOD, LocalDate.of(2026, 10, 20), List.of("team"));
        var train = new Expense("Train to Boston", new Money("89", "USD"),
                Category.TRAVEL, LocalDate.of(2026, 10, 21), List.of("client", "q4"));
        var ide = new Expense("IDE licence", new Money("16.9", "USD"),
                Category.SOFTWARE, LocalDate.of(2026, 10, 22), List.of());
        var coffee = new Expense("Coffee", new Money("4.5", "USD"),
                Category.FOOD, LocalDate.of(2026, 10, 22), List.of("team"));

        System.out.println(lunch);
        System.out.println(lunch.equals(new Expense("Team lunch", new Money("42.5", "USD"),
                Category.FOOD, LocalDate.of(2026, 10, 20), List.of("team"))));

        totals(List.of(lunch, train, ide, coffee)).forEach(System.out::println);
    }
}

CategoryTotal is a nested record, which is implicitly static, so it doesn't capture an App instance. Records can also be declared inside a method body when you only need them there. The EnumMap keeps the output in enum order, which is why FOOD comes before TRAVEL.

Run it

javac -d out $(find src/main -name '*.java')
java -cp out com.example.expenses.App

On Windows PowerShell, list the files explicitly instead of using find.

Expense[description=Team lunch, cost=42.50 USD, category=FOOD, date=2026-10-20, tags=[team]]
true
CategoryTotal[category=FOOD, total=47.00 USD]
CategoryTotal[category=TRAVEL, total=89.00 USD]
CategoryTotal[category=SOFTWARE, total=16.90 USD]

The second line is the one to look at. I built a second Expense with "42.5" instead of "42.50" and it compared equal to the first. Remove the setScale line from Money and it prints false.

If you see all five lines, the hard part's done.

Prove it with checks

src/test/java/com/example/expenses/ExpenseChecks.java

package com.example.expenses;

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

public class ExpenseChecks {

    static int run = 0;

    static void check(String name, boolean ok) {
        run++;
        if (!ok) throw new AssertionError("FAILED: " + name);
        System.out.println("ok   " + name);
    }

    static void throwsIae(String name, Runnable r) {
        try { r.run(); } catch (IllegalArgumentException e) { check(name + " -> " + e.getMessage(), true); return; }
        check(name, false);
    }

    public static void main(String[] args) {
        check("money normalises scale so 42.5 equals 42.50",
                new Money("42.5", "USD").equals(new Money("42.50", "USD")));
        check("JPY has zero decimals", new Money("100.4", "JPY").amount().toPlainString().equals("100"));
        throwsIae("mixed currencies", () -> new Money("1", "USD").plus(new Money("1", "EUR")));
        throwsIae("blank description", () -> new Expense(" ", new Money("5", "USD"),
                Category.OTHER, LocalDate.now(), List.of()));
        throwsIae("zero cost", () -> new Expense("x", new Money("0", "USD"),
                Category.OTHER, LocalDate.now(), List.of()));

        List<String> tags = new ArrayList<>(List.of("a"));
        var e = new Expense("x", new Money("5", "USD"), Category.OTHER, LocalDate.of(2026, 1, 1), tags);
        tags.add("b");
        check("defensive copy ignores later changes", e.tags().equals(List.of("a")));
        try { e.tags().add("c"); check("tags immutable", false); }
        catch (UnsupportedOperationException ex) { check("tags immutable", true); }

        var moved = e.withCategory(Category.FOOD);
        check("with-er returns a copy", moved.category() == Category.FOOD && e.category() == Category.OTHER);
        check("hashCode matches for equal records",
                e.hashCode() == e.withCategory(Category.OTHER).hashCode());
        System.out.println(run + " checks passed");
    }
}

This is a hand-rolled harness, not JUnit. It's ugly on purpose; the point is that every behaviour you've built has a check.

javac -d out $(find src -name '*.java')
java -ea -cp out com.example.expenses.ExpenseChecks
ok   money normalises scale so 42.5 equals 42.50
ok   JPY has zero decimals
ok   mixed currencies -> Cannot add EUR to USD
ok   blank description -> description must not be blank
ok   zero cost -> cost must be positive, got 0.00 USD
ok   defensive copy ignores later changes
ok   tags immutable
ok   with-er returns a copy
ok   hashCode matches for equal records
9 checks passed

What the compiler generates for you

Every record gets four things, and you only write the ones you want to change.

The fields are private and final, one per component. The accessors have the component's name, not a get prefix, so it's expense.cost() and not expense.getCost(). equals and hashCode compare every component, using equals for objects and the primitive rules for primitives. toString prints the type name and each component.

Two rules explain most of the compile errors you'll hit. A record can't have instance fields beyond its components, because the components are its entire state. And a record is implicitly final and extends java.lang.Record, so it can't extend a class. It can implement interfaces, which is how you'd combine records with sealed types: see how to use sealed classes and interfaces in Java.

The generated equals is only as good as the components' own equals. That's the array trap below, and the reason BigDecimal needed normalising. For hand-written equality, how to implement equals() and hashCode() properly covers the contract.

Errors I hit, and the fixes

I forced each of these on purpose so the messages below are exact.

field declaration must be static

A.java:1: error: field declaration must be static
public record A(int x) { private int y; }
                                     ^
  (consider replacing field with record component)

You added an instance field to a record body. Put it in the header as a component, or make it static.

cannot assign a value to final variable x

B.java:1: error: cannot assign a value to final variable x
public record B(int x) { public B { this.x = x; } }
                                        ^

Inside a compact constructor you assign the parameter (x = x.trim()), never the field. The compiler does the field assignment at the end.

variable x might not have been initialized

C.java:1: error: variable x might not have been initialized
public record C(int x) { public C(int x) { } }
                                           ^

If you write the full canonical constructor, with a parameter list, you must assign every field yourself. Switch to the compact form unless you have a reason not to.

invalid canonical constructor in record D

A canonical constructor can't call this(...). Only the extra, non-canonical constructors (like Money(String, String)) delegate.

records are not supported in -source 11

Money.java:7: error: records are not supported in -source 11
  (use -source 16 or higher to enable records)

Your compiler is too old. I reproduced it by compiling with javac --release 11. Check javac -version, not only java -version; they can point at different JDKs, and in IDEs the project language level can lag the installed JDK.

The array trap

A record with an int[] component compares the array by reference. I ran this:

false
true

The first line is new Bad(new int[]{1}).equals(new Bad(new int[]{1})) for a plain record Bad(int[] v). The second is a Good record where I cloned in the constructor and accessor, and overrode equals, hashCode and toString with Arrays. My advice: use a List instead of an array in records, and the problem disappears.

Where to go next

A few things to do before you rely on this in a real service.

  • Serialization and JSON. Records work with Jackson 2.12 and later, but I haven't tested it here, so check the Jackson docs for your version before you rely on it. Java serialization of records goes through the canonical constructor, so your validation runs on deserialization too.
  • Pattern matching. Java 21 added record patterns, which let you destructure a record in instanceof and switch. See how to use record patterns in Java.
  • JUnit 5. Once you have a build tool, move the checks into JUnit tests with assertThrows and AssertJ. I didn't run JUnit for this article, so I won't print a version or a result. Pin the current version from Maven Central when you set it up, and see how to write unit tests with JUnit 5.
  • When not to use a record. If the object has identity, changes over time, or needs inheritance, use a class. A JPA entity is the common case; records can't be entities because entities need a no-argument constructor and mutable state.
  • Immutability beyond records. For the broader picture, read how to create an immutable class in Java.

Recap

  • You declared records with components and got fields, accessors, equals, hashCode and toString for free.
  • You used a compact constructor to validate and normalise, assigning parameters, not fields.
  • You copied the List component so the record stays immutable in practice.
  • You wrote a with-er for the "change one thing" case.
  • You know the three traps: arrays, BigDecimal scale, and the old-compiler error.

Java records FAQ

Can a Java record have a setter?
No. Components are final, so there's nothing to set. Return a new record from a method like withCategory instead.

Can a record extend another class?
No, it already extends java.lang.Record and is final. It can implement any number of interfaces.

Can I add my own methods and static fields to a record?
Yes to both. Instance methods, static methods and static fields are fine. Only extra instance fields are rejected.

Which Java version do I need for records?
Java 16 or later for the final feature. They appeared as a preview in 14 and 15. I used 21.

References

how-to-tutorialsmodern-javacore-java

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments