Welcome. This course begins with the Java habits that make backend code easier to reason about under interview pressure: clear domain models, explicit dependencies, correct collections, and predictable failure handling.
This first lesson focuses on a deceptively important building block: an object whose state is valid when created and cannot later be changed. Rather than memorizing a checklist, you will learn to explain why each design choice closes a way that state could otherwise change. By the end, you should be able to write either a conventional immutable class or a Java record, validate its construction, and identify the common “looks immutable but is not” trap.
Immutability begins with a domain invariant
An object is immutable when its observable state cannot change after construction.
That does not mean every object in the application has the same values. You can construct two OrderLine instances with different quantities. It means that a particular OrderLine, once created, remains the same order line for its lifetime.
A useful domain model does more than store data. It protects rules that must always hold. Suppose an order line must have:
- a nonblank SKU
- a strictly positive quantity
Those are invariants: facts that must be true for every valid instance. The constructor is the natural enforcement point because it creates the object’s initial state. If construction fails, no invalid instance becomes available to the caller.
Here is a conventional immutable class:
import java.util.Objects;
public final class OrderLine {
private final String sku;
private final int quantity;
public OrderLine(String sku, int quantity) {
String normalizedSku = Objects.requireNonNull(sku, "sku must not be null")
.strip();
if (normalizedSku.isEmpty()) {
throw new IllegalArgumentException("sku must not be blank");
}
if (quantity <= 0) {
throw new IllegalArgumentException("quantity must be positive: " + quantity);
}
this.sku = normalizedSku;
this.quantity = quantity;
}
public String sku() {
return sku;
}
public int quantity() {
return quantity;
}
public OrderLine withQuantity(int newQuantity) {
return new OrderLine(sku, newQuantity);
}
}
Notice what withQuantity does not do: it does not alter this.quantity. It creates a new valid object. Immutability prohibits state mutation, not useful behavior.
The constructor has three distinct responsibilities:
- Reject null for required references.
Objects.requireNonNullmakes the contract explicit and gives a useful failure message. - Validate domain rules. A blank SKU and a nonpositive quantity make no sense for this model.
- Optionally normalize values. Here, whitespace surrounding the SKU is removed before storing it. Normalization should be a deliberate domain policy, not an automatic habit.
The distinction between validation and normalization is worth stating in an interview:
- Validation rejects unacceptable input.
- Normalization converts multiple acceptable representations into one canonical representation.
For example, stripping surrounding whitespace may be appropriate for a SKU entered through a form. Converting every SKU to uppercase is appropriate only if SKU matching is intentionally case-insensitive.
Some rules do not belong in a simple constructor. “This SKU exists in the catalog” requires external data and may change over time; it is not a stable property of the OrderLine values alone. In contrast, “quantity is positive” is local, deterministic, and permanently meaningful, so it belongs here.
The mechanics: how a conventional class becomes immutable
The implementation above relies on several cooperating decisions. Leaving out any one can reintroduce mutability.
| Design choice | What it protects against |
|---|---|
private fields | Callers cannot assign fields directly. |
final fields | No method in the class can reassign a field after construction. |
| No mutating methods | The public API offers no operation that changes this instance. |
final class | A subclass cannot add mutable state and still be used where this type is expected. |
| Constructor validation | Invalid states cannot be created through the normal public API. |
| Safe handling of mutable components | Callers cannot mutate the object indirectly through an aliased reference. |
Two details commonly get blurred together:
- A
finalfield means the reference cannot be reassigned. - It does not necessarily mean the referenced object cannot be changed.
For primitive values such as int, there is no referenced object to mutate. For String, the referenced object is itself immutable. Thus these fields are safe:
private final int quantity;
private final String sku;
By contrast, this declaration alone is unsafe:
private final List<String> tags;
The tags reference cannot point at another list after construction, but the existing list might still be changed. An external caller could hold the same list reference and add items after passing it to the constructor.
This is the central idea behind defensive copying.
Records: the concise form for transparent data carriers
Since Java 16, a record is often the best default for a small value-like domain type. It states directly that the type is defined by its components.
Records In Java - Full Tutorial - The Best New Java Feature You're Not Using
Watch “Records In Java - Full Tutorial - The Best New Java Feature You're Not Using” by Coding with John for a compact visual comparison between a traditional data-holder class and a record. Focus on what Java generates and what that removes from your implementation responsibility.
Watch the boilerplate problem to see why a simple class can accumulate repetitive constructor, accessor, equality, and display code. Then watch the record declaration and generated members. Pay particular attention to the accessor naming convention: records use sku() rather than getSku().
The OrderLine class can be written as a record:
import java.util.Objects;
public record OrderLine(String sku, int quantity) {
public OrderLine {
sku = Objects.requireNonNull(sku, "sku must not be null")
.strip();
if (sku.isEmpty()) {
throw new IllegalArgumentException("sku must not be blank");
}
if (quantity <= 0) {
throw new IllegalArgumentException("quantity must be positive: " + quantity);
}
}
}
This is a compact canonical constructor. The record header:
(String sku, int quantity)
declares the record’s complete state. Java supplies:
- private, final fields for
skuandquantity - accessors named
sku()andquantity() - a constructor accepting those two components
equals,hashCode, andtoStringimplementations based on the components
The compact constructor runs validation and normalization before Java assigns the final component fields. That is why there are no assignments such as this.sku = sku.
Reassigning the constructor parameter is allowed:
sku = sku.strip();
At the end of the compact constructor, Java assigns the current parameter values to the record’s fields. Therefore, the normalized sku becomes the stored value.
Read the relevant portions of the OpenJDK record specification to connect the concise syntax with its precise language guarantees. This is especially useful for explaining records accurately instead of describing them merely as “classes with less boilerplate.”
In the “Description” section, read the record model. Focus on the trade-off: a record exposes an API that directly corresponds to its state components. Then move to “Constructors for record classes.” Read the constructor forms, including the Range validation example. Notice that compact constructors are designed specifically for validation and normalization before automatic field assignment.
A record is implicitly final, so it cannot be extended. It also cannot extend an arbitrary base class; its superclass is java.lang.Record. It can, however, implement interfaces.
Records are especially well suited when the public meaning of the type is exactly its data. OrderLine(sku, quantity) says clearly that those two values define an order line.
A conventional class remains appropriate when the type needs more control over its public API or its internal representation. For example, perhaps an object has derived or cached internal state that should not be part of its value identity, or its construction process needs substantially different public factory methods. The decision is not “records are modern, classes are old”; it is whether the type is a transparent carrier of its declared data.
The important caveat: records provide shallow immutability
A record makes its component fields final, but it cannot magically make the objects stored in those fields immutable.
This record is not safely immutable:
import java.util.List;
public record ProductDraft(String sku, List<String> tags) {
}
The generated accessor returns the same List reference passed to the constructor. Both of these mutations are possible:
List<String> inputTags = new ArrayList<>();
inputTags.add("sale");
ProductDraft draft = new ProductDraft("A-17", inputTags);
inputTags.add("featured"); // Mutates draft indirectly
draft.tags().add("clearance"); // Also mutates draft directly
The field is final, but the list itself remains mutable. The solution is to take an immutable defensive copy during construction:
import java.util.List;
import java.util.Objects;
public record ProductDraft(String sku, List<String> tags) {
public ProductDraft {
sku = Objects.requireNonNull(sku, "sku must not be null").strip();
if (sku.isEmpty()) {
throw new IllegalArgumentException("sku must not be blank");
}
tags = List.copyOf(Objects.requireNonNull(tags, "tags must not be null"));
}
}
List.copyOf gives the record an unmodifiable list independent of a mutable input list. Because the elements are String, which is immutable, callers cannot alter the structure or alter an element’s internal state.
Do not confuse this with merely wrapping the incoming list:
tags = Collections.unmodifiableList(tags);
That creates an unmodifiable view, but the original list can still be changed through another reference. List.copyOf is normally the stronger choice because it prevents that aliasing.
Defensive copying has a depth:
- A copied
List<String>is safe because strings are immutable. - A copied
List<MutableAddress>is not fully safe if callers can mutate the individualMutableAddressinstances. - Arrays are mutable too. If an immutable object stores an array, it must generally clone it on the way in and clone it again when returning it.
The concise interview wording is:
Records are shallowly immutable. Their component references cannot be reassigned, but mutable component objects still require defensive copying or an immutable representation.
A compact way to explain this in an interview
When asked, “How would you create an immutable Java class?”, avoid reciting a disconnected list. Start from the guarantee and show how each decision supports it:
An immutable object has state that cannot change after construction. I make the class non-extendable, keep state private and final, and provide no mutating methods. The constructor validates every local domain invariant before assigning fields, so invalid instances cannot be created. For mutable inputs such as collections, arrays, or legacy mutable date types, final is insufficient because it only freezes the reference. I defensively copy on input and avoid exposing mutable internal state on output. For a small transparent value type, I would often use a record with a compact constructor, while remembering that records provide shallow rather than deep immutability.
Then ground it in the OrderLine example:
For example, an
OrderLinerejects a null or blank SKU and a nonpositive quantity at construction. It stores onlyStringandint, which are safe components. AwithQuantityoperation returns a new validated order line rather than mutating the existing one.
That response demonstrates conceptual understanding, a practical implementation strategy, and awareness of the main pitfall.
Build-and-break practice
Implement OrderLine first as the conventional class, then replace it with the record version.
As you do so, deliberately test the guarantees:
- Construct an order line with
" A-17 "and confirm itssku()is"A-17". - Attempt to construct lines with
null," ",0, and a negative quantity; each should fail immediately with a diagnostic exception. - Add a
List<String>component without defensive copying, mutate the original input list, and observe the supposed immutable object change. - Replace direct storage with
List.copyOfand repeat the mutation attempt.
The point is not just that the compiler rejects assignment to a final field. It is that no external reference should provide a hidden route to changing the object’s observable state.
Key takeaways
An immutable domain object is valid for its entire lifetime because construction is the sole point at which its state is established.
- Constructor validation protects local domain invariants.
private finalfields and no mutators prevent direct state changes in a conventional class.- A
finalclass closes inheritance-based weakening of the immutability contract. finalreferences do not make mutable objects immutable; collections, arrays, and mutable elements require defensive handling.- Records express small transparent value types concisely, generate value-based methods, and support compact constructors for validation and normalization.
- Records are shallowly immutable, so mutable components remain your responsibility.
Next, you will move from a single well-designed domain object to a small service assembled through interfaces and constructor-based composition.
Can't find a good explanation? Sign up and we'll make it for you
Sign up