Classes, Records and Value Objects

Article · 14:00

A class bundles state with the operations allowed on it, and the point of private fields is not secrecy - it is that the class gets to decide what a valid object looks like and keep that true. With the equals and hashCode pair, and what a record generates for you.

Classes, Records and Value Objects

A class bundles state with the operations allowed on it. The point of making fields private is not secrecy - it is that the class gets to decide what a valid object looks like, and can keep that true for the object's whole life.

public class Temperature {
    private final double celsius;          // final: set once, in the constructor

    public Temperature(double celsius) {
        if (celsius < -273.15) {
            throw new IllegalArgumentException("below absolute zero: " + celsius);
        }
        this.celsius = celsius;
    }

    // A named constructor, spelled as a static factory - it can validate,
    // convert, or return a cached instance, and it reads at the call site.
    public static Temperature ofFahrenheit(double f) {
        return new Temperature((f - 32) * 5 / 9);
    }

    public double celsius() { return celsius; }
    public double fahrenheit() { return celsius * 9 / 5 + 32; }

    @Override
    public String toString() {
        return String.format("%.1fC", celsius);
    }

    public static void main(String[] args) {
        System.out.println(new Temperature(21));
        System.out.println(Temperature.ofFahrenheit(98.6));

        try {
            new Temperature(-300);
        } catch (IllegalArgumentException e) {
            System.out.println("rejected: " + e.getMessage());
        }
    }
}

Because the field is final and validated in the constructor, no Temperature anywhere in the program can be below absolute zero. That is worth more than any amount of checking at the point of use.

equals, hashCode and toString travel together

Two separately built objects are not equal unless the class says how to compare them. Override equals and you must override hashCode, because every hash collection uses the pair.

import java.util.*;

public final class Money {
    private final long pence;
    private final String currency;

    public Money(long pence, String currency) {
        this.pence = pence;
        this.currency = Objects.requireNonNull(currency);
    }

    @Override
    public boolean equals(Object other) {
        if (this == other) return true;
        return other instanceof Money m && m.pence == pence && m.currency.equals(currency);
    }

    @Override
    public int hashCode() {
        return Objects.hash(pence, currency);
    }

    @Override
    public String toString() {
        return String.format("%.2f %s", pence / 100.0, currency);
    }

    public Money plus(Money other) {
        if (!other.currency.equals(currency)) {
            throw new IllegalArgumentException("cannot add " + other.currency + " to " + currency);
        }
        return new Money(pence + other.pence, currency);      // a new object
    }

    public static void main(String[] args) {
        var a = new Money(500, "GBP");
        var b = new Money(500, "GBP");

        System.out.println(a.equals(b));                       // true
        System.out.println(new HashSet<>(List.of(a, b)).size()); // 1
        System.out.println(a.plus(new Money(250, "GBP")));
    }
}

Skip hashCode and a HashSet keeps both objects while equals insists they are the same. That bug hides for months.

A record is all of that, written once

Since Java 16, a record declares the components and generates the constructor, the accessors, equals, hashCode and toString from them.

import java.util.*;

public class Records {
    // Two lines replace about forty, and cannot drift apart.
    record Point(int x, int y) { }

    // A compact constructor validates, and may normalise, before assignment.
    record Money(long pence, String currency) {
        Money {
            Objects.requireNonNull(currency, "currency");
            if (currency.length() != 3) {
                throw new IllegalArgumentException("a currency code is three letters");
            }
            currency = currency.toUpperCase();       // normalised, then assigned
        }

        Money plus(Money other) {
            return new Money(pence + other.pence, currency);
        }

        static Money zero(String currency) {
            return new Money(0, currency);
        }
    }

    public static void main(String[] args) {
        var p = new Point(1, 2);
        System.out.println(p);                      // Point[x=1, y=2]
        System.out.println(p.equals(new Point(1, 2)));
        System.out.println(new HashSet<>(List.of(p, new Point(1, 2))).size());

        var price = new Money(1999, "gbp");
        System.out.println(price + " -> " + price.plus(Money.zero("GBP")));

        try {
            new Money(1, "pounds");
        } catch (IllegalArgumentException e) {
            System.out.println("rejected: " + e.getMessage());
        }
    }
}

A record is the right shape when the object is its data: a coordinate, an amount, a row read from a database, a response about to be serialised. Use a class when the object has behaviour and identity beyond its fields, or when its state must change.

Inheritance, and why composition is usually better

public class Shapes {
    abstract static class Shape {
        abstract double area();                 // no body: subclasses must supply one

        String describe() {
            return getClass().getSimpleName() + " of area " + String.format("%.2f", area());
        }
    }

    static final class Rectangle extends Shape {
        private final double width, height;
        Rectangle(double width, double height) { this.width = width; this.height = height; }
        @Override double area() { return width * height; }
    }

    static final class Circle extends Shape {
        private final double radius;
        Circle(double radius) { this.radius = radius; }
        @Override double area() { return Math.PI * radius * radius; }
        @Override String describe() { return "Circle r=" + radius + ", " + super.describe(); }
    }

    public static void main(String[] args) {
        for (Shape shape : new Shape[]{new Rectangle(3, 4), new Circle(1)}) {
            System.out.println(shape.describe());
        }
    }
}

@Override is not required by the compiler, but write it anyway: it turns a misspelled method name from a silent new method into a compile error. And prefer final on a class unless you have designed for extension - an inherited method that a subclass overrides halfway is the hardest kind of bug to find.

static: belonging to the class rather than an instance

public class Counters {
    private static int created = 0;            // one, shared by every instance
    private static final int MAX = 3;          // a constant

    private final int id;

    public Counters() {
        created++;
        this.id = created;
    }

    public static int created() { return created; }

    public static void main(String[] args) {
        new Counters();
        var third = new Counters();
        System.out.println(third.id + " of " + created() + ", max " + MAX);
    }
}

Static mutable state is shared by everything in the process, including every thread - which is why it is the first place to look when a program behaves differently under load. Static constants are fine.

What to take away

  • Private final fields plus validation in the constructor make invalid objects impossible.
  • Override equals and hashCode together, or hash collections misbehave.
  • A record generates both from its components - use it when the object is its data.
  • A compact constructor validates and normalises before the fields are assigned.
  • @Override catches the typo that would otherwise be a silent new method.
  • Static mutable state is shared by every thread; static constants are not a problem.