1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 19:30:16 +01:00
effigenix/bin/.claude/skills/ddd-model/languages/java/templates/ValueObject.java.md
2026-02-18 23:25:12 +01:00

20 KiB

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

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<MoneyError, Money> 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<MoneyError, Money> 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

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

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

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<PhoneNumberError, PhoneNumber> 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<PhoneNumberError, PhoneNumber> 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:

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

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

public class AccountRepository {
    private final Set<Money> 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<EmailAddress, String> emailToCustomer = new HashMap<>();

    public void registerEmail(EmailAddress email, String customerId) {
        emailToCustomer.put(email, customerId);  // Works with value objects
    }
}

Testing Value Objects

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<MoneyError, Money> 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<MoneyError, Money> 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<Money> 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