Definition
- Collectors.toUnmodifiableList() (Java 10) returns an unmodifiable list. Any add/remove/set throws
UnsupportedOperationException. Rejects nulls. - Stream.toList() (Java 16) returns an unmodifiable list. Allows nulls. Shorter syntax.
- collectingAndThen(toList(), Collections::unmodifiableList) wraps a collected list as unmodifiable. Works in Java 8+.
- Why immutability matters: stream results are often cached, shared across threads, or returned from APIs. A mutable result can be accidentally modified by callers, corrupting shared state.
- Unmodifiability is shallow: the list cannot be structurally changed (no add/remove), but if it holds mutable objects, those objects' fields can still change.
When National Bank of India gives a customer a bank statement (stream result), it should be a sealed document. The customer can read it but not alter the transactions on it. If the statement were mutable, someone might add fake transactions. An unmodifiable list is a sealed statement.
Understanding the Concepts
How do you collect to an unmodifiable list?
package com.bank.service;
import java.math.BigDecimal;
import java.util.*;
import java.util.stream.Collectors;
public class UnmodifiableCollectDemo {
record Account(String id, BigDecimal balance) {}
public static void main(String[] args) {
List<Account> accounts = List.of(
new Account("SAV2001", new BigDecimal("50000")),
new Account("SAV2002", new BigDecimal("150000")),
new Account("SAV2003", new BigDecimal("75000"))
);
// Java 10: toUnmodifiableList
List<Account> unmod = accounts.stream()
.filter(a -> a.balance().compareTo(new BigDecimal("60000")) > 0)
.collect(Collectors.toUnmodifiableList());
// Java 16: Stream.toList()
List<Account> toList = accounts.stream()
.filter(a -> a.balance().compareTo(new BigDecimal("60000")) > 0)
.toList();
// Java 8+: collectingAndThen
List<Account> wrapped = accounts.stream()
.filter(a -> a.balance().compareTo(new BigDecimal("60000")) > 0)
.collect(Collectors.collectingAndThen(
Collectors.toList(),
Collections::unmodifiableList));
// All three throw UnsupportedOperationException on mutation
try {
unmod.add(new Account("SAV9999", BigDecimal.ZERO));
} catch (UnsupportedOperationException e) {
System.out.println("Cannot modify: " + e.getClass().getSimpleName());
}
}
}
Cannot modify: UnsupportedOperationException
How do the three approaches differ?
| Approach | Java Version | Null Elements | Mutability |
|----------|-------------|--------------|------------|
| toUnmodifiableList() | 10+ | Rejects (NPE) | Unmodifiable |
| Stream.toList() | 16+ | Allows nulls | Unmodifiable |
| collectingAndThen(toList(), unmodifiableList) | 8+ | Allows nulls | Unmodifiable |
| collect(Collectors.toList()) | 8+ | Allows nulls | Mutable (ArrayList) |
Why does immutability matter at API boundaries?
public class AccountService {
private final List<Account> activeAccounts;
// WRONG: returns mutable list -- caller can corrupt internal state
public List<Account> getActiveAccounts() {
return accounts.stream()
.filter(Account::isActive)
.collect(Collectors.toList()); // mutable!
}
// CORRECT: returns unmodifiable list -- safe API boundary
public List<Account> getActiveAccounts() {
return accounts.stream()
.filter(Account::isActive)
.collect(Collectors.toUnmodifiableList());
}
}
What about shallow vs deep immutability?
// Shallow: list structure is frozen, but element fields can change
List<StringBuilder> list = List.of(new StringBuilder("hello"));
// list.add(...) → UnsupportedOperationException
// list.get(0).append(" world") → OK! StringBuilder is mutable
For true immutability, use immutable element types (records, String, BigDecimal).
What are common mistakes?
WRONG: Blindly replacing collect(toList()) with .toList() when the caller mutates the result.
WRONG: Expecting null elements to work with toUnmodifiableList().
What should a senior engineer know?
- Always prefer unmodifiable results at API boundaries. Callers should not be able to corrupt the service's internal state.
- For sorted unmodifiable results, chain
.sorted()before.toList()ortoUnmodifiableList(). List.copyOf(mutableList)creates an unmodifiable copy of an existing list (Java 10).- In concurrent environments, unmodifiable lists are safely published without synchronization because their structure cannot change.
Visual Overview
stream().filter().collect(toUnmodifiableList())
│
▼
Unmodifiable List
┌──────────────────┐
│ SAV2002 (150K) │
│ SAV2003 (75K) │
│ │
│ add() → throws! │
│ remove() → throws│
│ set() → throws! │
│ sort() → throws! │
└──────────────────┘
Key Takeaway
Collecting to unmodifiable results protects shared data from accidental mutation. Use toUnmodifiableList (Java 10), Stream.toList() (Java 16), or collectingAndThen with Collections::unmodifiableList for older Java versions. All three throw UnsupportedOperationException on any structural modification.
Immutability matters because stream results are often cached, shared across threads, or returned from APIs. A mutable result can be accidentally modified by callers, corrupting the service's internal state.
At National Bank of India, getActiveAccounts() returns toUnmodifiableList() so callers cannot add fake accounts to the service's view. Unmodifiability is shallow: the list structure is frozen, but mutable element objects can still change. Use records for deep immutability.
Remember one sentence: use toUnmodifiableList at API boundaries to prevent callers from corrupting shared data.
---