Boilerplate-Free Java: Mastering Immutables.org Code Generation

Immutables.org is a Java annotation processor that generates immutable objects, builders, and utility methods, eliminating the verbose boilerplate typically associated with writing immutable value classes in Java. By generating code at compile time, it provides the benefits of immutability with minimal developer effort.

Why Immutables.org?

Traditional Java Value Class:

// 50+ lines of boilerplate
public final class Person {
private final String name;
private final int age;
private final List<String> emails;
public Person(String name, int age, List<String> emails) {
this.name = name;
this.age = age;
this.emails = Collections.unmodifiableList(new ArrayList<>(emails));
}
// Getters, equals, hashCode, toString...
// Builder class would be even more code
}

With Immutables.org:

// Just 5 lines!
@Value.Immutable
public interface Person {
String name();
int age();
List<String> emails();
}

Setup and Configuration

1. Maven Dependencies

<dependencies>
<dependency>
<groupId>org.immutables</groupId>
<artifactId>value</artifactId>
<version>2.9.3</version>
<scope>provided</scope>
</dependency>
</dependencies>

2. Basic Annotation Processor Setup

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.immutables</groupId>
<artifactId>value</artifactId>
<version>2.9.3</version>
</path>
</annotationProcessorPaths>
<compilerArgs>
<arg>-Amapstruct.defaultComponentModel=spring</arg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>

Core Immutables Usage Patterns

1. Basic Immutable Objects

import org.immutables.value.Value;
@Value.Immutable
public interface User {
String username();
String email();
int age();
boolean active();
}
// Generated class: ImmutableUser
// Usage:
public class UserService {
public void demonstrateUsage() {
// Builder pattern
User user = ImmutableUser.builder()
.username("john_doe")
.email("[email protected]")
.age(30)
.active(true)
.build();
// Copy with modifications
User updatedUser = ImmutableUser.builder()
.from(user)
.age(31)
.build();
System.out.println(user.username()); // "john_doe"
System.out.println(user.equals(updatedUser)); // false
}
}

2. Collections and Default Values

import java.util.List;
import java.util.Set;
import java.util.Map;
@Value.Immutable
public abstract class Product {
public abstract String sku();
public abstract String name();
public abstract List<String> categories();
public abstract Set<String> tags();
public abstract Map<String, String> attributes();
@Value.Default
public boolean available() {
return true;
}
@Value.Default
public int stock() {
return 0;
}
@Value.Derived
public String displayName() {
return name() + " (" + sku() + ")";
}
}
// Usage example
public class ProductDemo {
public void demonstrate() {
Product product = ImmutableProduct.builder()
.sku("PROD-001")
.name("Laptop")
.addCategories("Electronics", "Computers")
.addTags("new", "featured")
.putAttributes("color", "black")
.putAttributes("weight", "2.5kg")
.stock(50)
.build();
System.out.println(product.displayName()); // "Laptop (PROD-001)"
System.out.println(product.available()); // true (default)
}
}

3. Optional and Nullable Fields

import java.util.Optional;
@Value.Immutable
public interface Customer {
String id();
String name();
Optional<String> phoneNumber();
Optional<String> secondaryEmail();
@Value.Auxiliary
Optional<String> internalNotes();
}
// Usage with Optional fields
public class CustomerService {
public void handleCustomer() {
// Customer with phone number
Customer withPhone = ImmutableCustomer.builder()
.id("CUST-001")
.name("Alice Smith")
.phoneNumber("+1234567890")
.build();
// Customer without phone number
Customer withoutPhone = ImmutableCustomer.builder()
.id("CUST-002")
.name("Bob Johnson")
.build();
// Safe access to optional fields
withPhone.phoneNumber().ifPresent(phone -> 
System.out.println("Phone: " + phone));
// Default value for absent optional
String contact = withoutPhone.phoneNumber()
.orElse("No phone provided");
}
}

Advanced Features

1. JSON Serialization with Jackson

import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
@Value.Immutable
@JsonSerialize(as = ImmutableOrder.class)
@JsonDeserialize(as = ImmutableOrder.class)
public interface Order {
String orderId();
String customerId();
List<OrderItem> items();
OrderStatus status();
@Value.Immutable
@JsonSerialize(as = ImmutableOrderItem.class)
@JsonDeserialize(as = ImmutableOrderItem.class)
interface OrderItem {
String productId();
int quantity();
double price();
}
enum OrderStatus {
PENDING, CONFIRMED, SHIPPED, DELIVERED
}
}
// JSON Serialization usage
public class OrderService {
private final ObjectMapper mapper = new ObjectMapper();
public void jsonDemo() throws Exception {
Order order = ImmutableOrder.builder()
.orderId("ORD-001")
.customerId("CUST-001")
.addItems(ImmutableOrderItem.builder()
.productId("PROD-001")
.quantity(2)
.price(299.99)
.build())
.status(Order.OrderStatus.PENDING)
.build();
// Serialize to JSON
String json = mapper.writeValueAsString(order);
System.out.println(json);
// Deserialize from JSON
Order deserialized = mapper.readValue(json, Order.class);
System.out.println(deserialized.orderId()); // "ORD-001"
}
}

2. Custom Builder Methods

@Value.Immutable
@Value.Style(strictBuilder = true)
public abstract class BankAccount {
public abstract String accountNumber();
public abstract String owner();
public abstract double balance();
public abstract Currency currency();
// Custom builder method
public static ImmutableBankAccount.Builder builder() {
return ImmutableBankAccount.builder();
}
// Factory method with validation
public static BankAccount create(String accountNumber, String owner, 
double initialBalance, Currency currency) {
if (initialBalance < 0) {
throw new IllegalArgumentException("Initial balance cannot be negative");
}
return ImmutableBankAccount.builder()
.accountNumber(accountNumber)
.owner(owner)
.balance(initialBalance)
.currency(currency)
.build();
}
// Instance methods
public BankAccount deposit(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Deposit amount must be positive");
}
return ImmutableBankAccount.builder()
.from(this)
.balance(this.balance() + amount)
.build();
}
public BankAccount withdraw(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Withdrawal amount must be positive");
}
if (amount > this.balance()) {
throw new IllegalArgumentException("Insufficient funds");
}
return ImmutableBankAccount.builder()
.from(this)
.balance(this.balance() - amount)
.build();
}
}
// Usage with custom methods
public class BankDemo {
public void demonstrate() {
BankAccount account = BankAccount.create("ACC-123", "John Doe", 1000.0, Currency.USD);
BankAccount afterDeposit = account.deposit(500.0);
BankAccount afterWithdrawal = afterDeposit.withdraw(200.0);
System.out.println("Final balance: " + afterWithdrawal.balance()); // 1300.0
}
}

3. Nested Immutable Structures

@Value.Immutable
public abstract class Project {
public abstract String id();
public abstract String name();
public abstract List<Task> tasks();
public abstract Team team();
@Value.Immutable
public abstract static class Task {
public abstract String taskId();
public abstract String description();
public abstract Optional<LocalDate> dueDate();
public abstract TaskStatus status();
public abstract Set<String> assignees();
public enum TaskStatus {
TODO, IN_PROGRESS, REVIEW, DONE
}
}
@Value.Immutable
public abstract static class Team {
public abstract String teamId();
public abstract String name();
public abstract List<TeamMember> members();
}
@Value.Immutable
public abstract static class TeamMember {
public abstract String memberId();
public abstract String name();
public abstract String role();
}
}
// Complex nested builder
public class ProjectManager {
public Project createProject() {
return ImmutableProject.builder()
.id("PROJ-001")
.name("New Website")
.addTasks(ImmutableTask.builder()
.taskId("TASK-001")
.description("Design homepage")
.dueDate(LocalDate.of(2024, 12, 31))
.status(Project.Task.TaskStatus.TODO)
.addAssignees("DES-001")
.build())
.team(ImmutableTeam.builder()
.teamId("TEAM-001")
.name("Web Team")
.addMembers(ImmutableTeamMember.builder()
.memberId("DES-001")
.name("Alice")
.role("Designer")
.build())
.build())
.build();
}
}

Style Customization and Configuration

1. Global Style Configuration

// Maven compiler configuration for global styles
<compilerArgs>
<arg>-Aimmutables.generated=.*</arg>
<arg>-Aimmutables.style=strict</arg>
<arg>-Aimmutables.auxiliary=.*</arg>
</compilerArgs>

2. Annotation-Based Style Configuration

import org.immutables.value.Value.Style;
// Custom style for different naming conventions
@Style(
typeImmutable = "*", // Generate Immutable* for all types
typeAbstract = "*", // Accept any abstract type
visibility = Style.ImplementationVisibility.PUBLIC,
builder = "new", // Use 'new' instead of 'builder'
build = "create", // Use 'create' instead of 'build'
defaults = @Value.Immutable(builder = false) // Disable builder by default
)
public @interface CustomStyle {}
@CustomStyle
@Value.Immutable
public interface Configuration {
String host();
int port();
boolean sslEnabled();
}
// Usage with custom style
public class ConfigDemo {
public void demonstrate() {
// Now uses 'new' and 'create' instead of 'builder' and 'build'
Configuration config = new ImmutableConfiguration()
.host("localhost")
.port(8080)
.sslEnabled(false)
.create();
}
}

3. Different Styles for Different Use Cases

// Jackson-friendly style
@Style(
typeImmutable = "*",
visibility = Style.ImplementationVisibility.PACKAGE,
overshadowImplementation = true,
builderVisibility = Style.ImplementationVisibility.PACKAGE
)
@interface JacksonStyle {}
// MongoDB-friendly style
@Style(
typeImmutable = "Mongo*",
passAnnotations = { org.mongodb.morphia.annotations.Entity.class }
)
@interface MongoStyle {}
@JacksonStyle
@Value.Immutable
public interface ApiResponse {
boolean success();
String message();
Object data();
}
@MongoStyle
@Value.Immutable
@Entity("users")
public abstract class MongoUser {
@org.mongodb.morphia.annotations.Id
public abstract String id();
public abstract String username();
public abstract String email();
}

Validation and Preconditions

1. Attribute Validation

@Value.Immutable
@Value.Style(strictBuilder = true)
public abstract class ValidatedUser {
@Value.Check
protected void check() {
if (username().length() < 3) {
throw new IllegalStateException("Username must be at least 3 characters");
}
if (age() < 0 || age() > 150) {
throw new IllegalStateException("Age must be between 0 and 150");
}
}
public abstract String username();
public abstract int age();
public abstract List<String> roles();
// Individual attribute validation
@Value.Check
protected void validateRoles() {
if (roles().isEmpty()) {
throw new IllegalStateException("User must have at least one role");
}
}
}
// Usage with validation
public class ValidationDemo {
public void demonstrate() {
try {
ImmutableValidatedUser.builder()
.username("ab") // Too short - will fail
.age(25)
.addRoles("USER")
.build();
} catch (IllegalStateException e) {
System.out.println("Validation failed: " + e.getMessage());
}
}
}

2. Precondition Methods

@Value.Immutable
@Value.Style(strictBuilder = true)
public abstract class InventoryItem {
public abstract String sku();
public abstract String name();
public abstract int quantity();
public abstract double price();
// Precondition methods in builder
public static class Builder extends ImmutableInventoryItem.Builder {
@Override
public InventoryItem build() {
// Custom validation logic
if (quantity < 0) {
throw new IllegalStateException("Quantity cannot be negative");
}
if (price <= 0) {
throw new IllegalStateException("Price must be positive");
}
return super.build();
}
}
}

Integration with Other Libraries

1. Spring Boot Configuration

@Value.Immutable
@JsonSerialize(as = ImmutableAppConfig.class)
@JsonDeserialize(as = ImmutableAppConfig.class)
public interface AppConfig {
String databaseUrl();
String databaseUsername();
String databasePassword();
int serverPort();
boolean debugEnabled();
@Value.Default
default int cacheSize() {
return 1000;
}
}
@Component
@ConfigurationProperties(prefix = "app")
@EnableConfigurationProperties
public class ApplicationConfiguration {
private AppConfig config;
public void setConfig(AppConfig config) {
this.config = config;
}
public AppConfig getConfig() {
return config;
}
}
// application.yml
app:
config:
database-url: "jdbc:postgresql://localhost:5432/mydb"
database-username: "admin"
database-password: "secret"
server-port: 8080
debug-enabled: true
cache-size: 5000

2. JPA Entity Projections

// Immutable projection for JPA queries
@Value.Immutable
public interface UserProjection {
String getUsername();
String getEmail();
LocalDateTime getCreatedAt();
// Static factory method from entity
static UserProjection from(UserEntity entity) {
return ImmutableUserProjection.builder()
.username(entity.getUsername())
.email(entity.getEmail())
.createdAt(entity.getCreatedAt())
.build();
}
}
// JPA Repository usage
@Repository
public interface UserRepository extends JpaRepository<UserEntity, Long> {
@Query("SELECT new package.ImmutableUserProjection(u.username, u.email, u.createdAt) " +
"FROM UserEntity u WHERE u.active = true")
List<UserProjection> findActiveUserProjections();
}

Testing with Immutables

1. Test Data Builders

@Value.Immutable
public interface TestUser {
String username();
String email();
int age();
List<String> permissions();
// Test data builder pattern
class Builder extends ImmutableTestUser.Builder {
public Builder withDefaultValues() {
return this
.username("testuser")
.email("[email protected]")
.age(25)
.addPermissions("READ", "WRITE");
}
public Builder admin() {
return withDefaultValues()
.username("admin")
.addPermissions("ADMIN");
}
}
}
// Usage in tests
public class UserServiceTest {
@Test
public void testUserCreation() {
TestUser user = new TestUser.Builder()
.withDefaultValues()
.username("specific_user")
.build();
assertThat(user.username()).isEqualTo("specific_user");
assertThat(user.permissions()).contains("READ", "WRITE");
}
@Test 
public void testAdminUser() {
TestUser admin = new TestUser.Builder()
.admin()
.build();
assertThat(admin.permissions()).contains("ADMIN");
}
}

2. JSON Testing

public class JsonSerializationTest {
private final ObjectMapper mapper = new ObjectMapper();
@Test
public void testSerializationRoundTrip() throws Exception {
TestUser original = ImmutableTestUser.builder()
.username("testuser")
.email("[email protected]")
.age(30)
.addPermissions("READ")
.build();
String json = mapper.writeValueAsString(original);
TestUser deserialized = mapper.readValue(json, TestUser.class);
assertThat(deserialized).isEqualTo(original);
}
}

Performance Considerations

1. Lazy Derived Attributes

@Value.Immutable
public abstract class ExpensiveComputation {
public abstract String input();
@Value.Lazy
public String expensiveResult() {
// This computation is cached after first call
try {
Thread.sleep(1000); // Simulate expensive operation
return input().toUpperCase();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}
}
// Usage
public class PerformanceDemo {
public void demonstrate() {
ExpensiveComputation comp = ImmutableExpensiveComputation.of("hello");
// First call computes and caches
long start1 = System.currentTimeMillis();
String result1 = comp.expensiveResult();
long time1 = System.currentTimeMillis() - start1;
// Second call uses cached value
long start2 = System.currentTimeMillis();
String result2 = comp.expensiveResult();
long time2 = System.currentTimeMillis() - start2;
System.out.println("First call: " + time1 + "ms"); // ~1000ms
System.out.println("Second call: " + time2 + "ms"); // ~0ms
}
}

Best Practices and Pitfalls

1. Do's and Don'ts

// ✅ GOOD: Simple, focused interfaces
@Value.Immutable
public interface GoodExample {
String name();
int value();
Optional<String> description();
}
// ❌ BAD: Too many responsibilities
@Value.Immutable
public interface BadExample {
String name();
int value();
String description();
List<String> tags();
Map<String, String> metadata();
LocalDateTime created();
LocalDateTime updated();
// ... 20 more fields
}
// ✅ GOOD: Use builders for complex objects
@Value.Immutable
public abstract class ComplexButManageable {
public abstract String id();
public abstract Configuration config();
public abstract List<Item> items();
@Value.Immutable
public abstract static class Configuration {
public abstract String host();
public abstract int port();
public abstract boolean ssl();
}
}
// ✅ GOOD: Use default values appropriately
@Value.Immutable
public interface SmartDefaults {
String name();
@Value.Default
default boolean enabled() {
return true;
}
@Value.Default
default int maxRetries() {
return 3;
}
@Value.Default
default Duration timeout() {
return Duration.ofSeconds(30);
}
}

Migration Strategy

1. Gradual Migration from Lombok

// Phase 1: Coexistence
// lombok.config
lombok.anyConstructor.suppressConstructorProperties = true
// Existing Lombok class
@Data
@Builder
@AllArgsConstructor
public class LegacyUser {
private String username;
private String email;
private int age;
}
// New Immutables interface
@Value.Immutable
public interface NewUser {
String username();
String email();
int age();
// Conversion method
static NewUser from(LegacyUser legacy) {
return ImmutableNewUser.builder()
.username(legacy.getUsername())
.email(legacy.getEmail())
.age(legacy.getAge())
.build();
}
LegacyUser toLegacy() {
return LegacyUser.builder()
.username(username())
.email(email())
.age(age())
.build();
}
}

Conclusion

Immutables.org provides significant advantages for Java development:

Key Benefits:

  • Reduced Boilerplate: 90% less code for value objects
  • Compile-time Safety: All code generation happens at compile time
  • Runtime Performance: No reflection overhead
  • Rich Feature Set: Builders, JSON, validation, and more
  • Framework Integration: Works with Spring, Jackson, JPA, etc.

When to Use:

  • Configuration objects
  • DTOs and API models
  • Value objects in domain-driven design
  • Immutable data structures
  • Projections and view models

When to Consider Alternatives:

  • Extremely simple data holders (record classes in Java 14+)
  • Cases requiring runtime bytecode generation
  • Projects already heavily invested in other code generation tools

Immutables.org strikes an excellent balance between convenience and control, making it an ideal choice for modern Java applications that value immutability, type safety, and maintainability.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper