mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 20:59:56 +01:00
20 KiB
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
- Immutable: All fields final, no setters
- Record-First: Use records for simple value objects
- Validation in Constructor: Compact constructor or private constructor
- Equality by Value: Override equals/hashCode based on all fields (or records do it)
- Hashable: Can use in sets/maps (records handle this)
- Throwable or Result: Choose exception-based or Result-based validation
- Operations Return New: Never mutate, always return new instance
- Factory Methods: Provide static constructors with validation
- Must-Variant: Provide
mustXxx()for tests (panics on error) - 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
finalimplicitly (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