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
equalsandhashCodetogether, 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.
@Overridecatches the typo that would otherwise be a silent new method.- Static mutable state is shared by every thread; static constants are not a problem.