mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 13:59:36 +01:00
docs: and skills
This commit is contained in:
parent
e4f0665086
commit
ccd4ee534a
25 changed files with 10412 additions and 0 deletions
|
|
@ -0,0 +1,751 @@
|
|||
# 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<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
|
||||
|
||||
```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<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:
|
||||
|
||||
```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<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
|
||||
|
||||
```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<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
|
||||
Loading…
Add table
Add a link
Reference in a new issue