Skip to content

Immutability & Value Objects


Definition

Immutability Definition


Benefits of Immutability

Immutability Benefits


Creating Immutable Classes

// Recipe for immutable class

public final class Money {  // 1. Class is final

    private final BigDecimal amount;  // 2. Fields are final
    private final Currency currency;

    // 3. All values set in constructor
    public Money(BigDecimal amount, Currency currency) {
        // 4. Validate
        if (amount == null || currency == null) {
            throw new IllegalArgumentException("Null values not allowed");
        }
        // 5. Defensive copy mutable inputs (not needed here - BigDecimal is immutable)
        this.amount = amount;
        this.currency = currency;
    }

    // 6. No setters

    // 7. Getters return immutable values or copies
    public BigDecimal getAmount() {
        return amount;  // BigDecimal is immutable, safe to return
    }

    public Currency getCurrency() {
        return currency;  // Currency is immutable
    }

    // 8. Operations return new instances
    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("Currency mismatch");
        }
        return new Money(this.amount.add(other.amount), this.currency);
    }

    public Money multiply(int factor) {
        return new Money(this.amount.multiply(BigDecimal.valueOf(factor)), currency);
    }

    // 9. equals and hashCode based on values
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Money money = (Money) o;
        return amount.compareTo(money.amount) == 0 &&
               currency.equals(money.currency);
    }

    @Override
    public int hashCode() {
        return Objects.hash(amount, currency);
    }
}

Java Records (Java 16+)

// Records make immutable value objects easy

// Traditional way: ~50 lines
public final class Point {
    private final int x;
    private final int y;

    public Point(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) { ... }

    @Override
    public int hashCode() { ... }

    @Override
    public String toString() { ... }
}

// With records: 1 line!
public record Point(int x, int y) { }

// Records are:
// - Immutable (final fields)
// - equals/hashCode based on all fields
// - toString auto-generated
// - Can add methods and validation

public record Money(BigDecimal amount, Currency currency) {

    // Compact constructor for validation
    public Money {
        if (amount == null || currency == null) {
            throw new IllegalArgumentException("Null not allowed");
        }
        if (amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Negative not allowed");
        }
    }

    // Additional methods
    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("Currency mismatch");
        }
        return new Money(amount.add(other.amount), currency);
    }
}

Value Objects in Practice

// Instead of primitive obsession, use value objects

// BAD: Primitive obsession
public class Order {
    private String customerId;    // What format? Validated?
    private String email;         // Valid email?
    private double total;         // What currency?
    private String status;        // What values allowed?
}

// GOOD: Rich value objects
public class Order {
    private CustomerId customerId;
    private EmailAddress email;
    private Money total;
    private OrderStatus status;
}

// Each value object validates itself
public record CustomerId(String value) {
    public CustomerId {
        if (value == null || !value.matches("^CUST-\\d{6}$")) {
            throw new IllegalArgumentException("Invalid customer ID: " + value);
        }
    }
}

public record EmailAddress(String value) {
    private static final Pattern EMAIL_PATTERN =
        Pattern.compile("^[\\w.-]+@[\\w.-]+\\.\\w+$");

    public EmailAddress {
        if (value == null || !EMAIL_PATTERN.matcher(value).matches()) {
            throw new InvalidEmailException(value);
        }
        value = value.toLowerCase();  // Normalize
    }
}

public enum OrderStatus {
    PENDING, PAID, SHIPPED, DELIVERED, CANCELLED
}

// Now impossible to create order with invalid email!

With Methods Pattern

// Modifying immutable objects using "with" methods

public record User(
    UserId id,
    String name,
    EmailAddress email,
    boolean active
) {
    // "With" methods return modified copy
    public User withName(String newName) {
        return new User(id, newName, email, active);
    }

    public User withEmail(EmailAddress newEmail) {
        return new User(id, name, newEmail, active);
    }

    public User withActive(boolean newActive) {
        return new User(id, name, email, newActive);
    }
}

// Usage
User user = new User(id, "John", email, true);
User updated = user
    .withName("John Smith")
    .withEmail(new EmailAddress("[email protected]"));

// Original user unchanged!

// Lombok can generate these automatically:
@With
@Value  // Makes class immutable with getters
public class User {
    UserId id;
    String name;
    EmailAddress email;
    boolean active;
}

Entity vs Value Object

Entity vs Value Object


Tips & Tricks

Immutability Tips