Loading content...
We've established why immutable objects are thread-safe and how they solve concurrency problems. Now we turn to the practical question: How do we actually design and implement deeply immutable objects?
This isn't as straightforward as adding final to every field. Deep immutability requires careful attention to:
This page provides a comprehensive toolkit for creating bulletproof immutable objects suitable for high-concurrency environments.
By the end of this page, you will master: the rules for safe immutable construction, the Builder pattern for complex immutable objects, defensive copying strategies for mutable dependencies, validation patterns that preserve immutability, factory methods and static construction, and modern language features (records, data classes) that simplify immutability.
The safety of an immutable object begins in its constructor. Follow these rules to ensure proper construction:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
// ✅ CORRECT: Following all rules for immutable constructionpublic final class ImmutableOrder { // Rule 2: class is final // Rule 1: All fields are final private final String orderId; private final String customerId; private final List<OrderItem> items; // Will store as immutable private final Instant createdAt; private final BigDecimal total; public ImmutableOrder(String orderId, String customerId, List<OrderItem> items, Instant createdAt) { // Rule 6: All validation and initialization in constructor Objects.requireNonNull(orderId, "orderId must not be null"); Objects.requireNonNull(customerId, "customerId must not be null"); Objects.requireNonNull(items, "items must not be null"); this.orderId = orderId; this.customerId = customerId; // Rule 4: Defensive copy of mutable input // Rule 7: Store as immutable type // Also ensures each item is immutable (assuming OrderItem is immutable) this.items = List.copyOf(items); // Instant is already immutable - no copy needed this.createdAt = createdAt != null ? createdAt : Instant.now(); // Compute derived values in constructor this.total = items.stream() .map(OrderItem::getSubtotal) .reduce(BigDecimal.ZERO, BigDecimal::add); // Rule 5: We never pass 'this' anywhere during construction } // Rule 3: Getters return immutable data public String getOrderId() { return orderId; } public String getCustomerId() { return customerId; } public List<OrderItem> getItems() { return items; } // Already immutable list public Instant getCreatedAt() { return createdAt; } public BigDecimal getTotal() { return total; }} // OrderItem must also be immutable for ImmutableOrder to be deeply immutablepublic final class OrderItem { private final String productId; private final String productName; private final int quantity; private final BigDecimal unitPrice; public OrderItem(String productId, String productName, int quantity, BigDecimal unitPrice) { this.productId = productId; this.productName = productName; this.quantity = quantity; this.unitPrice = unitPrice; } public BigDecimal getSubtotal() { return unitPrice.multiply(BigDecimal.valueOf(quantity)); } // Getters only, no setters public String getProductId() { return productId; } public String getProductName() { return productName; } public int getQuantity() { return quantity; } public BigDecimal getUnitPrice() { return unitPrice; }}When 'this' escapes during construction (passed to another object, stored in a collection, used to start a thread), other threads may see the object in a partially constructed state. This breaks the memory model guarantees for final fields. The Java Memory Model only guarantees visibility of final fields after the constructor completes AND before the reference is published.
When immutable objects have many fields, constructors become unwieldy. The Builder pattern provides a clean solution: a mutable builder object that accumulates configuration and then produces an immutable instance.
Why Builders Work for Immutability:
build() method atomically creates the immutable object123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
public final class ImmutableHttpRequest { private final String method; private final String url; private final Map<String, String> headers; private final byte[] body; private final Duration timeout; private final int maxRetries; private final boolean followRedirects; // Private constructor - only accessible via Builder private ImmutableHttpRequest(Builder builder) { this.method = Objects.requireNonNull(builder.method, "method required"); this.url = Objects.requireNonNull(builder.url, "url required"); this.headers = Map.copyOf(builder.headers); // Immutable copy this.body = builder.body != null ? Arrays.copyOf(builder.body, builder.body.length) // Defensive copy : null; this.timeout = builder.timeout; this.maxRetries = builder.maxRetries; this.followRedirects = builder.followRedirects; } // Static factory to start building public static Builder builder() { return new Builder(); } // Static factory for common case public static Builder get(String url) { return new Builder().method("GET").url(url); } public static Builder post(String url) { return new Builder().method("POST").url(url); } // The mutable Builder class public static class Builder { private String method; private String url; private Map<String, String> headers = new HashMap<>(); private byte[] body; private Duration timeout = Duration.ofSeconds(30); // Default private int maxRetries = 3; // Default private boolean followRedirects = true; // Default // Fluent setters return 'this' for chaining public Builder method(String method) { this.method = method; return this; } public Builder url(String url) { this.url = url; return this; } public Builder header(String name, String value) { this.headers.put(name, value); return this; } public Builder headers(Map<String, String> headers) { this.headers.putAll(headers); return this; } public Builder body(byte[] body) { this.body = body; return this; } public Builder body(String body) { this.body = body.getBytes(StandardCharsets.UTF_8); return this; } public Builder timeout(Duration timeout) { this.timeout = timeout; return this; } public Builder maxRetries(int maxRetries) { this.maxRetries = maxRetries; return this; } public Builder followRedirects(boolean followRedirects) { this.followRedirects = followRedirects; return this; } // Build creates the immutable object public ImmutableHttpRequest build() { // Validation at build time if (method == null || url == null) { throw new IllegalStateException("method and url are required"); } if (maxRetries < 0) { throw new IllegalArgumentException("maxRetries must be >= 0"); } return new ImmutableHttpRequest(this); } } // Copy-on-modify: derive new request from existing public Builder toBuilder() { return new Builder() .method(this.method) .url(this.url) .headers(this.headers) .body(this.body) .timeout(this.timeout) .maxRetries(this.maxRetries) .followRedirects(this.followRedirects); } // Getters public String getMethod() { return method; } public String getUrl() { return url; } public Map<String, String> getHeaders() { return headers; } // Already immutable public byte[] getBody() { return body != null ? Arrays.copyOf(body, body.length) : null; // Defensive copy out } public Duration getTimeout() { return timeout; } public int getMaxRetries() { return maxRetries; } public boolean isFollowRedirects() { return followRedirects; }}Usage Examples:
12345678910111213141516171819202122232425
// Simple creation with defaultsImmutableHttpRequest getRequest = ImmutableHttpRequest.get("https://api.example.com/users") .header("Accept", "application/json") .build(); // Full configurationImmutableHttpRequest postRequest = ImmutableHttpRequest.builder() .method("POST") .url("https://api.example.com/orders") .header("Content-Type", "application/json") .header("Authorization", "Bearer token123") .body("{\"item\": \"widget\", \"qty\": 5}") .timeout(Duration.ofSeconds(60)) .maxRetries(5) .followRedirects(false) .build(); // Deriving from existing (copy-on-modify)ImmutableHttpRequest retryRequest = postRequest.toBuilder() .timeout(Duration.ofSeconds(120)) // Longer timeout for retry .build(); // Original is unchangedSystem.out.println(postRequest.getTimeout()); // 60 secondsSystem.out.println(retryRequest.getTimeout()); // 120 secondsBuilders themselves are typically NOT thread-safe—they're meant for single-threaded configuration. This is fine because builders are usually local variables used briefly before calling build(). The resulting immutable object IS thread-safe and can be shared freely.
Not all types are immutable. When you must work with mutable types (legacy code, third-party libraries, inherently mutable data like arrays), defensive copying is essential.
When to Copy:
| Situation | Strategy |
|---|---|
| Receiving mutable object in constructor | Copy on entry (store the copy) |
| Returning mutable field from getter | Copy on exit (return a copy) |
| Receiving mutable object that you'll return later | Copy both ways |
| Storing/returning immutable type | No copy needed |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
public final class ImmutableEvent { private final String eventId; private final Instant occurredAt; // Immutable - no copy needed private final Date legacyDate; // MUTABLE - must copy! private final byte[] payload; // MUTABLE - must copy! private final List<String> tags; // Store as immutable private final int[] coordinates; // MUTABLE array - must copy! public ImmutableEvent(String eventId, Instant occurredAt, Date legacyDate, byte[] payload, List<String> tags, int[] coordinates) { this.eventId = eventId; // Instant is immutable - direct assignment OK this.occurredAt = occurredAt; // Date is mutable - DEFENSIVE COPY on input this.legacyDate = legacyDate != null ? new Date(legacyDate.getTime()) : null; // byte[] is mutable - DEFENSIVE COPY on input this.payload = payload != null ? Arrays.copyOf(payload, payload.length) : null; // List - convert to immutable on input this.tags = tags != null ? List.copyOf(tags) : List.of(); // int[] is mutable - DEFENSIVE COPY on input this.coordinates = coordinates != null ? Arrays.copyOf(coordinates, coordinates.length) : null; } public String getEventId() { return eventId; } // Immutable type - direct return OK public Instant getOccurredAt() { return occurredAt; } // Mutable type - DEFENSIVE COPY on output public Date getLegacyDate() { return legacyDate != null ? new Date(legacyDate.getTime()) : null; } // Mutable array - DEFENSIVE COPY on output public byte[] getPayload() { return payload != null ? Arrays.copyOf(payload, payload.length) : null; } // Immutable List - direct return OK public List<String> getTags() { return tags; } // Mutable array - DEFENSIVE COPY on output public int[] getCoordinates() { return coordinates != null ? Arrays.copyOf(coordinates, coordinates.length) : null; } // Alternative: return as immutable wrapper where possible public IntStream coordinatesStream() { return coordinates != null ? Arrays.stream(coordinates) : IntStream.empty(); }}For deeply nested mutable structures, you need recursive deep copying. Libraries like Apache Commons' SerializationUtils.clone() or Google Guava's immutable collections can help. Consider whether you really need mutable types in your immutable objects—often there's an immutable alternative (Instant instead of Date, List.of() instead of ArrayList).
Immutable objects have a powerful property: once constructed, their invariants hold forever. A valid object can never become invalid. This means validation logic belongs in the constructor—enforce invariants once, trust them forever.
Validation Patterns:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
public final class EmailAddress { private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$"); private final String value; private EmailAddress(String value) { this.value = value; } // Factory method with validation public static EmailAddress of(String value) { Objects.requireNonNull(value, "email address cannot be null"); String trimmed = value.trim().toLowerCase(); if (trimmed.isEmpty()) { throw new IllegalArgumentException("email address cannot be empty"); } if (!EMAIL_PATTERN.matcher(trimmed).matches()) { throw new IllegalArgumentException("invalid email format: " + value); } return new EmailAddress(trimmed); } // Optional-returning factory for graceful failure public static Optional<EmailAddress> tryParse(String value) { try { return Optional.of(of(value)); } catch (IllegalArgumentException e) { return Optional.empty(); } } public String getValue() { return value; } @Override public String toString() { return value; } @Override public boolean equals(Object o) { return o instanceof EmailAddress && value.equals(((EmailAddress) o).value); } @Override public int hashCode() { return value.hashCode(); }} // Usage: Invalid email cannot existEmailAddress valid = EmailAddress.of("user@example.com"); // OKEmailAddress invalid = EmailAddress.of("not-an-email"); // Throws immediately // Any code that has an EmailAddress object KNOWS it's valid// No need to re-validate anywhere else in the codebase!Complex Multi-Field Invariants:
For invariants involving multiple fields, validate after all fields are set but before the constructor completes:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
public final class DateRange { private final LocalDate startDate; private final LocalDate endDate; public DateRange(LocalDate startDate, LocalDate endDate) { // Individual field validation this.startDate = Objects.requireNonNull(startDate, "startDate required"); this.endDate = Objects.requireNonNull(endDate, "endDate required"); // Multi-field invariant validation if (endDate.isBefore(startDate)) { throw new IllegalArgumentException( "endDate (" + endDate + ") cannot be before startDate (" + startDate + ")"); } } // Factory methods for common cases public static DateRange ofDays(LocalDate start, int days) { return new DateRange(start, start.plusDays(days)); } public static DateRange thisMonth() { LocalDate today = LocalDate.now(); return new DateRange( today.withDayOfMonth(1), today.withDayOfMonth(today.lengthOfMonth()) ); } // Business logic methods can trust invariants public long getDays() { return ChronoUnit.DAYS.between(startDate, endDate) + 1; } public boolean contains(LocalDate date) { return !date.isBefore(startDate) && !date.isAfter(endDate); } public boolean overlaps(DateRange other) { return !this.endDate.isBefore(other.startDate) && !this.startDate.isAfter(other.endDate); } // Copy-on-modify with revalidation public DateRange extendBy(int days) { return new DateRange(startDate, endDate.plusDays(days)); } public LocalDate getStartDate() { return startDate; } public LocalDate getEndDate() { return endDate; }}This pattern follows the 'Parse, Don't Validate' principle: rather than validating data and then working with raw strings, we parse the data into a validated type. The type itself guarantees validity. An EmailAddress is always valid; a DateRange always has end >= start. This eliminates entire categories of bugs.
Factory methods (static methods that return instances) are often superior to constructors for immutable objects. They offer several advantages:
of(), from(), parse(), copyOf() communicate intent123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
public final class Money { // Common instances cached private static final Money ZERO_USD = new Money(BigDecimal.ZERO, Currency.getInstance("USD")); private static final Money ZERO_EUR = new Money(BigDecimal.ZERO, Currency.getInstance("EUR")); private final BigDecimal amount; private final Currency currency; // Private constructor - use factory methods private Money(BigDecimal amount, Currency currency) { this.amount = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.HALF_UP); this.currency = currency; } // Named factory - clear intent public static Money of(BigDecimal amount, Currency currency) { Objects.requireNonNull(amount, "amount required"); Objects.requireNonNull(currency, "currency required"); // Return cached zero instances if (amount.compareTo(BigDecimal.ZERO) == 0) { if ("USD".equals(currency.getCurrencyCode())) return ZERO_USD; if ("EUR".equals(currency.getCurrencyCode())) return ZERO_EUR; } return new Money(amount, currency); } // Convenience factories public static Money usd(double amount) { return of(BigDecimal.valueOf(amount), Currency.getInstance("USD")); } public static Money eur(double amount) { return of(BigDecimal.valueOf(amount), Currency.getInstance("EUR")); } // Parsing factory with Optional for graceful failure public static Optional<Money> parse(String text) { try { // Expected format: "100.00 USD" String[] parts = text.trim().split("\\s+"); if (parts.length != 2) return Optional.empty(); BigDecimal amount = new BigDecimal(parts[0]); Currency currency = Currency.getInstance(parts[1]); return Optional.of(of(amount, currency)); } catch (NumberFormatException | IllegalArgumentException e) { return Optional.empty(); } } // Zero factory public static Money zero(Currency currency) { return of(BigDecimal.ZERO, currency); } // Instance methods that return new Money objects (immutable operations) public Money add(Money other) { requireSameCurrency(other); return of(this.amount.add(other.amount), this.currency); } public Money subtract(Money other) { requireSameCurrency(other); return of(this.amount.subtract(other.amount), this.currency); } public Money multiply(int factor) { return of(this.amount.multiply(BigDecimal.valueOf(factor)), this.currency); } public Money negate() { return of(this.amount.negate(), this.currency); } private void requireSameCurrency(Money other) { if (!this.currency.equals(other.currency)) { throw new IllegalArgumentException( "Cannot combine " + currency + " with " + other.currency); } } public BigDecimal getAmount() { return amount; } public Currency getCurrency() { return currency; } @Override public String toString() { return amount.toPlainString() + " " + currency.getCurrencyCode(); } // equals and hashCode based on value semantics @Override public boolean equals(Object o) { if (!(o instanceof Money)) return false; Money other = (Money) o; return amount.compareTo(other.amount) == 0 && currency.equals(other.currency); } @Override public int hashCode() { return Objects.hash(amount.stripTrailingZeros(), currency); }} // UsageMoney price = Money.usd(99.99);Money tax = Money.usd(8.00);Money total = price.add(tax); // Returns new Money, originals unchanged Money parsed = Money.parse("49.99 EUR").orElse(Money.zero(Currency.getInstance("EUR")));Java's Integer.valueOf() caches instances for -128 to 127. Boolean caches TRUE and FALSE. Your immutable objects can do the same for frequently-used values. Since immutable objects are identical to any other instance with the same state, caching is safe and improves performance.
Modern language versions have introduced features that make immutability easier:
Java 14+ records provide concise immutable classes with automatic equals, hashCode, and toString:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
// Before records: ~50 lines of boilerplatepublic final class PointOld { private final int x; private final int y; public PointOld(int x, int y) { this.x = x; this.y = y; } public int x() { return x; } public int y() { return y; } @Override public boolean equals(Object o) { if (!(o instanceof PointOld)) return false; PointOld other = (PointOld) o; return x == other.x && y == other.y; } @Override public int hashCode() { return Objects.hash(x, y); } @Override public String toString() { return "PointOld[x=" + x + ", y=" + y + "]"; }} // With records: 1 line!public record Point(int x, int y) {} // Records can have custom constructors for validation:public record Email(String value) { // Compact constructor for validation public Email { Objects.requireNonNull(value, "email required"); if (!value.contains("@")) { throw new IllegalArgumentException("invalid email: " + value); } value = value.toLowerCase().trim(); // Can reassign in compact constructor } // Additional factory methods public static Email of(String value) { return new Email(value); }} // Records can have additional methodspublic record Person(String firstName, String lastName, int age) { // Copy-on-modify helper public Person withAge(int newAge) { return new Person(firstName, lastName, newAge); } public String fullName() { return firstName + " " + lastName; }}Even with understanding of immutability principles, certain pitfalls catch developers repeatedly:
| Pitfall | Problem | Solution |
|---|---|---|
| Mutable field in record/data class | Java record with List field is shallowly immutable | Use List.copyOf() in compact constructor or use immutable types |
| Getter returns array directly | Arrays are always mutable; caller can modify | Return defensive copy: Arrays.copyOf() |
| Date/Calendar in immutable class | These are mutable; leaking them breaks immutability | Use Instant, LocalDate, etc.; defensive copy if legacy |
| StringBuilder toString() race | StringBuilder.toString() doesn't copy the buffer | Ensure no references to StringBuilder remain |
| Lazy initialization without volatile | Non-thread-safe lazy init in 'immutable' object | Use volatile, synchronized, or holder class idiom |
| Subclass adds mutable field | Non-final class can be subclassed with mutation | Make classes final or use sealed classes |
| Object.freeze() doesn't deep freeze | JS Object.freeze() is shallow | Implement recursive deep freeze or use immutable library |
Java records look immutable but aren't deeply so by default. record Order(List<Item> items) {} allows order.items().clear() to modify internal state! Always use record Order(List<Item> items) { public Order { items = List.copyOf(items); } } for true immutability.
We've covered the practical techniques for designing deeply immutable objects suitable for concurrent environments:
What's Next:
With individual immutable objects mastered, the final page explores immutable collections—specialized data structures designed for efficient immutable operations. We'll cover copy-on-write collections, persistent data structures, and libraries that make working with large immutable collections practical and performant.
You now have a complete toolkit for designing immutable objects: construction rules, builders, defensive copying, validation patterns, factory methods, and modern language features. Next, we'll explore immutable collections for managing sets of data immutably.