Immutability in Java 21: Beyond Records

java java21 scala scala3 kotlin immutability records immutable-collections case-classes data-classes

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:

  1. Make state final (records help here).
  2. Validate state at construction time.
  3. Defensively copy mutable inputs.
  4. 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, or Money values, 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);
    }
}

View full Java example

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)

View full Scala example

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()))
        }
    }
}

View full Kotlin example

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"));
}

View full Java tests

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")
}

View full Scala tests

@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") }
}

View full Kotlin tests

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 List is 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?
In Java, immutability means the observable state of an object cannot change after construction. A record gives you final components, but you still need to protect mutable inputs like lists and maps using defensive copies. So the core idea is not only "no setters" but also "no external handle can mutate my internals".
How is Java immutability different from Scala and Kotlin?
Scala and Kotlin make immutable-style modeling feel more natural because copy-based updates are built in and immutable usage is more idiomatic. Java reaches the same outcome, but usually with a little more explicit code, especially around collection handling. In interviews, this is a strong point: Java can be just as safe, but you must be deliberate.
Can teams enforce immutability consistently in Java codebases?
Yes, with team conventions and code review rules: prefer records for value objects, ban mutable fields in DTOs, and require defensive copies for collection components. Add tests that try to mutate exposed collections and expect failures. This combination gives practical, repeatable immutability even without a dedicated language keyword.

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:


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.