Immutable objects in Java — why they matter for thread safety, how to build them correctly with final fields, factory methods, and validation, and why setters undermine everything. Practical examples from production Spring Boot systems.
If there’s one design discipline that has saved me the most debugging time over the years, it’s immutability. Mutable objects — those whose state can change after construction — are a reliable source of subtle, hard-to-reproduce bugs, particularly in multi-threaded environments. I learned this painfully early in my career when unexpected state changes in a Spring Boot service cost me days of debugging. Since then, I default to immutable objects wherever practical, and I’ve applied this discipline across every project from Kafka consumers at Mosaic Smart Data to workflow DTOs in ESG’s BPMN engine. Here’s why immutables matter, how to build them properly in Java, and why setters have no place in them.
An immutable object is simple: its state is set at construction and stays fixed. This predictability makes immutables a powerhouse for robust code. In Mosaic’s pipeline, I used immutables for trade event DTOs, ensuring high-velocity data stayed consistent across threads. Here’s why they’re worth adopting:
ProTip: Use immutables in data-intensive projects to lock in state and reduce debugging time.
Creating immutables is straightforward but requires discipline. Here’s how I implement them, drawing from my Mosaic and ESG projects.
A basic immutable needs final fields and a constructor to set them. Here’s a TradeEvent class I used in Mosaic’s
pipeline:
class TradeEvent {
private final Long id;
private final String symbol;
TradeEvent(Long id, String symbol) {
this.id = id;
this.symbol = symbol;
}
}
The final keyword ensures fields can’t change, and the constructor sets all values upfront.
ProTip: Make fields private final by default to enforce immutability and encapsulation.
Writing constructors manually is tedious, so I use Lombok’s @RequiredArgsConstructor to generate them. Here’s a
cleaner version:
@RequiredArgsConstructor
class TradeEvent {
private final Long id;
private final String symbol;
}
This generates a constructor for all final fields, keeping my code concise. I used this in Co-op’s pricing system to
streamline DTOs.
ProTip: Watch out for Lombok’s field order—reordering fields changes the constructor signature, so document it clearly.
Optional fields, like an ID for unsaved objects, need careful handling. Instead of passing null to constructors (a
code smell), use factory methods. For ESG’s workflow engine, I did this:
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
class ProcessInstance {
private final Long id;
private final String name;
static ProcessInstance newProcess(String name) {
return new ProcessInstance(null, name);
}
static ProcessInstance existingProcess(Long id, String name) {
return new ProcessInstance(id, name);
}
}
Factory methods like newProcess clarify intent and prevent invalid states. I made the constructor private to force
clients to use factories.
ProTip: Name factory methods descriptively (e.g., newProcess) to signal valid field combinations.
Immutables should reject invalid inputs. For Mosaic’s trade events, I added validation:
class TradeEvent {
private final Long id;
private final String symbol;
TradeEvent(Long id, String symbol) {
if (id != null && id < 0) {
throw new IllegalArgumentException("ID must be >= 0");
}
if (symbol == null || symbol.isEmpty()) {
throw new IllegalArgumentException("Symbol must not be empty");
}
this.id = id;
this.symbol = symbol;
}
}
This ensures only valid objects are created. Alternatively, I’ve used Bean Validation for declarative checks in ESG’s DTOs:
class TradeEvent extends SelfValidating<TradeEvent> {
@Min(0)
private final Long id;
@NotEmpty
private final String symbol;
TradeEvent(Long id, String symbol) {
this.id = id;
this.symbol = symbol;
this.validateSelf();
}
}
ProTip: Centralize validation in constructors or use Bean Validation to keep rules close to fields.
OptionalTo avoid NullPointerExceptions, return Optional for nullable fields. In Ribby Hall’s data sync, I did this:
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
class LedgerEntry {
private final Long id;
private final String description;
static LedgerEntry newEntry(String description) {
return new LedgerEntry(null, description);
}
Optional<Long> getId() {
return Optional.ofNullable(id);
}
}
Clients know id might be absent and handle it safely.
ProTip: Never use Optional as a field type—it’s meant for return values, not storage, to avoid null checks inside
the class.
Immutables are powerful, but certain patterns undermine them. Here’s what I steer clear of, based on painful lessons.
Setters and “wither” methods (e.g., withId) mimic mutability by returning new objects:
class TradeEvent {
private final Long id;
private final String symbol;
TradeEvent setId(Long id) {
return new TradeEvent(id, this.symbol);
}
}
This confuses clients, who expect immutables to be immutable. In Mosaic’s pipeline, I banned setters to keep DTOs predictable. If you need state changes, use a mutable class instead.
ProTip: If you’re tempted to add setters or withers, reconsider whether the class should be mutable.
Builders, like those from Lombok’s @Builder, let you set fields step-by-step:
TradeEvent event = TradeEvent.builder()
.id(42L)
.build(); // Oops, forgot symbol
This risks incomplete objects, as I learned when a builder-created DTO caused a null error in Co-op’s pricing system. Factory methods are safer, as they enforce valid combinations.
ProTip: Use factory methods over builders to let the compiler catch missing fields at compile time.
Lombok’s @Getter or IDE-generated getters can expose mutable state. In an early ESG project, I made this mistake:
@Getter
class User {
private final Long id;
private final List<String> roles;
}
Clients could modify roles via getRoles().add("admin"), breaking immutability. Instead, return immutable types or
copies:
class User {
private final Long id;
private final List<String> roles;
List<String> getRoles() {
return List.copyOf(roles);
}
}
ProTip: Only provide getters for immutable types (e.g., String, Long) or return defensive copies for
collections.
Immutables excel in specific scenarios. Here’s where I’ve seen them transform projects:
Price objects (e.g., amount and currency) guaranteed
consistent data across reports.ProTip: Default to immutables for DTOs and value objects in Spring Boot apps to streamline data flows.
Immutables aren’t just a nice-to-have—they’re a cornerstone of clean, reliable code. In Mosaic’s pipeline, they ensured
sub-second latency by preventing state-related bugs. In Co-op’s reports, they cut validation overhead. By making fields
final and avoiding setters, you’ll catch errors at compile time and sleep better knowing your state is locked down.
Start small: convert one DTO to an immutable, add a factory method, and validate its inputs. Check out Oracle’s Java docs or my clean code post here for more.
Have you used immutables to tame complex systems? Share your wins with me here, or ask me for tips, I’d love to hear your story!