A practical guide to immutable design in Java — when to use records, when to write manual value objects, how to handle collections correctly, and the wither pattern for clean modification.
Immutability is one of the most effective tools for writing correct concurrent code. An object that cannot change after construction cannot have race conditions. It can be shared across threads without synchronisation. It can be cached safely and compared by value rather than identity. The cost is predictability; the benefit is correctness.
Java records, added in Java 16, make immutability the path of least resistance for simple value objects. But records have constraints — no inheritance, no additional fields, shallow immutability — that mean understanding when to go beyond them still matters.
A record declares the component fields once, and the compiler generates the constructor, accessors, equals, hashCode, and toString:
public record Price(double value, String currency) {}
Attempting to add a mutable field fails to compile. Each component has a final backing field. For simple data carriers this is exactly right — the signal in the class declaration is “this is data, not behaviour.”
Records also validate well with compact constructors:
public record OrderId(String value) {
public OrderId {
Objects.requireNonNull(value, "OrderId must not be null");
if (value.isBlank()) throw new IllegalArgumentException("OrderId must not be blank");
value = value.trim(); // canonical form — assigned back to component
}
}
The compact constructor runs before the generated one. All 17 uses of new OrderId(...) across your codebase get validation automatically.
Records are shallowly immutable — the component references are final, but the objects they point to may not be:
public record MarketSnapshot(Instant timestamp, List<RunnerPrice> prices) {}
var prices = new ArrayList<RunnerPrice>();
prices.add(new RunnerPrice("Runner A", 2.5));
var snapshot = new MarketSnapshot(Instant.now(), prices);
prices.add(new RunnerPrice("Runner B", 3.0)); // mutates snapshot.prices()
Fix it with a defensive copy in the compact constructor:
public record MarketSnapshot(Instant timestamp, List<RunnerPrice> prices) {
public MarketSnapshot {
Objects.requireNonNull(timestamp, "timestamp required");
prices = List.copyOf(prices); // unmodifiable copy
}
}
List.copyOf creates an unmodifiable snapshot. Any attempt to add or remove from the returned list throws UnsupportedOperationException. The external prices list can change freely without affecting the record.
Records cannot extend other classes (beyond Object). If your domain model needs a base class — or if you’re working pre-Java 16 — write the value object manually:
public final class Money {
private final long amount; // pence, avoiding floating-point
private final Currency currency;
public Money(long amount, Currency currency) {
if (amount < 0) throw new IllegalArgumentException("Amount must be non-negative");
this.amount = amount;
this.currency = Objects.requireNonNull(currency, "currency required");
}
public long amount() { return amount; }
public Currency currency() { return currency; }
public Money add(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("Cannot add different currencies");
}
return new Money(amount + other.amount, currency);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Money m)) return false;
return amount == m.amount && currency.equals(m.currency);
}
@Override
public int hashCode() { return Objects.hash(amount, currency); }
@Override
public String toString() { return amount + " " + currency; }
}
Key points: final class (prevents subclass mutation), private final fields, no setters, constructor validates invariants, operations return new instances.
Records and immutable value objects have no setters. To produce a modified copy, the wither pattern generates a new instance with one field changed:
public record OrderRequest(String marketId, String selectionId,
double price, double size, String side) {
public OrderRequest withPrice(double price) {
return new OrderRequest(marketId, selectionId, price, size, side);
}
public OrderRequest withSize(double size) {
return new OrderRequest(marketId, selectionId, price, size, side);
}
}
Usage:
OrderRequest original = new OrderRequest("1.234567", "789", 2.5, 10.0, "BACK");
OrderRequest repriced = original.withPrice(2.52); // original unchanged
This is more verbose than a setter but explicit: the return type makes clear a new object is produced. Call chains are readable:
OrderRequest adjusted = order
.withPrice(currentBestBack)
.withSize(riskAdjustedStake);
Beyond records, apply the same discipline to any class holding collections:
public final class RunnerBook {
private final String runnerId;
private final List<PriceSize> backPrices;
private final List<PriceSize> layPrices;
public RunnerBook(String runnerId,
List<PriceSize> backPrices,
List<PriceSize> layPrices) {
this.runnerId = Objects.requireNonNull(runnerId);
this.backPrices = List.copyOf(backPrices);
this.layPrices = List.copyOf(layPrices);
}
public List<PriceSize> backPrices() { return backPrices; }
public List<PriceSize> layPrices() { return layPrices; }
}
Callers receive the same unmodifiable list on every call — no defensive copy on read required because the field itself is already unmodifiable.
Map.copyOf and Set.copyOf work identically. One caveat: these methods reject null keys and values, which is usually the right constraint for a domain object but may surprise you if your source data contains nulls.
public record ExchangeRates(Map<String, Double> rates) {
public ExchangeRates {
rates = Map.copyOf(rates);
}
}
Immutability is not always the right choice. A builder accumulating state before construction is correctly mutable. A counter incremented by multiple threads should be AtomicLong, not a new Long per increment. The rule of thumb: prefer immutability for objects passed between components or held in collections; allow mutability for short-lived, single-owner, local state.
Domain entities that have identity and evolve over time — an order that transitions from PENDING to FILLED — are worth modelling as mutable aggregates with controlled state transitions, not as immutable value types that produce a new object on every state change.
A pre-race trading signal built from immutable components:
public record TradingSignal(
String marketId,
String selectionId,
SignalType type,
double womAtSignal,
double priceAtSignal,
Instant signalTime,
List<String> contributingFactors
) {
public TradingSignal {
Objects.requireNonNull(marketId);
Objects.requireNonNull(selectionId);
Objects.requireNonNull(type);
Objects.requireNonNull(signalTime);
contributingFactors = List.copyOf(contributingFactors);
}
}
This signal can be passed to a risk evaluator, logged, queued, and serialised without any fear that someone downstream modifies it. Thread safety comes for free. Equality is defined by value, not identity, so you can deduplicate signals in a set without writing custom comparators.
Immutable design is not a constraint — it is a guarantee. The more of your domain model you can make immutable, the less of it you have to reason about at runtime.
If you’re working on a Java domain model and want a review of how immutability is being applied, get in touch.