You already saw how records remove boilerplate in Immutable Data with Java Records, but the real interview question is usually deeper: “How do you keep data truly immutable when collections and runtime mutation are involved?”
The Problem / Context
For Scala developers, immutability usually feels like the default path. In Java, you can absolutely model immutable data, but you need to be explicit about a few extra rules:
- Make state final (records help here).
- Validate state at construction time.
- Defensively copy mutable inputs.
- Expose immutable collection views.
If you skip steps 3 and 4, your “immutable” object can still change from the outside.
Immutability matters because shared state is one of the easiest ways to create bugs, especially in concurrent code and long-lived business workflows. When an object can change underneath you, it becomes much harder to reason about what the system is doing at any moment: a thread may read a stale value, a cache may be mutated unexpectedly, and tests become fragile because behavior depends on hidden state changes. In Java 21, we want immutability because it gives us stable snapshots: once a value is created, it stays consistent until we deliberately replace it with a new instance. That makes code easier to read, safer to share across threads, and simpler to validate.
A few places where immutability matters a lot:
- Domain models such as
CustomerProfile,Order, orMoneyvalues, where a logical object should not change mid-request or mid-transaction. - Configuration and application settings, where shared configuration should be read-only and predictable during startup and runtime.
- Concurrent systems, where immutable objects can be safely shared across threads without locking each read.
- Cache keys and event payloads, where a value must not be mutated after it has been published or stored.
Key Concepts
| Concept | Java 21 | Scala 3 | Kotlin |
|---|---|---|---|
| Data carrier | record |
case class |
data class |
| Built-in copy for updates | Manual (withX method) |
copy(...) |
copy(...) |
| Default collection mindset | Mutable ecosystem, choose immutable APIs explicitly | Immutable collections by default in common usage | Read-only interfaces, must still guard Java interop |
| Validation style | Compact constructor / factory checks | require(...) in constructor/factory |
require(...) in init/factory |
The Solution / Implementation
The example below uses the same CustomerProfile idea across Java, Scala, and Kotlin.
public record CustomerProfile(long id, String email, List<String> roles, Map<String, String> preferences) {
public CustomerProfile(long id, String email, List<String> roles, Map<String, String> preferences) {
if (id <= 0) throw new IllegalArgumentException("id must be positive");
this.roles = List.copyOf(roles);
this.preferences = Map.copyOf(preferences);
}
public CustomerProfile withRole(String role) {
var normalizedRole = role == null ? "" : role.trim();
if (normalizedRole.isBlank()) throw new IllegalArgumentException("role cannot be blank");
if (roles.contains(normalizedRole)) return this;
var updatedRoles = new ArrayList<>(roles);
updatedRoles.add(normalizedRole);
return new CustomerProfile(id, email, updatedRoles, preferences);
}
}
case class CustomerProfile private (
id: Long,
email: String,
roles: List[String],
preferences: Map[String, String]
):
def withRole(role: String): CustomerProfile =
val normalizedRole = Option(role).map(_.trim).getOrElse("")
require(normalizedRole.nonEmpty, "role cannot be blank")
if roles.contains(normalizedRole) then this else copy(roles = roles :+ normalizedRole)
object CustomerProfile:
def create(id: Long, email: String, roles: collection.Seq[String], preferences: collection.Map[String, String]): CustomerProfile =
require(id > 0, "id must be positive")
val normalizedEmail = Option(email).map(_.trim).getOrElse("")
require(normalizedEmail.contains("@"), "email must contain '@'")
val immutableRoles = roles.toList.map(role => Option(role).map(_.trim).getOrElse(""))
val immutablePreferences = preferences.map: case (key, value) => Option(key).map(_.trim).getOrElse("") -> Option(value).map(_.trim).getOrElse("")
CustomerProfile(id, normalizedEmail, immutableRoles, immutablePreferences.toMap)
data class CustomerProfile private constructor(
val id: Long,
val email: String,
val roles: List<String>,
val preferences: Map<String, String>,
) {
fun withRole(role: String): CustomerProfile {
val normalizedRole = role.trim()
require(normalizedRole.isNotBlank()) { "role cannot be blank" }
if (roles.contains(normalizedRole)) return this
return copy(roles = Collections.unmodifiableList(roles + normalizedRole))
}
companion object {
fun create(id: Long, email: String, roles: List<String>, preferences: Map<String, String>): CustomerProfile {
val normalizedEmail = email.trim()
require(normalizedEmail.contains("@")) { "email must contain '@'" }
val normalizedRoles = roles.map { it.trim() }
val normalizedPreferences = preferences.map { (k, v) -> k.trim() to v.trim() }
return CustomerProfile(id, normalizedEmail, Collections.unmodifiableList(normalizedRoles), Collections.unmodifiableMap(normalizedPreferences.toMap()))
}
}
}
Can We Force Immutability in Java?
You can get close, but Java does not have one universal immutable keyword.
Instead, you combine language and API choices:
record+ validation for stable object state.List.copyOf(...)/Map.copyOf(...)for immutable collection views.- No setters, no exposed mutable internals.
withX(...)methods that return new instances.
So the practical answer is: you enforce immutability by design, not by one compiler switch.
Focused Tests for Immutability
@Test
void shouldExposeUnmodifiableCollections() {
var profile = new CustomerProfile(1L, "alex@example.com", List.of("user"), Map.of("tier", "standard"));
assertThrows(UnsupportedOperationException.class, () -> profile.roles().add("admin"));
}
test("Should defensively copy mutable constructor inputs") {
val roles = scala.collection.mutable.ArrayBuffer("user")
val profile = CustomerProfile.create(1L, "alex@example.com", roles, Map("tier" -> "standard"))
roles += "admin"
profile.roles shouldBe List("user")
}
@Test
fun shouldExposeUnmodifiableCollections() {
val profile = CustomerProfile.create(1L, "alex@example.com", listOf("user"), mapOf("tier" to "standard"))
val roles = profile.roles as MutableList<String>
assertThrows(UnsupportedOperationException::class.java) { roles.add("admin") }
}
Comparison Table
| Question | Java 21 | Scala 3 | Kotlin |
|---|---|---|---|
| Are there immutable data types? | Yes (record) |
Yes (case class) |
Yes (data class with val) |
| Are there immutable collections? | Yes via List.copyOf, Map.copyOf, List.of, Map.of |
Yes, standard immutable collections are common default | Read-only collection types + optional Java unmodifiable wrappers |
| Can you force immutability? | You enforce by conventions + API choices | Strong defaults and type-level nudges | Strong defaults, plus care for Java interop |
| Copy-on-write update ergonomics | Manual withX methods |
copy(...) built-in |
copy(...) built-in |
When to Use / Best Practices
- Use immutable models for domain events, API DTOs, and configuration.
- Validate at construction; never allow “half-valid” objects.
- In Java, always defensively copy incoming collections.
- In Kotlin, remember read-only
Listis not always deeply immutable at runtime. - In Scala, keep constructor/factory boundaries clear when accepting generic collection inputs.
What does immutability really mean in Java?
How is Java immutability different from Scala and Kotlin?
Can teams enforce immutability consistently in Java codebases?
Conclusion
For Scala developers moving into Java 21, the key mindset is simple: records are the start of immutability, not the full story. Once you combine records, validation, and immutable collection boundaries, Java can model immutable domain data in a way that is robust and interview-ready.
Code Samples
All examples in this post are runnable. Find them in the repository:
- Java 21 CustomerProfile
- Scala 3 CustomerProfile
- Kotlin CustomerProfile
- Java 21 tests
- Scala 3 tests
- Kotlin tests
This is part of our Java 21 Interview Preparation Guide - Your Roadmap to Success. Next related posts: String Manipulation with Modern APIs, Null-Safe Programming with Optional, and Collection Factory Methods and Stream Basics.