Immutable, Unmodifiable, Fixed-Size, and Defensive Collections
Learn Java Array, Collections, Iterator/Iterable, Stream - Part 014
Deep dive into immutable, unmodifiable, fixed-size, shallow, snapshot, live view, and defensive collection boundaries in modern Java.
Part 014 — Immutable, Unmodifiable, Fixed-Size, and Defensive Collections
Target: setelah bagian ini, kamu mampu membedakan immutable, unmodifiable, fixed-size, snapshot, view, dan defensive copy secara presisi, lalu mendesain API boundary yang tidak bocor mutation, aliasing, null, atau live-view behavior.
Banyak bug collection di enterprise Java bukan karena engineer tidak tahu List atau Map. Bug muncul karena boundary semantics tidak eksplisit:
- apakah caller boleh mutate collection?
- apakah callee menyimpan reference langsung?
- apakah return value adalah snapshot atau live view?
- apakah elemen di dalam collection ikut immutable?
- apakah
nullditerima? - apakah order dipertahankan?
- apakah
UnsupportedOperationExceptionbisa muncul di tengah algorithm?
Bagian ini adalah salah satu fondasi API design terpenting dalam Java.
1. Posisi Part Ini dalam Framework Kaufman
Kaufman-style decomposition:
| Practice Block | Skill | Observable Ability |
|---|---|---|
| 30 menit | Vocabulary precision | Bisa menjelaskan immutable vs unmodifiable vs fixed-size tanpa ambiguity |
| 30 menit | Factory methods | Bisa memilih List.of, Set.of, Map.of, copyOf dengan benar |
| 30 menit | Wrapper semantics | Bisa menjelaskan kenapa unmodifiableList masih bisa berubah jika backing list berubah |
| 45 menit | Defensive boundary | Bisa menulis constructor/getter yang tidak bocor internal mutation |
| 45 menit | Shallow immutability | Bisa mendeteksi mutable element leak |
| 60 menit | Failure modeling | Bisa menemukan aliasing, null rejection, live view, and unsupported mutation bugs |
2. The Vocabulary Problem
Java code sering memakai kata “immutable” untuk banyak hal yang berbeda.
Contoh:
List<String> names = Collections.unmodifiableList(new ArrayList<>());
Orang sering berkata “ini immutable list”. Itu tidak sepenuhnya benar.
Lebih presisi:
Ini adalah unmodifiable view terhadap backing list.
Kalau backing list berubah, view ikut berubah.
var mutable = new ArrayList<String>();
var view = Collections.unmodifiableList(mutable);
mutable.add("A");
System.out.println(view); // [A]
Jadi kita butuh vocabulary yang lebih tajam.
3. Terminology Matrix
| Term | Meaning | Can structure change through this reference? | Can structure change elsewhere? | Can element state change? |
|---|---|---|---|---|
| Mutable collection | Normal modifiable collection | Yes | Yes if aliased | Yes if elements mutable |
| Fixed-size collection | Size cannot change, but elements may be replaceable | Sometimes | Yes if backed | Yes |
| Unmodifiable view | This reference cannot mutate structure | No | Yes through backing collection | Yes |
| Unmodifiable snapshot | This reference cannot mutate structure, and source mutation not reflected | No | No structural reflection | Yes |
| Immutable collection | Structural state cannot change | No | No | Elements may still be mutable unless deeply immutable |
| Deeply immutable graph | Collection and elements cannot observably change | No | No | No |
Java standard collections mostly give you unmodifiable structural collections, not deep immutability.
4. List.of, Set.of, Map.of: Factory-Created Unmodifiable Collections
Modern Java provides convenient factory methods:
List<String> names = List.of("Ayu", "Bima", "Citra");
Set<String> roles = Set.of("ADMIN", "REVIEWER");
Map<String, Integer> weights = Map.of("LOW", 1, "HIGH", 10);
Important characteristics:
- structurally unmodifiable;
- reject
nullelements/keys/values; - compact for small constants;
- safe to share structurally;
- shallow with respect to element mutability.
Mutation attempt:
names.add("Dian"); // UnsupportedOperationException
Null rejection:
List.of("A", null); // NullPointerException
Duplicate set element rejection:
Set.of("A", "A"); // IllegalArgumentException
Duplicate map key rejection:
Map.of("A", 1, "A", 2); // IllegalArgumentException
Use these for:
- constants;
- small configuration lists;
- test fixtures;
- default empty/non-empty values;
- safe structural return values.
5. copyOf: Snapshot Boundary
List.copyOf, Set.copyOf, and Map.copyOf create unmodifiable collections from existing collections/maps.
List<String> source = new ArrayList<>();
source.add("A");
List<String> snapshot = List.copyOf(source);
source.add("B");
System.out.println(snapshot); // [A]
This is the default tool for defensive structural snapshot.
Use it in constructors:
public final class Report {
private final List<Row> rows;
public Report(Collection<Row> rows) {
this.rows = List.copyOf(rows);
}
public List<Row> rows() {
return rows;
}
}
This solves two problems:
- caller cannot mutate your internal list by holding the original reference;
- caller cannot mutate the returned list.
But it does not solve mutable element state:
record Holder(List<StringBuilder> builders) {
Holder(Collection<StringBuilder> builders) {
this(List.copyOf(builders));
}
}
StringBuilder remains mutable.
6. Shallow Immutability: The Most Common Hidden Leak
Unmodifiable collection means collection structure cannot be changed through that reference.
It does not mean elements cannot change.
var account = new MutableAccount("A-001", BigDecimal.TEN);
var accounts = List.of(account);
account.setBalance(BigDecimal.ZERO);
System.out.println(accounts.getFirst().balance()); // 0
The list did not mutate structurally. The object inside mutated.
This matters in:
- DTO caching;
- audit snapshots;
- validation results;
- domain events;
- workflow state snapshots;
- authorization policy snapshots;
- reporting views.
Rule:
For audit or historical snapshot,
List.copyOfis insufficient if elements are mutable.
You need element copying too.
public AuditSnapshot(Collection<Account> accounts) {
this.accounts = accounts.stream()
.map(AccountSnapshot::from)
.toList();
}
Since Stream.toList() returns an unmodifiable list in modern Java, this gives structural immutability for the resulting list, but element immutability still depends on AccountSnapshot.
7. Collections.unmodifiableX: Live View Boundary
The classic wrappers:
Collections.unmodifiableCollection(c)
Collections.unmodifiableList(list)
Collections.unmodifiableSet(set)
Collections.unmodifiableMap(map)
Collections.unmodifiableSequencedCollection(c)
Collections.unmodifiableSequencedSet(set)
Collections.unmodifiableSequencedMap(map)
They return an unmodifiable view backed by the specified collection/map.
Example:
var mutable = new ArrayList<String>();
var view = Collections.unmodifiableList(mutable);
mutable.add("A");
mutable.add("B");
System.out.println(view); // [A, B]
The view prevents mutation through view, not mutation through mutable.
This is useful when:
- you control the backing collection;
- you want a live read-only view;
- internal mutation should be reflected to observers;
- you intentionally expose changing state.
It is dangerous when:
- caller controls the backing collection;
- constructor stores wrapper over caller input;
- getter exposes live internal state accidentally;
- thread visibility is assumed but not guaranteed;
- consumer expects snapshot semantics.
Bad constructor:
public final class BadReport {
private final List<Row> rows;
public BadReport(List<Row> rows) {
this.rows = Collections.unmodifiableList(rows);
}
}
Caller can still mutate:
List<Row> source = new ArrayList<>();
BadReport report = new BadReport(source);
source.add(new Row(...)); // report changes
Better:
public final class Report {
private final List<Row> rows;
public Report(Collection<Row> rows) {
this.rows = List.copyOf(rows);
}
}
8. Fixed-Size Is Not Unmodifiable
Arrays.asList(array) returns a fixed-size list backed by the array.
String[] array = {"A", "B"};
List<String> list = Arrays.asList(array);
list.set(0, "Z"); // allowed
list.add("C"); // UnsupportedOperationException
array[1] = "Y"; // reflected in list
System.out.println(list); // [Z, Y]
This is neither a normal mutable list nor a safe immutable snapshot.
It is a fixed-size view backed by an array.
Use cases:
- quick adapter from array to list for local code;
- legacy interop;
- temporary view.
Avoid for:
- defensive constructor copy;
- API return value;
- long-lived state;
- code that expects
add/remove; - code that assumes source array changes will not reflect.
Better:
List<String> mutableCopy = new ArrayList<>(Arrays.asList(array));
List<String> unmodifiableSnapshot = List.copyOf(Arrays.asList(array));
Or with array directly:
List<String> snapshot = List.of(array); // careful: varargs behavior depends on array type
For object array String[], List.of(array) creates a list of array elements. For primitive arrays, it creates a single element list containing the primitive array object.
int[] numbers = {1, 2, 3};
List<int[]> list = List.of(numbers); // one element: int[]
9. Stream.toList() vs Collectors.toList() vs Collectors.toUnmodifiableList()
This series will cover streams deeply later, but this boundary matters now.
List<String> a = stream.toList();
List<String> b = stream.collect(Collectors.toList());
List<String> c = stream.collect(Collectors.toUnmodifiableList());
High-level semantics:
| API | Result Mutability Contract |
|---|---|
Stream.toList() | unmodifiable list |
Collectors.toList() | no guarantee of type, mutability, serializability, thread-safety |
Collectors.toUnmodifiableList() | unmodifiable list, rejects null |
Production rule:
If you need a mutable result, say so explicitly with
toCollection(ArrayList::new)ornew ArrayList<>(...).
List<String> mutable = stream.collect(Collectors.toCollection(ArrayList::new));
If you need an unmodifiable result:
List<String> immutableStructure = stream.toList();
or:
List<String> immutableStructure = stream.collect(Collectors.toUnmodifiableList());
Be careful with null behavior. Collectors.toUnmodifiableList() rejects null. Stream.toList() has different specification details and should not be treated as equivalent in every edge case. If nulls may exist, decide explicitly whether nulls are allowed at the boundary.
10. Defensive Copy in Constructors
The safest default for value-like classes:
public final class InvoiceBatch {
private final List<Invoice> invoices;
public InvoiceBatch(Collection<Invoice> invoices) {
this.invoices = List.copyOf(invoices);
}
public List<Invoice> invoices() {
return invoices;
}
}
This has clear semantics:
- constructor rejects null collection;
- constructor rejects null elements;
- internal list is unmodifiable;
- source collection mutation after construction does not affect object;
- getter can return internal reference safely for structural immutability.
But again, Invoice must also be immutable or defensively copied if element state matters.
For mutable elements:
public final class InvoiceBatchSnapshot {
private final List<InvoiceSnapshot> invoices;
public InvoiceBatchSnapshot(Collection<Invoice> invoices) {
this.invoices = invoices.stream()
.map(InvoiceSnapshot::from)
.toList();
}
public List<InvoiceSnapshot> invoices() {
return invoices;
}
}
11. Defensive Copy in Getters
If internal state is mutable and you keep mutating it internally, returning direct reference leaks mutation authority.
Bad:
public final class MutableAccumulator {
private final List<Event> events = new ArrayList<>();
public List<Event> events() {
return events;
}
}
Caller can do:
accumulator.events().clear();
Better options:
Option A — Snapshot Getter
public List<Event> events() {
return List.copyOf(events);
}
Properties:
- caller gets independent structural snapshot;
- internal future mutations not reflected;
- allocation per call.
Option B — Live Read-Only View
private final List<Event> readOnlyEvents = Collections.unmodifiableList(events);
public List<Event> eventsView() {
return readOnlyEvents;
}
Properties:
- caller cannot mutate through returned reference;
- internal future mutations are reflected;
- one wrapper allocation;
- must document live-view semantics.
Option C — Iterator/Stream-Only API
public void forEachEvent(Consumer<? super Event> consumer) {
events.forEach(consumer);
}
Properties:
- no collection reference exposed;
- still subject to concurrent modification/side effects if consumer calls back;
- less flexible for caller.
Choose based on semantics, not habit.
12. Defensive Copy for Maps
Maps need extra care because both key and value can be mutable.
public final class RoutingTable {
private final Map<RouteKey, RouteTarget> routes;
public RoutingTable(Map<RouteKey, RouteTarget> routes) {
this.routes = Map.copyOf(routes);
}
public Map<RouteKey, RouteTarget> routes() {
return routes;
}
}
This only protects map structure.
It does not protect:
- mutable key hash/equality fields;
- mutable value fields;
- mutable collections inside values.
Danger:
RouteKey key = new RouteKey("A");
Map<RouteKey, RouteTarget> source = new HashMap<>();
source.put(key, target);
RoutingTable table = new RoutingTable(source);
key.setCode("B"); // can break hash-based lookup semantics if key is mutable
Rule:
Map keys used across defensive boundaries should be immutable or copied into immutable key records.
13. Defensive Copy for Sequenced Collections
List.copyOf is often enough if you want ordered snapshot and duplicates are acceptable.
But if uniqueness + order is part of the invariant, preserve it explicitly.
public final class OrderedUniqueCodes {
private final SequencedSet<String> codes;
public OrderedUniqueCodes(Collection<String> codes) {
this.codes = Collections.unmodifiableSequencedSet(new LinkedHashSet<>(codes));
}
public SequencedSet<String> codes() {
return codes;
}
}
Why not Set.copyOf(codes)?
Because Set.copyOf does not promise insertion-order preservation as a semantic contract. If order matters, choose an ordered implementation first, then wrap.
SequencedSet<String> snapshot = Collections.unmodifiableSequencedSet(
new LinkedHashSet<>(codes)
);
For sequenced maps:
public final class OrderedProperties {
private final SequencedMap<String, String> properties;
public OrderedProperties(Map<String, String> properties) {
this.properties = Collections.unmodifiableSequencedMap(
new LinkedHashMap<>(properties)
);
}
public SequencedMap<String, String> properties() {
return properties;
}
}
But beware: if the input map is unordered, new LinkedHashMap<>(properties) preserves the input map's iteration order, which might already be arbitrary.
If deterministic order is required, sort explicitly:
this.properties = Collections.unmodifiableSequencedMap(new TreeMap<>(properties));
or:
SequencedMap<String, String> ordered = new LinkedHashMap<>();
properties.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.forEach(e -> ordered.put(e.getKey(), e.getValue()));
this.properties = Collections.unmodifiableSequencedMap(ordered);
14. Null Policy at Boundaries
Modern unmodifiable factories are useful because they reject null.
List.copyOf(input); // throws if input or element is null
Map.copyOf(input); // throws if input, key, or value is null
This is often a good thing.
It turns hidden null contamination into immediate boundary failure.
However, if your domain intentionally permits nulls, do not accidentally use APIs that reject them without documenting the choice.
Better: normalize explicitly.
List<String> normalized = input.stream()
.map(value -> value == null ? "" : value)
.toList();
Or reject with domain-specific error:
static List<String> requireNoNulls(Collection<String> input) {
int index = 0;
for (String value : input) {
if (value == null) {
throw new IllegalArgumentException("input contains null at index " + index);
}
index++;
}
return List.copyOf(input);
}
Why not just let NullPointerException happen?
Because domain-specific errors are easier to diagnose across API boundaries.
15. Empty Collections: Never Return Null
Bad:
List<Order> findOrders(CustomerId id) {
if (notFound(id)) {
return null;
}
return orders;
}
Better:
List<Order> findOrders(CustomerId id) {
if (notFound(id)) {
return List.of();
}
return List.copyOf(orders);
}
Empty collection means:
valid result with zero elements.
null means:
absence of collection reference, which forces every caller into defensive null logic.
Use Optional<List<T>> rarely. It usually means two levels of absence:
- no collection known;
- collection known but empty.
Most APIs only need empty collection.
16. Boundary Design Patterns
Pattern 1 — Value Object With Ordered Children
public record WorkflowDefinition(
String code,
List<WorkflowStep> steps
) {
public WorkflowDefinition {
Objects.requireNonNull(code, "code");
steps = List.copyOf(steps);
if (steps.isEmpty()) {
throw new IllegalArgumentException("steps must not be empty");
}
}
}
Record compact constructor allows defensive replacement of the component parameter.
Pattern 2 — Ordered Unique Codes
public final class ErrorCatalog {
private final SequencedSet<String> codes;
public ErrorCatalog(Collection<String> codes) {
this.codes = Collections.unmodifiableSequencedSet(new LinkedHashSet<>(codes));
}
public SequencedSet<String> codes() {
return codes;
}
}
Pattern 3 — Internal Mutable, External Snapshot
public final class ValidationContext {
private final List<Violation> violations = new ArrayList<>();
public void addViolation(Violation violation) {
violations.add(Objects.requireNonNull(violation));
}
public List<Violation> violations() {
return List.copyOf(violations);
}
}
Pattern 4 — Internal Mutable, External Live View
public final class ObservableBuffer<T> {
private final List<T> buffer = new ArrayList<>();
private final List<T> readOnlyBuffer = Collections.unmodifiableList(buffer);
public void add(T value) {
buffer.add(value);
}
/** Returns a live read-only view. Future internal additions are visible. */
public List<T> view() {
return readOnlyBuffer;
}
}
Only use this when live view is intentional and documented.
17. Mutation Authority Matrix
When designing APIs, ask: who owns mutation authority?
| Scenario | Constructor Action | Getter Action |
|---|---|---|
| Value object snapshot | List.copyOf(input) | return field |
| Internal mutable accumulator | copy into ArrayList | List.copyOf(field) |
| Live read-only observer | own mutable list | return unmodifiableList(field) |
| Ordered unique snapshot | new LinkedHashSet + unmodifiableSequencedSet | return field |
| Sorted snapshot | new TreeSet + unmodifiable wrapper | return field |
| Map lookup snapshot | Map.copyOf(input) if order irrelevant | return field |
| Sequenced map snapshot | new LinkedHashMap or TreeMap + wrapper | return field |
18. Failure Mode: UnsupportedOperationException in Generic Algorithms
Generic code often assumes mutability:
void normalize(List<String> names) {
names.removeIf(String::isBlank);
names.add("UNKNOWN");
}
This fails for:
List.of(...);List.copyOf(...);Collections.unmodifiableList(...);- fixed-size
Arrays.asList(...)foradd/remove.
Better:
List<String> normalized(Collection<String> names) {
List<String> result = new ArrayList<>();
for (String name : names) {
if (!name.isBlank()) {
result.add(name);
}
}
result.add("UNKNOWN");
return List.copyOf(result);
}
Input is read-only. Output is new value.
Rule:
Prefer transform-to-new-result over mutating caller-provided collections unless mutation is the explicit purpose of the method.
19. Failure Mode: Defensive Copy That Destroys Semantics
Bad:
Set<String> codes = new LinkedHashSet<>(List.of("B", "A"));
Set<String> copy = Set.copyOf(codes);
If order matters, this may not preserve the semantic order you intend to expose.
Better:
SequencedSet<String> copy = Collections.unmodifiableSequencedSet(
new LinkedHashSet<>(codes)
);
For maps:
Map<String, String> copy = Map.copyOf(linkedHashMap);
If mapping order matters to callers, do not return plain Map.copyOf and expect order semantics to be your API contract.
Use explicit sequenced map snapshot.
20. Failure Mode: unmodifiableList Over Caller Input
This is one of the most common bugs.
public UserSession(List<String> permissions) {
this.permissions = Collections.unmodifiableList(permissions);
}
Caller mutation leaks in:
List<String> permissions = new ArrayList<>(List.of("READ"));
UserSession session = new UserSession(permissions);
permissions.add("ADMIN");
Now session may appear to have ADMIN.
Correct:
public UserSession(Collection<String> permissions) {
this.permissions = List.copyOf(permissions);
}
If you need uniqueness:
public UserSession(Collection<String> permissions) {
this.permissions = Collections.unmodifiableSequencedSet(
new LinkedHashSet<>(permissions)
);
}
21. Failure Mode: Mutable Elements in Security/Policy Data
record PolicySet(List<Policy> policies) {
PolicySet(Collection<Policy> policies) {
this(List.copyOf(policies));
}
}
If Policy is mutable, caller can retain reference and mutate it.
Safer:
record PolicySnapshot(String id, int priority, Set<String> permissions) {
PolicySnapshot {
permissions = Set.copyOf(permissions);
}
static PolicySnapshot from(Policy policy) {
return new PolicySnapshot(
policy.id(),
policy.priority(),
policy.permissions()
);
}
}
record PolicySet(List<PolicySnapshot> policies) {
PolicySet(Collection<Policy> policies) {
this(policies.stream().map(PolicySnapshot::from).toList());
}
}
For security, audit, regulatory, and workflow systems, shallow immutability is often not enough.
22. Failure Mode: Confusing final With Immutable
private final List<String> names = new ArrayList<>();
final means the field reference cannot be reassigned.
It does not mean the list cannot mutate.
names.add("A"); // allowed inside class
Also:
public final List<String> names;
is still dangerous if it references a mutable list.
Rule:
finalprotects the reference, not the object graph.
23. Failure Mode: Assuming Wrapper Is Thread-Safe
Unmodifiable is not synchronized.
List<String> view = Collections.unmodifiableList(new ArrayList<>());
This does not make access thread-safe. It only blocks mutation through view.
If the backing list is mutated concurrently elsewhere, readers may still observe inconsistent behavior or encounter concurrency problems.
Thread-safety needs separate design:
- external synchronization;
- concurrent collections;
- immutable snapshots;
- volatile/atomic publication;
- copy-on-write strategy.
This series does not repeat concurrency internals, but the boundary rule matters:
Immutability helps concurrency only when the object graph is safely published and not mutated elsewhere.
24. Code Review Checklist
For every collection field, constructor parameter, and getter, ask:
- Who owns this collection?
- Can caller mutate it after passing it in?
- Can callee mutate it after returning it?
- Is returned value snapshot or live view?
- Are null elements allowed?
- Are duplicate elements allowed?
- Is order part of the contract?
- Is uniqueness part of the contract?
- Are elements themselves immutable?
- Are map keys immutable?
- Is
UnsupportedOperationExceptionpossible in downstream algorithms? - Does defensive copy preserve ordering semantics?
- Is allocation cost acceptable for snapshot getters?
- Is live view behavior documented if used?
25. Decision Matrix
| Need | Recommended Approach |
|---|---|
| Small constant list | List.of(...) |
| Small constant set | Set.of(...) |
| Small constant map | Map.of(...) / Map.ofEntries(...) |
| Snapshot list from input | List.copyOf(input) |
| Mutable copy from input | new ArrayList<>(input) |
| Snapshot map, order irrelevant | Map.copyOf(input) |
| Snapshot insertion-order set | Collections.unmodifiableSequencedSet(new LinkedHashSet<>(input)) |
| Snapshot sorted set | Collections.unmodifiableSortedSet(new TreeSet<>(input)) |
| Live read-only view | Collections.unmodifiableX(backing) |
| Fixed-size array adapter | Arrays.asList(array) |
| Mutable stream result | collect(Collectors.toCollection(ArrayList::new)) |
| Unmodifiable stream result | stream.toList() or toUnmodifiableList() |
| Deep snapshot | map each element to immutable snapshot + unmodifiable collection |
26. Practical API Examples
Example 1 — Bad API
public final class Batch {
private final List<Item> items;
public Batch(List<Item> items) {
this.items = items;
}
public List<Item> getItems() {
return items;
}
}
Problems:
- caller can mutate after construction;
- caller can mutate via getter;
- accepts null list;
- accepts null elements;
- element mutability unknown;
- no invariant validation.
Example 2 — Better Structural Boundary
public final class Batch {
private final List<Item> items;
public Batch(Collection<Item> items) {
this.items = List.copyOf(items);
if (this.items.isEmpty()) {
throw new IllegalArgumentException("items must not be empty");
}
}
public List<Item> items() {
return items;
}
}
Example 3 — Deep Snapshot Boundary
public final class BatchSnapshot {
private final List<ItemSnapshot> items;
public BatchSnapshot(Collection<Item> items) {
this.items = items.stream()
.map(ItemSnapshot::from)
.toList();
if (this.items.isEmpty()) {
throw new IllegalArgumentException("items must not be empty");
}
}
public List<ItemSnapshot> items() {
return items;
}
}
27. Deliberate Practice
Exercise 1 — Classify Collection Semantics
For each expression, classify as mutable, fixed-size, unmodifiable view, or unmodifiable snapshot:
new ArrayList<>(input)
List.of("A", "B")
List.copyOf(input)
Collections.unmodifiableList(input)
Arrays.asList(array)
stream.toList()
stream.collect(Collectors.toCollection(ArrayList::new))
Also answer:
- does it allow null?
- does it reflect source changes?
- can caller add/remove elements?
Exercise 2 — Fix Constructor Leak
Refactor:
public final class UserPermissions {
private final List<String> permissions;
public UserPermissions(List<String> permissions) {
this.permissions = Collections.unmodifiableList(permissions);
}
public List<String> permissions() {
return permissions;
}
}
Requirement:
- caller mutation must not leak;
- duplicates should be removed;
- first-seen order should be preserved;
- returned value must not be structurally modifiable.
Expected direction:
private final SequencedSet<String> permissions;
public UserPermissions(Collection<String> permissions) {
this.permissions = Collections.unmodifiableSequencedSet(
new LinkedHashSet<>(permissions)
);
}
Exercise 3 — Deep Snapshot
Given mutable Order objects, implement OrderBatchSnapshot so later mutation of original orders does not alter the snapshot.
Exercise 4 — Decide View vs Snapshot
For each case, choose live view or snapshot:
- audit report rows;
- UI progress buffer;
- validation error response;
- internal metrics rolling window;
- policy definition loaded at startup;
- debug endpoint showing current in-memory queue.
Explain the trade-off.
28. Summary
The core distinction:
unmodifiable view = cannot mutate through this reference, but backing can change
unmodifiable snapshot = cannot mutate through this reference, and source changes are not reflected
fixed-size view = size cannot change, but replacement/backing mutation may happen
shallow immutable-ish = structure stable, elements may mutate
deep immutable = structure and reachable element state stable
Production-grade Java APIs need explicit boundary semantics.
The highest-value rules:
- Use
List.copyOf,Set.copyOf,Map.copyOffor structural snapshots when their semantics fit. - Use
Collections.unmodifiableXonly when you intentionally want a live read-only view. - Do not wrap caller input with
unmodifiableXand call it defensive. - Use ordered implementations before wrapping when order matters.
- Use
SequencedSet/SequencedMapwrappers when uniqueness/order or mapping-order are part of the contract. - Remember shallow immutability: mutable elements still mutate.
- Never return
nullfor “no elements”; return an empty collection. - Do not mutate caller-provided collections unless mutation is the explicit purpose of the method.
References
- Oracle Java SE 25 API —
List,Set,Map,Collections,Arrays - Oracle Java SE 25 API — unmodifiable lists, sets, maps, and sequenced wrappers
- OpenJDK JEP 431 — Sequenced Collections
You just completed lesson 14 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.