# Value Object Template (Java) Template for creating immutable value objects in Java 21+. ## Pattern A value object is: - Immutable (cannot change after creation) - Equality based on all fields (not identity) - Hashable (can be used in sets/maps) - Validates data in constructor - Can use Records (recommended) or Classes - Can use exceptions-based or Result-based validation ## Approach 1: Records with Exception-Based Validation Simple, compact, perfect for most value objects. ### Example 1: Money ```java package com.example.domain.shared; import com.example.shared.result.Result; import static com.example.shared.result.Result.success; import static com.example.shared.result.Result.failure; import java.math.BigDecimal; import java.math.RoundingMode; /** * Money value object - represents a monetary amount with currency. * * Immutable. Validates in compact constructor. * Uses records for automatic equals/hashCode/toString. * Can be used in sets and maps. * * All operations return new Money instances (immutability). */ public record Money( java.math.BigDecimal amount, String currency ) { /** * Compact constructor - executes BEFORE field assignment. * Perfect place for validation with exceptions. */ public Money { if (amount == null) { throw new IllegalArgumentException("Amount cannot be null"); } if (currency == null || currency.isBlank()) { throw new IllegalArgumentException("Currency cannot be blank"); } if (currency.length() != 3) { throw new IllegalArgumentException("Currency must be 3-letter ISO code"); } if (amount.signum() < 0) { throw new IllegalArgumentException("Amount cannot be negative"); } // Canonicalize to 2 decimal places amount = amount.setScale(2, RoundingMode.HALF_UP); } // ================== Factory Methods ================== /** * Create Money in US Dollars. * * @param cents amount in cents (1_00 = $1.00) */ public static Money usd(long cents) { return new Money( BigDecimal.valueOf(cents).setScale(2, RoundingMode.HALF_UP) .divide(BigDecimal.valueOf(100)), "USD" ); } /** * Create Money in Euros. */ public static Money eur(long cents) { return new Money( BigDecimal.valueOf(cents).setScale(2, RoundingMode.HALF_UP) .divide(BigDecimal.valueOf(100)), "EUR" ); } /** * Create zero Money for currency. */ public static Money zero(String currency) { return new Money(BigDecimal.ZERO, currency); } /** * Create Money with arbitrary amount (throws on error). * Use for tests only. */ public static Money mustOf(String amount, String currency) { try { return new Money(new BigDecimal(amount), currency); } catch (IllegalArgumentException | NumberFormatException e) { throw new AssertionError("Test money construction failed: " + e.getMessage()); } } // ================== Operations ================== /** * Add two amounts (must be same currency). * Returns Result for safe chaining. */ public Result add(Money other) { if (!currency.equals(other.currency)) { return failure( new CurrencyMismatchError(currency, other.currency) ); } return success( new Money(amount.add(other.amount), currency) ); } /** * Subtract two amounts (must be same currency). */ public Result subtract(Money other) { if (!currency.equals(other.currency)) { return failure( new CurrencyMismatchError(currency, other.currency) ); } return success( new Money(amount.subtract(other.amount), currency) ); } /** * Multiply by factor. */ public Money multiply(int factor) { if (factor < 0) { throw new IllegalArgumentException("Factor cannot be negative"); } return new Money( amount.multiply(BigDecimal.valueOf(factor)), currency ); } /** * Divide by divisor. */ public Money divide(int divisor) { if (divisor <= 0) { throw new IllegalArgumentException("Divisor must be positive"); } return new Money( amount.divide(BigDecimal.valueOf(divisor), 2, RoundingMode.HALF_UP), currency ); } /** * Negate amount (flip sign). */ public Money negate() { return new Money(amount.negate(), currency); } // ================== Comparisons ================== public boolean isZero() { return amount.signum() == 0; } public boolean isPositive() { return amount.signum() > 0; } public boolean isNegative() { return amount.signum() < 0; } public boolean isNegativeOrZero() { return amount.signum() <= 0; } public boolean greaterThan(Money other) { if (!currency.equals(other.currency)) { throw new IllegalArgumentException("Cannot compare different currencies"); } return amount.compareTo(other.amount) > 0; } public boolean lessThan(Money other) { if (!currency.equals(other.currency)) { throw new IllegalArgumentException("Cannot compare different currencies"); } return amount.compareTo(other.amount) < 0; } public boolean equalsAmount(Money other) { if (!currency.equals(other.currency)) { return false; } return amount.compareTo(other.amount) == 0; } // ================== Display ================== /** * Format for display (e.g., "USD 123.45"). */ public String formatted() { return String.format("%s %s", currency, amount); } /** * Record provides toString automatically. */ } /** * Money error type - sealed interface for type safety. */ public sealed interface MoneyError permits CurrencyMismatchError { String message(); } public record CurrencyMismatchError( String from, String to ) implements MoneyError { @Override public String message() { return String.format("Cannot operate on different currencies: %s vs %s", from, to); } } ``` ### Example 2: EmailAddress ```java package com.example.domain.shared; /** * EmailAddress value object. * * Validates email format on construction. * Equality based on email string. * Immutable and hashable. */ public record EmailAddress(String value) { public EmailAddress { if (value == null || value.isBlank()) { throw new IllegalArgumentException("Email cannot be empty"); } if (!isValidEmail(value)) { throw new IllegalArgumentException("Invalid email format: " + value); } // Normalize to lowercase value = value.toLowerCase().trim(); } public static EmailAddress of(String email) { return new EmailAddress(email); } /** * For tests - panics on invalid format. */ public static EmailAddress mustOf(String email) { try { return new EmailAddress(email); } catch (IllegalArgumentException e) { throw new AssertionError("Test email invalid: " + e.getMessage()); } } public String domain() { int at = value.indexOf('@'); return value.substring(at + 1); } public String localPart() { int at = value.indexOf('@'); return value.substring(0, at); } private static boolean isValidEmail(String email) { return email.matches("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); } @Override public String toString() { return value; } } ``` ### Example 3: DateRange ```java package com.example.domain.shared; import java.time.LocalDate; /** * DateRange value object - represents period between two dates. * * Validates that start <= end. * Equality based on both dates. * Immutable and hashable. * * Invariant: startDate must be <= endDate */ public record DateRange( LocalDate startDate, LocalDate endDate ) { public DateRange { if (startDate == null || endDate == null) { throw new IllegalArgumentException("Dates cannot be null"); } if (startDate.isAfter(endDate)) { throw new IllegalArgumentException( "Start date must be before end date" ); } } public static DateRange of(LocalDate start, LocalDate end) { return new DateRange(start, end); } public long days() { return java.time.temporal.ChronoUnit.DAYS.between(startDate, endDate); } public boolean contains(LocalDate date) { return !date.isBefore(startDate) && !date.isAfter(endDate); } public boolean overlaps(DateRange other) { return !endDate.isBefore(other.startDate) && !startDate.isAfter(other.endDate); } } ``` ## Approach 2: Classes with Result-Based Validation For complex validation that should return errors instead of throwing. ### Example: PhoneNumber with Result Validation ```java package com.example.domain.shared; import com.example.shared.result.Result; import static com.example.shared.result.Result.success; import static com.example.shared.result.Result.failure; import java.util.Objects; /** * PhoneNumber value object - complex validation with Result return. * * Immutable class (not record) because we want custom factory * that returns Result instead of throwing exceptions. * * Equality based on country code + number. */ public final class PhoneNumber { private final String countryCode; // +1, +44, +33, etc. private final String number; // Without country code /** * Private constructor - use factory method. */ private PhoneNumber(String countryCode, String number) { this.countryCode = countryCode; this.number = number; } /** * Factory with Result-based validation. * Returns error instead of throwing for graceful handling. */ public static Result of( String countryCode, String number ) { // Guard: Validate country code if (countryCode == null || countryCode.isBlank()) { return failure(new InvalidCountryCodeError("Country code required")); } if (!countryCode.startsWith("+")) { return failure( new InvalidCountryCodeError("Country code must start with +") ); } // Guard: Validate number if (number == null || number.isBlank()) { return failure(new InvalidPhoneNumberError("Phone number required")); } if (!number.matches("^\\d{6,15}$")) { return failure( new InvalidPhoneNumberError("Phone must be 6-15 digits") ); } return success(new PhoneNumber(countryCode, number)); } /** * Parse full phone (e.g., "+14155552671"). */ public static Result parse(String fullPhone) { if (fullPhone == null || !fullPhone.startsWith("+")) { return failure(new InvalidPhoneNumberError("Must start with +")); } // Find where digits start int digitStart = 1; // Skip the + while (digitStart < fullPhone.length() && !Character.isDigit(fullPhone.charAt(digitStart))) { digitStart++; } if (digitStart >= fullPhone.length()) { return failure(new InvalidPhoneNumberError("No digits found")); } String country = fullPhone.substring(0, digitStart); String number = fullPhone.substring(digitStart); return of(country, number); } /** * For tests - panics on error. */ public static PhoneNumber mustOf(String country, String number) { return of(country, number) .orElseThrow(e -> new AssertionError("Test phone invalid: " + e.message()) ); } // ================== Getters ================== public String countryCode() { return countryCode; } public String number() { return number; } public String formatted() { return countryCode + number; } // ================== Equality ================== @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof PhoneNumber that)) return false; return Objects.equals(countryCode, that.countryCode) && Objects.equals(number, that.number); } @Override public int hashCode() { return Objects.hash(countryCode, number); } @Override public String toString() { return formatted(); } } /** * PhoneNumber error type. */ public sealed interface PhoneNumberError permits InvalidCountryCodeError, InvalidPhoneNumberError { String message(); } public record InvalidCountryCodeError(String reason) implements PhoneNumberError { @Override public String message() { return "Invalid country code: " + reason; } } public record InvalidPhoneNumberError(String reason) implements PhoneNumberError { @Override public String message() { return "Invalid phone number: " + reason; } } ``` ## ID Value Objects Common pattern for all entity and aggregate IDs: ```java package com.example.domain.shared; import java.util.UUID; /** * AccountId value object - unique identifier for accounts. * * Using UUID internally but abstracting with value object * for type safety and potential ID scheme changes. */ public record AccountId(String value) { public AccountId { if (value == null || value.isBlank()) { throw new IllegalArgumentException("AccountId cannot be blank"); } } public static AccountId random() { return new AccountId(UUID.randomUUID().toString()); } public static AccountId of(String id) { return new AccountId(id); } @Override public String toString() { return value; } } /** * CustomerId - same pattern as AccountId. */ public record CustomerId(String value) { public CustomerId { if (value == null || value.isBlank()) { throw new IllegalArgumentException("CustomerId cannot be blank"); } } public static CustomerId random() { return new CustomerId(UUID.randomUUID().toString()); } @Override public String toString() { return value; } } /** * ProductId - same pattern. */ public record ProductId(String value) { public ProductId { if (value == null || value.isBlank()) { throw new IllegalArgumentException("ProductId cannot be blank"); } } public static ProductId of(String id) { return new ProductId(id); } @Override public String toString() { return value; } } ``` ## Enumerations as Value Objects ```java package com.example.domain.account; /** * AccountStatus - represents possible states. * Sealed interface with record implementations. */ public sealed interface AccountStatus permits ActiveStatus, FrozenStatus, ClosedStatus { String displayName(); } public record ActiveStatus() implements AccountStatus { @Override public String displayName() { return "Active"; } } public record FrozenStatus() implements AccountStatus { @Override public String displayName() { return "Frozen - debit blocked"; } } public record ClosedStatus() implements AccountStatus { @Override public String displayName() { return "Closed"; } } // Constants for convenience public class AccountStatuses { public static final AccountStatus ACTIVE = new ActiveStatus(); public static final AccountStatus FROZEN = new FrozenStatus(); public static final AccountStatus CLOSED = new ClosedStatus(); } ``` ## Using Value Objects in Collections ```java public class AccountRepository { private final Set validAmounts = Set.of( Money.usd(10_00), Money.usd(50_00), Money.usd(100_00) ); public boolean isValidAmount(Money amount) { return validAmounts.contains(amount); // Works because equals based on fields } // Maps keyed by value objects private final Map emailToCustomer = new HashMap<>(); public void registerEmail(EmailAddress email, String customerId) { emailToCustomer.put(email, customerId); // Works with value objects } } ``` ## Testing Value Objects ```java public class MoneyTest { @Test void shouldCreateMoney() { Money money = Money.usd(100_00); // $100.00 assertThat(money.amount()).isEqualTo(BigDecimal.valueOf(100.00)); } @Test void shouldRejectNegativeAmount() { assertThatThrownBy(() -> new Money(BigDecimal.valueOf(-10), "USD")) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("cannot be negative"); } @Test void shouldAddMoney() { Money m1 = Money.usd(100_00); Money m2 = Money.usd(50_00); var result = m1.add(m2); assertThat(result).satisfies(r -> { assertThat(r).isInstanceOf(Success.class); if (r instanceof Success s) { assertThat(s.value()).isEqualTo(Money.usd(150_00)); } }); } @Test void shouldRejectAddingDifferentCurrencies() { Money usd = Money.usd(100_00); Money eur = Money.eur(100_00); var result = usd.add(eur); assertThat(result).satisfies(r -> { assertThat(r).isInstanceOf(Failure.class); if (r instanceof Failure f) { assertThat(f.error()) .isInstanceOf(CurrencyMismatchError.class); } }); } @Test void shouldEqualByFieldValue() { Money m1 = Money.usd(100_00); Money m2 = Money.usd(100_00); Money m3 = Money.usd(50_00); assertThat(m1).isEqualTo(m2); assertThat(m1).isNotEqualTo(m3); } @Test void shouldBeHashable() { Money m1 = Money.usd(100_00); Money m2 = Money.usd(100_00); Set set = Set.of(m1, m2); // Both go in but set size = 1 assertThat(set).hasSize(1); } } ``` ## Key Features 1. **Immutable**: All fields final, no setters 2. **Record-First**: Use records for simple value objects 3. **Validation in Constructor**: Compact constructor or private constructor 4. **Equality by Value**: Override equals/hashCode based on all fields (or records do it) 5. **Hashable**: Can use in sets/maps (records handle this) 6. **Throwable or Result**: Choose exception-based or Result-based validation 7. **Operations Return New**: Never mutate, always return new instance 8. **Factory Methods**: Provide static constructors with validation 9. **Must-Variant**: Provide `mustXxx()` for tests (panics on error) 10. **Type Safe**: Wrap strings/numbers in typed value objects ## Best Practices - Use records unless you need custom field transformations - Validate in compact constructor or private constructor - Make records `final` implicitly (they are already) - Provide static factories with clear names (of, from, parse) - Use sealed interfaces for enumeration-like value objects - Make value objects usable in collections (implement hashCode/equals) - Document invariants that are enforced by validation - Never have mutable fields - Consider performance-sensitive scenarios (Money in financial systems) - Return Result for business rule violations, exceptions for programming errors