Hire Me
← All Writing Java

Immutables - No Setters Allowed

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.

1. Why Immutables Are Your Secret Weapon

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.

2. Building Immutables the Right Way

Creating immutables is straightforward but requires discipline. Here’s how I implement them, drawing from my Mosaic and ESG projects.

Use Final Fields and Constructors

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.

Leverage Lombok for Clean Code

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.

Add Factory Methods for Flexibility

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.

Validate State at Construction

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.

Handle Optional Fields with Optional

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

3. Immutable Pitfalls to Avoid

Immutables are powerful, but certain patterns undermine them. Here’s what I steer clear of, based on painful lessons.

No Setters or Withers

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.

Skip Builders for Immutables

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.

Don’t Auto-Generate Getters

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.

4. Where Immutables Shine

Immutables excel in specific scenarios. Here’s where I’ve seen them transform projects:

ProTip: Default to immutables for DTOs and value objects in Spring Boot apps to streamline data flows.

Why Immutables Matter for Your Projects

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!

Java Lombok Clean Code Spring Boot

Share LinkedIn →
Samuel Jackson

Samuel Jackson

Senior Java Back End Developer & Contractor

Senior Java Back End Developer — Betfair Exchange API specialist, Spring Boot, AWS, and event-driven architecture. 20+ years delivering high-performance systems across betting, finance, energy, retail, and government. Available for Java contracting.