1. Introduction
An immutable object is an object whose observable state cannot be changed after the object has been created.
In Java applications, immutability is especially valuable for objects that represent stable data such as:
- Money
- API requests and responses
- Configuration values
- Identifiers
- Date ranges
- Coordinates
- Search criteria
- Domain value objects
- Events
- Messages passed between threads
For example, once a payment amount has been represented as a Money object, another part of the application should not be able to unexpectedly change that amount.
Immutable objects reduce hidden side effects and make code easier to understand.
This becomes important in:
- Spring Boot services
- Concurrent applications
- Caching
- Event-driven systems
- REST APIs
- Collections
- Domain modeling
During Pull Request review, reviewers should check whether an object that conceptually represents a value can be modified unexpectedly after construction.
The goal is not to make every Java class immutable.
Entities managed by JPA, workflow objects, and some framework-controlled objects often need controlled mutability.
The goal is to use immutability where it improves correctness and makes state changes explicit.
2. What This Topic Means
Designing an immutable object means ensuring that code outside the object cannot change its internal state after construction.
A properly designed immutable Java class typically has:
- Final fields
- No state-changing setters
- Constructor validation
- No exposure of mutable internal objects
- Defensive copies where necessary
- Methods that return new objects instead of modifying the current instance
For example:
public final class Money {
private final BigDecimal amount;
private final Currency currency;
public Money(BigDecimal amount, Currency currency) {
this.amount = Objects.requireNonNull(amount);
this.currency = Objects.requireNonNull(currency);
}
public BigDecimal getAmount() {
return amount;
}
public Currency getCurrency() {
return currency;
}
}After construction, neither amount nor currency can be reassigned.
Both BigDecimal and Currency are also immutable, which makes the design straightforward.
However, simply declaring fields final does not automatically make an object immutable.
If a final field references a mutable collection, the collection can still change.
Example:
private final List<String> roles;The reference cannot point to another list, but the existing list may still be modified.
That distinction is extremely important during code review.
3. Why It Matters in Real Projects
Readability
Immutable objects make state easier to reason about.
If an object cannot change after construction, developers do not need to search the application for hidden setter calls.
Maintainability
Business logic becomes easier to modify because objects have predictable state.
A value passed into a method remains the same unless a new value is explicitly created.
Debugging
Mutable objects often produce bugs where the value observed during failure analysis differs from the value originally created.
Immutable objects eliminate many such hidden mutations.
Reliability
An object cannot accidentally enter an invalid state after successful construction.
This is particularly useful when constructor validation guarantees invariants.
Concurrency
Immutable objects are naturally easier to share between threads because their state cannot change after publication.
This removes many synchronization concerns.
Caching
Immutable values are safer cache entries because consumers cannot modify cached state accidentally.
Team Development
An immutable API creates a stronger contract.
Developers know that receiving an object does not give them permission to mutate shared application state.
4. Core Concept
The central idea behind immutability is:
Construction establishes the complete valid state, and that state cannot later be modified.
A good immutable class usually follows these principles.
Fields Cannot Be Reassigned
Use final where appropriate.
private final String customerId;No State-Changing Setters
Avoid:
public void setCustomerId(String customerId) {
this.customerId = customerId;
}Mutable Inputs Are Copied
Consider:
public OrderSummary(List<OrderItem> items) {
this.items = items;
}This is unsafe if items is mutable.
The caller still owns the original list.
It could execute:
items.clear();and modify the object's apparent state.
A safer approach is:
this.items = List.copyOf(items);Mutable Internal State Is Not Exposed
This is also unsafe:
public List<OrderItem> getItems() {
return items;
}if items is mutable.
The caller could modify the returned collection.
Nested Objects Matter
A class is not deeply immutable if it stores mutable objects that can still be modified.
Example:
private final CustomerAddress address;If CustomerAddress has setters, another reference to that object can change the state visible through the immutable wrapper.
Java Records
Records are useful for immutable-style data models because record components are final.
Example:
public record PaymentRequest(
String customerId,
BigDecimal amount,
Currency currency) {
}However, records are only shallowly immutable.
A record containing:
List<String> rolesstill requires defensive handling because the list itself may be mutable.
5. Important Rules
- Make object state complete at construction time.
- Validate constructor arguments immediately.
- Declare fields
finalwhere practical. - Do not provide setters for immutable state.
- Do not expose mutable internal collections.
- Make defensive copies of mutable constructor arguments.
- Consider nested mutable objects, not only top-level fields.
- Prefer immutable Java types where available.
- Use
List.copyOf(),Set.copyOf(), orMap.copyOf()for collection snapshots when appropriate. - Return new objects when business operations produce changed values.
- Do not rely only on the
finalkeyword. - Keep invariants enforced for the object's entire lifetime.
- Consider records for simple immutable data carriers.
- Do not force immutability onto framework-managed objects where it creates unnecessary complexity.
- Document whether the object represents a value, entity, DTO, or mutable workflow state.
6. Bad Code Example
Consider an order-pricing result returned between Spring services.
public class PricingResult {
private String orderId;
private BigDecimal subtotal;
private BigDecimal discount;
private BigDecimal tax;
private BigDecimal total;
private List<String> appliedPromotions;
public PricingResult() {
}
public String getOrderId() {
return orderId;
}
public void setOrderId(String orderId) {
this.orderId = orderId;
}
public BigDecimal getSubtotal() {
return subtotal;
}
public void setSubtotal(BigDecimal subtotal) {
this.subtotal = subtotal;
}
public BigDecimal getDiscount() {
return discount;
}
public void setDiscount(BigDecimal discount) {
this.discount = discount;
}
public BigDecimal getTax() {
return tax;
}
public void setTax(BigDecimal tax) {
this.tax = tax;
}
public BigDecimal getTotal() {
return total;
}
public void setTotal(BigDecimal total) {
this.total = total;
}
public List<String> getAppliedPromotions() {
return appliedPromotions;
}
public void setAppliedPromotions(List<String> appliedPromotions) {
this.appliedPromotions = appliedPromotions;
}
}The service creates it:
@Service
public class PricingService {
public PricingResult calculate(Order order) {
PricingResult result = new PricingResult();
result.setOrderId(order.getId());
result.setSubtotal(new BigDecimal("1000.00"));
result.setDiscount(new BigDecimal("100.00"));
result.setTax(new BigDecimal("162.00"));
result.setTotal(new BigDecimal("1062.00"));
result.setAppliedPromotions(
new ArrayList<>(List.of("PREMIUM10")));
return result;
}
}Another service receives it:
@Service
public class CheckoutService {
public void checkout(PricingResult result) {
result.setDiscount(BigDecimal.ZERO);
result.getAppliedPromotions().clear();
paymentGateway.charge(result.getTotal());
}
}The pricing result no longer represents the value originally calculated by PricingService.
7. Problems in the Bad Code
State Can Change Anywhere
Any component receiving PricingResult can call its setters.
This makes ownership unclear.
Business Result Can Become Inconsistent
The following values are mathematically related:
- Subtotal
- Discount
- Tax
- Total
One field can be modified without recalculating the others.
Example:
result.setDiscount(BigDecimal.ZERO);The total may still contain a value calculated using the previous discount.
The object can therefore enter an invalid state.
Mutable Collection Is Exposed
This line:
result.getAppliedPromotions().clear();modifies the internal collection.
Difficult Debugging
Logs may show one state when pricing was calculated and another state later in the request.
Weak API Contract
CheckoutService receives a pricing result but also has permission to rewrite it.
There is no clear boundary between reading and modifying.
Concurrency Risk
If the same result is shared between asynchronous workflows, one thread could observe changes made by another.
Cache Risk
If a mutable result is cached and returned directly, one consumer can modify data subsequently seen by other consumers.
8. Code Review Findings
A reviewer should notice:
- The object appears to represent a completed calculation, but all fields remain mutable.
- Setters allow consumers to create inconsistent pricing state.
appliedPromotionsexposes mutable state.- There is no constructor enforcing required fields.
- A partially initialized
PricingResultcan exist. - The no-argument constructor allows invalid intermediate states.
- Pricing values have no invariant validation.
- The object is a good candidate for an immutable result model.
- If used in caching or async processing, mutation risk becomes more serious.
- Ownership of state changes is unclear.
A senior reviewer should focus on the semantics of the type.
A completed pricing result should normally be a snapshot of a calculation rather than a mutable working object.
9. Reviewer Comment Example
PricingResultrepresents the output of a completed pricing calculation, but callers can currently modify individual fields and create an inconsistent result. Could we make this an immutable value and construct it with all required values?
Another useful comment:
getAppliedPromotions()exposes the mutable list directly. A caller can clear or modify promotions after pricing has completed. Please return/store an immutable copy.
Another example:
The no-arg constructor plus setters allows partially initialized pricing results. Since all fields appear mandatory after calculation, constructor-based initialization would give us a stronger invariant.
10. Improved Code
A Java record is suitable for this type of result.
public record PricingResult(
String orderId,
BigDecimal subtotal,
BigDecimal discount,
BigDecimal tax,
BigDecimal total,
List<String> appliedPromotions) {
public PricingResult {
Objects.requireNonNull(orderId, "orderId cannot be null");
Objects.requireNonNull(subtotal, "subtotal cannot be null");
Objects.requireNonNull(discount, "discount cannot be null");
Objects.requireNonNull(tax, "tax cannot be null");
Objects.requireNonNull(total, "total cannot be null");
Objects.requireNonNull(
appliedPromotions,
"appliedPromotions cannot be null");
if (subtotal.signum() < 0) {
throw new IllegalArgumentException(
"subtotal cannot be negative");
}
if (discount.signum() < 0) {
throw new IllegalArgumentException(
"discount cannot be negative");
}
if (tax.signum() < 0) {
throw new IllegalArgumentException(
"tax cannot be negative");
}
if (total.signum() < 0) {
throw new IllegalArgumentException(
"total cannot be negative");
}
appliedPromotions =
List.copyOf(appliedPromotions);
}
}The pricing service can now create a complete result:
@Service
public class PricingService {
public PricingResult calculate(Order order) {
BigDecimal subtotal =
calculateSubtotal(order);
BigDecimal discount =
calculateDiscount(order, subtotal);
BigDecimal taxableAmount =
subtotal.subtract(discount);
BigDecimal tax =
calculateTax(taxableAmount);
BigDecimal total =
taxableAmount.add(tax);
List<String> promotions =
findAppliedPromotions(order);
return new PricingResult(
order.getId(),
subtotal,
discount,
tax,
total,
promotions);
}
}The checkout service can only consume the result:
@Service
public class CheckoutService {
private final PaymentGateway paymentGateway;
public CheckoutService(PaymentGateway paymentGateway) {
this.paymentGateway = paymentGateway;
}
public void checkout(PricingResult pricingResult) {
paymentGateway.charge(pricingResult.total());
}
}If a new pricing result is required, create another object instead of mutating the original one.
11. Improved Code Explanation
Complete Initialization
All required state is supplied during object construction.
There is no period where the object contains only some pricing fields.
No Setters
Consumers cannot change:
- Discount
- Tax
- Total
- Order ID
after construction.
Collection Is Defensively Copied
This line is critical:
appliedPromotions =
List.copyOf(appliedPromotions);The record would not be safely immutable without it.
Records do not automatically copy mutable values.
Invalid Values Are Rejected Early
Negative pricing values are rejected during construction.
The object cannot successfully exist with those invalid values.
Responsibility Becomes Clear
PricingService calculates pricing.
PricingResult represents the completed result.
CheckoutService consumes the result.
There is no ambiguous ownership of pricing state.
12. Bad Code vs Improved Code
| Area | Mutable Version | Immutable Version |
|---|---|---|
| Initialization | Can be partial | Complete during construction |
| Setters | Available everywhere | No state-changing setters |
| Collection | Exposed as mutable | Defensive immutable copy |
| Invariants | Easy to break | Validated during construction |
| Debugging | State may change unexpectedly | State remains predictable |
| Thread Safety | Requires more care | Safer to share |
| Caching | Consumers may alter cached data | Much safer for cached values |
| Testability | Tests may depend on mutation | Stable input/output model |
| API Contract | Ownership unclear | Snapshot semantics are clear |
| Reliability | Can become inconsistent | Invalid mutation prevented |
13. Real Project Scenario
Consider a banking application.
A service calculates loan eligibility and produces:
LoanDecisionThe result contains:
- Applicant ID
- Eligible amount
- Interest rate
- Risk score
- Decision
- Reasons
The decision is sent to:
- REST response mapping
- Audit logging
- Notification service
- Reporting
- Kafka event publishing
If LoanDecision is mutable, one downstream component could accidentally execute:
decision.setInterestRate(...);or:
decision.getReasons().clear();Now different systems may receive different representations of what was supposed to be the same loan decision.
For regulated financial systems, this is especially problematic because:
- Audit logs may show one value
- Customer response may show another
- Event consumers may receive another
- Database records may contain another
An immutable LoanDecision provides a stable business snapshot.
Once produced, every consumer sees the same decision.
14. Production Impact
Incorrect Business Results
A mutable pricing, payment, or eligibility result can be accidentally changed before later processing.
Inconsistent Logs
The same logical transaction may appear with different values in different logs.
Cache Corruption
Consider:
List<Product> products =
productCache.get("featured");If callers can modify the cached collection, future requests may receive corrupted data.
Concurrency Bugs
Mutable shared objects can lead to:
- Race conditions
- Visibility issues
- Unexpected data changes
Event Integrity Problems
Events should normally describe something that happened.
A published event should not be modifiable by another consumer after creation.
Difficult Debugging
An object may be correct when created but incorrect when a failure occurs.
Finding who mutated it can require tracing many execution paths.
15. Common Developer Mistakes
Believing final Makes Everything Immutable
This:
private final List<String> roles;does not make the list immutable.
Only the reference is final.
Returning Internal Collections Directly
Example:
public List<String> getRoles() {
return roles;
}This is unsafe when roles is mutable.
Storing Constructor Collections Directly
Example:
this.roles = roles;The caller may still retain and mutate the original list.
Using Unmodifiable Views Incorrectly
Consider:
this.roles =
Collections.unmodifiableList(roles);The returned view cannot be modified directly, but the original roles list may still be modified elsewhere.
List.copyOf() is often safer because it creates an unmodifiable snapshot.
Mutable Nested Objects
Example:
private final Address address;does not make the object deeply immutable if Address has setters.
Adding Setters to Records Through Mutable Components
Records do not provide field setters, but mutable component contents can still change.
Using Mutable Date APIs
Legacy types such as java.util.Date are mutable.
Modern Java should generally prefer immutable java.time types such as:
Instant
LocalDate
LocalDateTime
OffsetDateTime
ZonedDateTimeMaking JPA Entities Immutable Without Considering Framework Requirements
Full immutability can be awkward for JPA entities because ORM frameworks manage entity lifecycle and state.
Use immutability selectively instead of forcing it everywhere.
16. Edge Cases
Null Collections
List.copyOf(null) throws NullPointerException.
Decide whether null should be rejected or treated as an empty collection.
For example:
this.roles =
roles == null
? List.of()
: List.copyOf(roles);The choice depends on the domain contract.
Null Elements
List.copyOf() rejects null elements.
This can be useful because null collection entries are often undesirable.
However, reviewers should know this behavior before replacing existing code.
Nested Collections
Consider:
List<List<String>>Copying only the outer list does not automatically make inner mutable lists immutable.
Mutable Element Objects
This:
List.copyOf(customers)protects the list structure but not mutable Customer objects inside the list.
Arrays
Arrays are mutable.
This is unsafe:
private final byte[] token;Use defensive copying:
this.token = token.clone();and return a copy:
public byte[] token() {
return token.clone();
}Dates
If legacy Date objects must be used, create defensive copies.
Serialization
Serialization frameworks may require specific constructors, field visibility, or configuration.
Confirm compatibility before refactoring DTOs heavily.
Inheritance
Subclassing can weaken immutability if subclasses add mutable behavior.
For immutable value classes, records or final classes are often appropriate.
17. Performance Considerations
Immutability can involve additional object creation, but this should be evaluated realistically.
Defensive Copies
Creating:
List.copyOf(items)uses additional memory when a copy is required.
For typical DTO-sized collections, this cost is usually justified by stronger correctness.
For extremely large data structures, evaluate the cost more carefully.
Object Creation
Immutable update patterns create new values rather than modifying existing objects.
Example:
Money updated =
original.add(additionalAmount);Modern JVM garbage collection handles many short-lived objects efficiently.
Do not introduce mutability solely to avoid small object allocations without measurement.
Concurrency
Immutability can improve performance indirectly because shared immutable objects often require less locking.
Hash-Based Collections
Immutable keys are especially important for:
HashMap
HashSetIf fields used by equals() or hashCode() change after insertion, retrieval can break.
Caching
Immutable cache values reduce defensive copying at every read boundary and prevent accidental cache modifications.
Database Performance
Immutability itself usually has little direct effect on database performance.
Do not invent database optimization claims where none exist.
18. Security Considerations
Immutability is not a replacement for authentication, authorization, or input validation.
However, it can reduce certain classes of accidental data modification.
Security-Sensitive Values
Objects representing:
- Access scopes
- Authorization claims
- Signed payloads
- Security configuration
- Token metadata
benefit from stable state.
Byte Arrays
Sensitive byte arrays require special consideration.
Making defensive copies prevents external mutation but creates additional copies containing sensitive data.
For highly sensitive credentials, lifecycle and memory-clearing requirements may matter more than general immutability.
Authorization Context
If authorization decisions are represented using mutable collections of roles or permissions, another component should not be able to silently modify them.
Input Validation
Immutable construction should validate incoming state before accepting it.
However, immutability does not make malicious input safe automatically.
19. Testing Considerations
Test Constructor Validation
Test valid object creation.
Test invalid cases such as:
- Null required values
- Negative amounts
- Empty identifiers
- Invalid ranges
Test Collection Defensive Copy
Example:
class PricingResultTest {
@Test
void shouldProtectPromotionsFromOriginalListChanges() {
List<String> promotions =
new ArrayList<>();
promotions.add("PREMIUM10");
PricingResult result =
new PricingResult(
"ORD-101",
new BigDecimal("1000.00"),
new BigDecimal("100.00"),
new BigDecimal("162.00"),
new BigDecimal("1062.00"),
promotions);
promotions.clear();
assertEquals(
List.of("PREMIUM10"),
result.appliedPromotions());
}
}Test Returned Collection
@Test
void shouldNotAllowPromotionListModification() {
PricingResult result =
new PricingResult(
"ORD-101",
new BigDecimal("1000.00"),
new BigDecimal("100.00"),
new BigDecimal("162.00"),
new BigDecimal("1062.00"),
List.of("PREMIUM10"));
assertThrows(
UnsupportedOperationException.class,
() -> result.appliedPromotions().add("NEW50"));
}Test Invariants
If:
total = subtotal - discount + taxis a required domain invariant, either calculate it internally or validate it.
Test Equality
Immutable value objects often rely on value equality.
Records automatically provide useful equals() and hashCode() implementations based on components.
Integration Tests
Integration testing may be useful when immutable DTOs interact with:
- Jackson serialization
- REST controllers
- Kafka serialization
- Database mapping
- Validation frameworks
20. Refactoring Guidelines
Step 1: Identify the Object's Semantics
Ask whether the type represents:
- A value
- A completed result
- A command
- An event
- A mutable entity
- A workflow state
Value-like types are stronger immutability candidates.
Step 2: Find All Mutations
Search for:
setX(...)
collection.add(...)
collection.remove(...)
array[index] = ...Understand how callers currently modify the object.
Step 3: Establish Tests
Add tests protecting existing behavior before changing the data model.
Step 4: Introduce Constructor-Based Initialization
Require necessary values at creation.
Step 5: Remove Setters Incrementally
Update callers to create correct state instead of modifying state afterward.
Step 6: Protect Mutable Inputs
Use defensive copies for:
- Lists
- Sets
- Maps
- Arrays
- Mutable nested objects
Step 7: Protect Outputs
Do not expose mutable internals.
Step 8: Consider Records
For Java applications using modern Java versions, records can significantly simplify immutable DTOs and value carriers.
Step 9: Review Serialization
Verify that:
- Jackson still deserializes correctly
- Kafka serializers work
- API contracts remain unchanged
Step 10: Review Framework Constraints
Be especially careful with:
- JPA entities
- Hibernate proxies
- Framework-generated subclasses
- Reflection-based libraries
21. Best Practices
Prefer Immutable Value Objects
Good candidates include:
Money
EmailAddress
DateRange
PaymentReference
OrderNumber
PricingResultValidate at Construction
An immutable object should not be creatable in an invalid state.
Prefer Immutable Java Types
Prefer:
LocalDateover:
Datewhen appropriate.
Use Defensive Collection Copies
Prefer:
List.copyOf(items)when snapshot semantics are intended.
Keep Business Invariants Inside the Type
For example, a DateRange can ensure:
start <= endduring construction.
Prefer Methods Returning New Values
Example:
public Money add(Money other) {
return new Money(
amount.add(other.amount),
currency);
}instead of mutating the current object.
Use Records for Appropriate Data Carriers
Records are useful for:
- API response models
- Commands
- Events
- Query results
- Value carriers
provided mutable components are handled correctly.
22. Practices to Avoid
Setters on Value Objects
Question setters on objects intended to represent stable values.
Exposing Mutable Collections
Avoid returning internal mutable collections directly.
Constructor Assignment Without Copying
Avoid:
this.items = items;when callers should not retain mutation access.
Assuming Records Are Deeply Immutable
A record containing a mutable object can still expose mutable state.
Mutable HashMap Keys
Avoid using objects as map keys when fields participating in equals() and hashCode() can change.
Reflection-Based Mutation Without Strong Need
Avoid breaking immutable contracts using reflection or framework hacks.
Making Everything Immutable Blindly
Do not force immutable architecture onto objects whose lifecycle genuinely requires controlled mutation.
Excessive Copying
Do not create repeated large defensive copies at every internal layer without understanding ownership and performance requirements.
23. Code Review Checklist
- Does this object represent a value or completed result?
- Should its state be allowed to change after construction?
- Are all required fields initialized during construction?
- Are fields declared final where appropriate?
- Are unnecessary setters present?
- Can callers put the object into an invalid state?
- Are mutable collections accepted directly from callers?
- Are mutable collections returned directly?
- Is
List.copyOf(),Set.copyOf(), orMap.copyOf()appropriate? - Does the object contain mutable nested objects?
- Does the object contain arrays requiring defensive copying?
- Are legacy mutable date types being used unnecessarily?
- Would a Java record simplify the design?
- Does the record contain mutable components?
- Are business invariants validated during construction?
- Can the object safely be used as a
HashMapkey? - Is the object shared between threads?
- Is the object stored in a cache?
- Is the object published as an event or message?
- Would making this object immutable conflict with JPA or framework requirements?
- Has serialization behavior been verified after the change?
- Is the proposed immutability improving correctness rather than adding unnecessary complexity?
24. Common Pull Request Review Comments
- > This object represents the result of a completed calculation, so exposing setters allows downstream code to invalidate that result. Could we make it immutable and require the complete state in the constructor?
- > The field is
final, but the referenced list is still mutable. Please consider storing an immutable defensive copy.
- >
getItems()exposes our internal collection directly. A caller can currently modify this object's state without using any method on the object itself.
- > Since all these fields are required, the no-argument constructor plus setters creates unnecessary partially initialized states. Constructor-based initialization would make the contract stronger.
- > This looks like a good record candidate, but we should still copy the
Listcomponent because records provide shallow rather than deep immutability.
- > This type is used as a
HashMapkey. Fields involved inequals()/hashCode()should not change after insertion.
- > We currently store the caller's array reference directly. Please clone it during construction and when returning it to avoid external mutation.
- > The object is published through our async event flow. Making the event immutable would prevent one consumer from changing data observed by another.
- > Please verify Jackson serialization/deserialization before removing these setters, since this DTO is part of the REST contract.
- > I would avoid making the JPA entity itself immutable only to satisfy this rule. A separate immutable domain/API value may be a cleaner boundary here.
25. Code Review Exercise
Review the following code as if it were included in a Pull Request.
public class PaymentCommand {
private final String customerId;
private final BigDecimal amount;
private final List<String> tags;
private final byte[] requestSignature;
public PaymentCommand(
String customerId,
BigDecimal amount,
List<String> tags,
byte[] requestSignature) {
this.customerId = customerId;
this.amount = amount;
this.tags = tags;
this.requestSignature = requestSignature;
}
public String getCustomerId() {
return customerId;
}
public BigDecimal getAmount() {
return amount;
}
public List<String> getTags() {
return tags;
}
public byte[] getRequestSignature() {
return requestSignature;
}
}The author claims:
"PaymentCommand is immutable because all fields are final."
Identify:
- Which parts are actually immutable
- Which fields can still be modified indirectly
- Whether constructor validation is sufficient
- Collection-related risks
- Array-related risks
- Security implications
- Changes required to make the object safely immutable
Do not read the solution until you have reviewed the code yourself.
26. Exercise Solution
The class is only partially immutable.
String Is Safe
String is immutable.
The customerId value cannot be changed internally.
BigDecimal Is Safe
BigDecimal is immutable.
Operations create new values instead of modifying the existing value.
List<String> Is Unsafe
Although the tags reference is final, the list may still be mutable.
The caller can do this:
List<String> tags =
new ArrayList<>(List.of("MOBILE"));
PaymentCommand command =
new PaymentCommand(
"CUS-101",
new BigDecimal("500.00"),
tags,
signature);
tags.clear();Now command.getTags() may return an empty list.
The caller can also execute:
command.getTags().add("HIGH_RISK");if the internal list is mutable.
byte[] Is Unsafe
Arrays are mutable.
The constructor stores the caller's array directly.
The getter also returns the internal array directly.
This allows external modification.
Constructor Validation Is Missing
The object currently allows:
- Null customer ID
- Blank customer ID
- Null amount
- Negative amount
- Null tags
- Null signature
Improved Version
public final class PaymentCommand {
private final String customerId;
private final BigDecimal amount;
private final List<String> tags;
private final byte[] requestSignature;
public PaymentCommand(
String customerId,
BigDecimal amount,
List<String> tags,
byte[] requestSignature) {
if (customerId == null || customerId.isBlank()) {
throw new IllegalArgumentException(
"customerId is required");
}
Objects.requireNonNull(
amount,
"amount cannot be null");
if (amount.signum() <= 0) {
throw new IllegalArgumentException(
"amount must be positive");
}
Objects.requireNonNull(
tags,
"tags cannot be null");
Objects.requireNonNull(
requestSignature,
"requestSignature cannot be null");
this.customerId = customerId;
this.amount = amount;
this.tags = List.copyOf(tags);
this.requestSignature =
requestSignature.clone();
}
public String getCustomerId() {
return customerId;
}
public BigDecimal getAmount() {
return amount;
}
public List<String> getTags() {
return tags;
}
public byte[] getRequestSignature() {
return requestSignature.clone();
}
}Why These Changes Matter
List.copyOf() prevents structural modification through the original list and returned reference.
Cloning the array at construction prevents the caller from modifying the internal signature through its original reference.
Returning another clone prevents callers from modifying the internal signature through the getter.
Constructor validation ensures the immutable object starts in a valid state.
Security Consideration
Because the byte array represents a request signature, external mutation could potentially cause signature validation inconsistencies.
Defensive copying is therefore particularly important.
For highly sensitive cryptographic values, teams should additionally consider memory lifecycle requirements.
27. Interview Perspective
Immutability appears frequently in:
- Core Java interviews
- Concurrency interviews
- Collection questions
- Senior Java interviews
- Spring Boot design discussions
- Code review interviews
A junior-level interview may ask:
"What is an immutable class?"
A stronger interview may ask:
"This class has all final fields. Is it immutable?"
A senior-level interview may give you:
final List<Address> addresses;and expect you to discuss:
- Reference immutability
- Collection mutability
- Element mutability
- Defensive copying
- Shallow versus deep immutability
You may also be asked why immutability helps concurrency.
A good answer is not simply:
"Immutable objects are thread-safe."
A stronger answer explains that their state cannot change after safe publication, so threads do not compete to update the same state.
Senior interviews may also ask where immutability is inappropriate.
Examples include:
- JPA-managed entities
- Objects representing evolving workflows
- Large structures where constant copying would be expensive
The important skill is choosing immutability intentionally.
28. Interview Questions and Answers
Basic Question
Question: What is an immutable object in Java?
Answer:
An immutable object is an object whose observable state cannot change after construction.
A typical immutable class has final fields, no state-changing setters, constructor validation, and does not expose mutable internal state.
Intermediate Question
Question: Does making every field final make a Java class immutable?
Answer:
No.
final prevents field references from being reassigned.
It does not make referenced objects immutable.
For example:
final List<String> rolescan still reference a mutable list.
The list must also be protected through defensive copying or an immutable representation.
Advanced Question
Question: What is the difference between shallow and deep immutability?
Answer:
Shallow immutability means the object's own references cannot change, but referenced objects may still be mutable.
Deep immutability means the entire reachable state relevant to the object cannot be modified.
For example, an immutable record containing a mutable Address object is only shallowly immutable unless the address is also protected or immutable.
Scenario-Based Question
Question: A record contains List<OrderItem>. Is the record immutable?
Answer:
Not necessarily.
The record component reference is final, but the list can still be mutable.
Also, even if we use:
List.copyOf(items)the OrderItem elements themselves may remain mutable.
I would evaluate both the collection structure and the elements.
Code-Review Question
Question: You see this code:
public List<String> getRoles() {
return roles;
}What would you check?
Answer:
I would check whether roles is mutable.
If it is an internal mutable list, this getter exposes the object's internal state and callers can modify it.
For an immutable object, I would prefer an immutable snapshot such as List.copyOf() during construction and then safely return that list.
Real-Project Question
Question: Where would you use immutable objects in a Spring Boot application?
Answer:
Good candidates include:
- Request commands
- Response models
- Domain value objects
- Pricing results
- Payment results
- Configuration snapshots
- Domain events
- Kafka messages
- Cache values
- Search criteria
I would be more cautious with JPA entities because ORM-managed objects typically have mutable lifecycle requirements.
29. Quick Rule to Remember
An immutable object must protect both its field references and everything mutable those references expose.
30. Final Takeaway
Immutability is one of the most practical ways to make Java state easier to reason about.
It is especially valuable for:
- Domain values
- Results
- Commands
- Events
- Shared data
- Cached data
- Concurrent workflows
What the Developer Should Remember
Declaring fields final is only the beginning.
A production-quality immutable design should also consider:
- Constructor validation
- Mutable collections
- Arrays
- Nested objects
- Date types
- Equality
- Serialization
- Framework compatibility
Use records when they fit the model, but remember that records provide only shallow immutability automatically.
What the Reviewer Should Check
During Pull Request review, ask:
- Should this object's state change at all?
- Can callers mutate it through setters?
- Can callers modify its collections?
- Are constructor parameters copied?
- Are arrays copied?
- Are nested values mutable?
- Can invalid state exist?
- Is the object used concurrently?
- Is it used as a map key?
- Is it cached or published as an event?
- Would immutability conflict with framework lifecycle requirements?
What Should Be Avoided in Production Code
Avoid objects that conceptually represent stable values but allow arbitrary mutation from every consumer.
Do not assume:
final field = immutable objectand do not assume:
Java record = deeply immutable objectA well-designed immutable Java object provides a stable, valid, predictable state that cannot be modified accidentally after construction.