Designing Immutable Objects

17 min read

Object-Oriented Design and SOLID Review — review Java classes for mutable state that should be made immutable to improve thread safety, predictability, and reliability.

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:

JAVA
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:

JAVA
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.

JAVA
private final String customerId;

No State-Changing Setters

Avoid:

JAVA
public void setCustomerId(String customerId) {
    this.customerId = customerId;
}

Mutable Inputs Are Copied

Consider:

JAVA
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:

JAVA
items.clear();

and modify the object's apparent state.

A safer approach is:

JAVA
this.items = List.copyOf(items);

Mutable Internal State Is Not Exposed

This is also unsafe:

JAVA
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:

JAVA
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:

JAVA
public record PaymentRequest(
        String customerId,
        BigDecimal amount,
        Currency currency) {
}

However, records are only shallowly immutable.

A record containing:

JAVA
List<String> roles

still 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 final where 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(), or Map.copyOf() for collection snapshots when appropriate.
  • Return new objects when business operations produce changed values.
  • Do not rely only on the final keyword.
  • 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.

JAVA
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:

JAVA
@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:

JAVA
@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:

JAVA
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:

JAVA
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.
  • appliedPromotions exposes mutable state.
  • There is no constructor enforcing required fields.
  • A partially initialized PricingResult can 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

PricingResult represents 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.

JAVA
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:

JAVA
@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:

JAVA
@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:

JAVA
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

AreaMutable VersionImmutable Version
InitializationCan be partialComplete during construction
SettersAvailable everywhereNo state-changing setters
CollectionExposed as mutableDefensive immutable copy
InvariantsEasy to breakValidated during construction
DebuggingState may change unexpectedlyState remains predictable
Thread SafetyRequires more careSafer to share
CachingConsumers may alter cached dataMuch safer for cached values
TestabilityTests may depend on mutationStable input/output model
API ContractOwnership unclearSnapshot semantics are clear
ReliabilityCan become inconsistentInvalid mutation prevented

13. Real Project Scenario

Consider a banking application.

A service calculates loan eligibility and produces:

JAVA
LoanDecision

The 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:

JAVA
decision.setInterestRate(...);

or:

JAVA
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:

JAVA
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:

JAVA
private final List<String> roles;

does not make the list immutable.

Only the reference is final.

Returning Internal Collections Directly

Example:

JAVA
public List<String> getRoles() {
    return roles;
}

This is unsafe when roles is mutable.

Storing Constructor Collections Directly

Example:

JAVA
this.roles = roles;

The caller may still retain and mutate the original list.

Using Unmodifiable Views Incorrectly

Consider:

JAVA
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:

JAVA
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:

JAVA
Instant
LocalDate
LocalDateTime
OffsetDateTime
ZonedDateTime

Making 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:

JAVA
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:

JAVA
List<List<String>>

Copying only the outer list does not automatically make inner mutable lists immutable.

Mutable Element Objects

This:

JAVA
List.copyOf(customers)

protects the list structure but not mutable Customer objects inside the list.

Arrays

Arrays are mutable.

This is unsafe:

JAVA
private final byte[] token;

Use defensive copying:

JAVA
this.token = token.clone();

and return a copy:

JAVA
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:

JAVA
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:

JAVA
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:

JAVA
HashMap
HashSet

If 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:

JAVA
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

JAVA
@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:

JAVA
total = subtotal - discount + tax

is 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:

JAVA
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:

JAVA
Money
EmailAddress
DateRange
PaymentReference
OrderNumber
PricingResult

Validate at Construction

An immutable object should not be creatable in an invalid state.

Prefer Immutable Java Types

Prefer:

JAVA
LocalDate

over:

JAVA
Date

when appropriate.

Use Defensive Collection Copies

Prefer:

JAVA
List.copyOf(items)

when snapshot semantics are intended.

Keep Business Invariants Inside the Type

For example, a DateRange can ensure:

JAVA
start <= end

during construction.

Prefer Methods Returning New Values

Example:

JAVA
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:

JAVA
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(), or Map.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 HashMap key?
  • 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

  1. > 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?
  1. > The field is final, but the referenced list is still mutable. Please consider storing an immutable defensive copy.
  1. > getItems() exposes our internal collection directly. A caller can currently modify this object's state without using any method on the object itself.
  1. > 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.
  1. > This looks like a good record candidate, but we should still copy the List component because records provide shallow rather than deep immutability.
  1. > This type is used as a HashMap key. Fields involved in equals()/hashCode() should not change after insertion.
  1. > We currently store the caller's array reference directly. Please clone it during construction and when returning it to avoid external mutation.
  1. > The object is published through our async event flow. Making the event immutable would prevent one consumer from changing data observed by another.
  1. > Please verify Jackson serialization/deserialization before removing these setters, since this DTO is part of the REST contract.
  1. > 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.

JAVA
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:

JAVA
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:

JAVA
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

JAVA
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:

JAVA
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:

JAVA
final List<String> roles

can 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:

JAVA
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:

JAVA
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:

JAVA
final field = immutable object

and do not assume:

JAVA
Java record = deeply immutable object

A well-designed immutable Java object provides a stable, valid, predictable state that cannot be modified accidentally after construction.