> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to Use Java Records: Build an Expense Tracker (Java 21)
- URL: https://www.ggorantala.dev/how-to-use-java-records/
- Published: 2026-10-02T05:17:00.000Z
- Updated: 2026-10-31T06:32:32.000Z
- Description: Build a small expense tracker with Java 21 records: compact constructors, validation, defensive copies, with-ers and the array trap, all compiled and run.
- Author: Gopi Gorantala
- Tags: how-to-tutorials, modern-java, core-java

## Start with the finished app

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

```log
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.

```bash
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:

```log
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`

```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`

```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.

```bash
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`

```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`

```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

```bash
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`.

```log
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`

```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.

```bash
javac -d out $(find src -name '*.java')
java -ea -cp out com.example.expenses.ExpenseChecks

```

```log
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](https://ggorantala.dev/how-to-use-sealed-classes-interfaces-java/?ref=ggorantala.dev).

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](https://ggorantala.dev/how-to-implement-equals-hashcode-java/?ref=ggorantala.dev) 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`**

```log
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`**

```log
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`**

```log
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`**

```log
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:

```log
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](https://ggorantala.dev/how-to-use-record-patterns-java/?ref=ggorantala.dev).
- **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](https://ggorantala.dev/how-to-write-unit-tests-junit-5-java/?ref=ggorantala.dev).
- **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](https://ggorantala.dev/how-to-create-immutable-class-java/?ref=ggorantala.dev).

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

- [JDK 21 documentation, records](https://docs.oracle.com/en/java/javase/21/language/records.html?ref=ggorantala.dev), consulted for the language rules
- [dev.java: Records](https://dev.java/learn/records/?ref=ggorantala.dev) (page returned no readable text on the day I fetched it, so I relied on the language specification and my own compiler runs)
- [JEP 395: Records](https://openjdk.org/jeps/395?ref=ggorantala.dev)