Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 012 / 100

Immutability in Java: a practical Java & Spring Boot guide

Immutable objects are thread-safe, cache-friendly and easier to reason about.

Expona Admin8 min read

Introduction

Thread safety without locks, predictable behavior across concurrent requests, and objects that can be safely cached or reused—these benefits come from a single design principle: immutability. When you build immutable objects in Java, you eliminate entire classes of bugs that plague distributed systems and high-concurrency applications. In this practical guide, Immutability in Java: a practical Java & Spring Boot guide walks you through the real-world techniques we use in production services to build objects that cannot be modified after construction.

Whether you're building a REST API, handling payment events, or managing user sessions in a Spring Boot application, immutable objects make your code safer and easier to reason about. Let's explore how to design them correctly.

Make fields final and set them in the constructor

The foundation of immutability is simple: declare every field as final and initialize it in the constructor. Once set, the field cannot be reassigned. This is your contract with the JVM and your team—this value will not change.

java
public class PaymentEvent {
    private final String transactionId;
    private final BigDecimal amount;
    private final String currency;
    private final Instant createdAt;

    public PaymentEvent(String transactionId, BigDecimal amount, 
                        String currency, Instant createdAt) {
        this.transactionId = Objects.requireNonNull(transactionId, "transactionId cannot be null");
        this.amount = Objects.requireNonNull(amount, "amount cannot be null");
        this.currency = Objects.requireNonNull(currency, "currency cannot be null");
        this.createdAt = Objects.requireNonNull(createdAt, "createdAt cannot be null");
    }

    public String getTransactionId() {
        return transactionId;
    }

    public BigDecimal getAmount() {
        return amount;
    }

    public String getCurrency() {
        return currency;
    }

    public Instant getCreatedAt() {
        return createdAt;
    }
}

Notice that we use Objects.requireNonNull in the constructor. This is essential: you want to fail fast if someone tries to construct an invalid object. In a microservice handling payments, a null amount or transactionId could silently propagate downstream, causing reconciliation nightmares hours later. By validating in the constructor, you catch errors at the boundary.

Never provide setters. If a client needs to create a new version of the object with different values, they construct a new instance. This sounds expensive, but garbage collection is far faster than debugging race conditions caused by shared mutable state.

Copy incoming mutable collections with List.copyOf

The most common source of immutability violations is mutable collections. If you accept a List or Map as a constructor parameter and store it directly, the caller can modify it after construction, violating immutability.

java
// DANGEROUS: caller can modify the list after construction
public class Order {
    private final List<LineItem> items;

    public Order(List<LineItem> items) {
        this.items = items; // BUG: caller can still modify this list
    }
}

// Safe usage breaks:
List<LineItem> mutableItems = new ArrayList<>();
mutableItems.add(item1);
Order order = new Order(mutableItems);
mutableItems.add(item2); // Now the order is modified!

The solution is to copy the collection at construction time. Java 9+ provides List.copyOf, Set.copyOf, and Map.copyOf—these create immutable copies:

java
public class Order {
    private final List<LineItem> items;
    private final Map<String, String> metadata;

    public Order(List<LineItem> items, Map<String, String> metadata) {
        this.items = List.copyOf(Objects.requireNonNull(items, "items cannot be null"));
        this.metadata = Map.copyOf(Objects.requireNonNull(metadata, "metadata cannot be null"));
    }

    public List<LineItem> getItems() {
        return items;
    }

    public Map<String, String> getMetadata() {
        return metadata;
    }
}

Now the caller can modify their original list without affecting the Order object. The copy is shallow—references to the LineItem objects are copied, not the items themselves—but that's fine as long as LineItem is also immutable (which it should be).

In a real scenario, we've seen production bugs where order line items were accidentally modified by a payment processor thread while the order was being persisted to the database. Immutable collections prevent that entirely.

Return unmodifiable views, never internal lists

Even with an immutable collection stored internally, you must return unmodifiable views to callers. If you return the internal list directly, callers cannot modify it—but future maintainers might not realize it's supposed to be immutable, and they'll try. More importantly, static analysis tools and IDEs can detect when you return an unmodifiable view and warn about mutation attempts.

java
// WEAK: returns the internal list directly
public class UserSession {
    private final List<String> permissions;

    public UserSession(List<String> permissions) {
        this.permissions = List.copyOf(permissions);
    }

    public List<String> getPermissions() {
        return permissions; // Safe in practice, but unclear intent
    }
}

// BETTER: returns an unmodifiable view
public class UserSession {
    private final List<String> permissions;

    public UserSession(List<String> permissions) {
        this.permissions = List.copyOf(permissions);
    }

    public List<String> getPermissions() {
        return Collections.unmodifiableList(permissions);
    }
}

The second approach is more explicit. When a developer reads the code, they immediately see the intent: this list should not be modified. Any attempt to call add() or remove() on the returned list will throw UnsupportedOperationException.

In Spring Boot controllers, this is especially valuable. If you return an unmodifiable list from a service method, your controller code is safer:

java
@RestController
@RequestMapping("/api/users")
public class UserController {
    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/{id}/permissions")
    public List<String> getPermissions(@PathVariable Long id) {
        return userService.getUserPermissions(id); // Unmodifiable in transit
    }
}

Records give you most of this for free

Java 16+ introduced records, a language feature designed for immutable data carriers. A record automatically generates constructors, getters, equals, hashCode, and toString. All fields are implicitly final.

java
public record PaymentEvent(
    String transactionId,
    BigDecimal amount,
    String currency,
    Instant createdAt
) {}

This single declaration replaces the entire PaymentEvent class we showed earlier. The record compiler generates:

  • A constructor that takes all fields as parameters
  • Immutable field declarations (implicitly final and private)
  • Accessor methods: transactionId(), amount(), etc.
  • Proper equals and hashCode implementations
  • A readable toString

However, records don't automatically validate constructor arguments or copy collections. You need to handle that with a compact constructor:

java
public record Order(
    String orderId,
    List<LineItem> items,
    Map<String, String> metadata
) {
    // Compact constructor: runs before field assignment
    public Order {
        orderId = Objects.requireNonNull(orderId, "orderId cannot be null");
        items = List.copyOf(Objects.requireNonNull(items, "items cannot be null"));
        metadata = Map.copyOf(Objects.requireNonNull(metadata, "metadata cannot be null"));
    }
}

In a Spring Boot application, records are excellent for request/response DTOs and domain events:

java
@RestController
public class OrderController {
    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping("/orders")
    public CreateOrderResponse createOrder(@RequestBody CreateOrderRequest request) {
        var orderId = orderService.create(request.customerId(), request.items());
        return new CreateOrderResponse(orderId, "created");
    }
}

public record CreateOrderRequest(Long customerId, List<LineItemDTO> items) {}
public record CreateOrderResponse(Long orderId, String status) {}

Records shine here: you get immutable DTOs with zero boilerplate, and Spring Boot's JSON serialization works seamlessly.

Common mistakes

Mistake 1: Storing mutable objects without copying. If a field holds a mutable object (like a StringBuilder or a custom class without immutability guarantees), the field is technically final but the object inside can change. Always copy or wrap such objects, or redesign to use immutable types.

Mistake 2: Returning the internal collection directly from accessor methods. Even if the internal list is immutable, returning it directly invites future bugs. Return Collections.unmodifiableList(...) or use Java 9+ collection factories to be explicit about immutability.

Mistake 3: Forgetting validation in constructors. Final fields guarantee no reassignment, but they don't guarantee sensible values. If you accept null in a constructor, that invalid state propagates through your entire system. Validate early.

Mistake 4: Using records for complex behavior. Records are great for data carriers, but if you need substantial methods, validation logic, or inheritance, a regular class is often clearer. Records are not a replacement for classes; they're a tool for a specific use case.

Key takeaways

  • Declare all fields final and initialize them in the constructor. Use Objects.requireNonNull to fail fast on invalid input.
  • Copy mutable collections at construction time using List.copyOf, Set.copyOf, or Map.copyOf to prevent external modification.
  • Return unmodifiable views from accessor methods using Collections.unmodifiableList or similar, making your intent explicit and guarding against accidental mutation.
  • Use records for immutable data carriers in Java 16+. They eliminate boilerplate and make immutability the default, but still require validation logic in compact constructors.
  • Immutable objects are thread-safe without synchronization, cache-friendly for distributed systems, and dramatically easier to reason about in production code.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles