Back to Collectors
Q6Question 6 of 21

Collect to unmodifiable result: toUnmodifiableList, collectingAndThen, why immutability matters

Key Concepts
  • toUnmodifiableList() (Java 10) returns an unmodifiable list that throws UnsupportedOperationException on mutation
  • Stream.toList() (Java 16) is a shortcut that also returns an unmodifiable list
  • collectingAndThen(toList(), Collections::unmodifiableList) wraps the result for older Java versions
  • Immutable outputs prevent accidental modification of shared or cached data
  • toUnmodifiableList() rejects null elements (throws NullPointerException)
  • Unmodifiability is shallow: the list structure cannot change, but mutable element objects can still be modified

Definition

Use toUnmodifiableList (Java 10), Stream.toList() (Java 16), or collectingAndThen wrapping for unmodifiable results that prevent accidental mutation of shared data.
  • 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?

JAVA
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());
        }
    }
}
TEXT
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?

JAVA
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?

JAVA
// 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() or toUnmodifiableList().
  • 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

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

---

Share
Use ← → arrow keys to navigate