1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 16:09:35 +01:00

docs: and skills

This commit is contained in:
Sebastian Frick 2026-02-18 23:25:12 +01:00
parent e4f0665086
commit ccd4ee534a
25 changed files with 10412 additions and 0 deletions

View file

@ -0,0 +1,687 @@
# Aggregate Root Template (Java)
Template for creating aggregate roots in Java 21+ following DDD and Clean Architecture principles.
## Pattern
An aggregate root is:
- An entity with a public static factory method returning `Result<Error, Aggregate>`
- A mutable class with a private constructor
- The only entry point to modify its children
- Enforces all invariants through mutation methods
- All mutations return `Result` types instead of throwing exceptions
## Complete Example: Account Aggregate
```java
package com.example.domain.account;
import com.example.shared.result.Result;
import java.time.Instant;
import java.util.List;
import java.util.ArrayList;
import java.util.Collections;
import static com.example.shared.result.Result.success;
import static com.example.shared.result.Result.failure;
/**
* Account aggregate root.
*
* Represents a bank account with balance, status, and holders.
* Enforces all invariants around account operations.
*
* Invariant: Balance cannot be negative for standard accounts
* Invariant: Balance cannot exceed credit limit for credit accounts
* Invariant: Account must have at least one holder with OWNER role
* Invariant: Frozen account cannot process debit operations
* Invariant: Closed account cannot be modified
*/
public class Account {
private final AccountId id;
private Money balance;
private AccountStatus status;
private final AccountType accountType;
private Money creditLimit;
private final List<AccountHolder> holders;
private final Instant createdAt;
private Instant updatedAt;
private final List<DomainEvent> events;
/**
* Private constructor - only accessible via factory methods.
* Ensures validation always occurs before construction.
*/
private Account(
AccountId id,
Money initialBalance,
AccountType accountType,
Money creditLimit,
AccountStatus status,
List<AccountHolder> holders,
Instant createdAt
) {
this.id = id;
this.balance = initialBalance;
this.accountType = accountType;
this.creditLimit = creditLimit;
this.status = status;
this.holders = new ArrayList<>(holders);
this.createdAt = createdAt;
this.updatedAt = createdAt;
this.events = new ArrayList<>();
}
/**
* Factory method to create a new standard account.
*
* Validates:
* - Initial balance must be non-negative
* - Initial holder must have OWNER role
*
* @param id unique account identifier
* @param initialBalance starting balance (must be >= 0)
* @param owner initial owner holder
* @return success with created account, or failure with reason
*/
public static Result<AccountError, Account> create(
AccountId id,
Money initialBalance,
AccountHolder owner
) {
// Guard: Validate initial balance
if (initialBalance == null) {
return failure(new InvalidAccountDataError("Initial balance cannot be null"));
}
if (initialBalance.isNegative()) {
return failure(new InvalidAccountDataError("Initial balance cannot be negative"));
}
// Guard: Validate owner role
if (owner == null) {
return failure(new InvalidAccountDataError("Owner cannot be null"));
}
if (!owner.role().equals(AccountRole.OWNER)) {
return failure(new InvalidAccountDataError("Initial holder must have OWNER role"));
}
// Construct aggregate
Account account = new Account(
id,
initialBalance,
AccountType.STANDARD,
Money.zero("USD"), // Standard accounts have no credit
AccountStatus.ACTIVE,
List.of(owner),
Instant.now()
);
// Raise domain event
account.raise(new AccountCreatedEvent(id, initialBalance));
return success(account);
}
/**
* Factory method to create a new credit account with limit.
*
* @param id unique account identifier
* @param initialBalance starting balance
* @param owner initial owner holder
* @param creditLimit maximum credit allowed
* @return success with created account, or failure with reason
*/
public static Result<AccountError, Account> createCredit(
AccountId id,
Money initialBalance,
AccountHolder owner,
Money creditLimit
) {
// Guard: Validate credit limit
if (creditLimit == null || creditLimit.isNegative()) {
return failure(
new InvalidAccountDataError("Credit limit must be non-negative")
);
}
// Reuse standard account creation validation
var standardAccount = create(id, initialBalance, owner);
if (standardAccount instanceof Failure f) {
return (Result<AccountError, Account>) (Object) standardAccount;
}
// Construct credit account from standard
Account account = ((Success<AccountError, Account>) (Object) standardAccount).value();
account.accountType = AccountType.CREDIT;
account.creditLimit = creditLimit;
return success(account);
}
/**
* Reconstruct aggregate from database (internal use only).
* Skips validation since data is from trusted source.
*/
static Account reconstruct(
AccountId id,
Money balance,
AccountType accountType,
Money creditLimit,
AccountStatus status,
List<AccountHolder> holders,
Instant createdAt,
Instant updatedAt
) {
Account account = new Account(
id,
balance,
accountType,
creditLimit,
status,
holders,
createdAt
);
account.updatedAt = updatedAt;
return account;
}
/**
* Deposit money into account.
*
* Invariant: Cannot deposit to closed account
* Invariant: Amount must be positive
*/
public Result<AccountError, Void> deposit(Money amount) {
// Guard: Check status
if (status == AccountStatus.CLOSED) {
return failure(new AccountClosedError(id));
}
// Guard: Check amount
if (amount == null) {
return failure(new InvalidOperationError("Amount cannot be null"));
}
if (amount.isNegativeOrZero()) {
return failure(new InvalidAmountError(amount, "Amount must be positive"));
}
// Execute state change
try {
this.balance = balance.add(amount);
} catch (Exception e) {
return failure(new InvalidOperationError("Currency mismatch during deposit"));
}
this.updatedAt = Instant.now();
raise(new DepositedEvent(id, amount, balance));
return success(null);
}
/**
* Withdraw money from account.
*
* Invariant: Cannot withdraw from closed account
* Invariant: Cannot withdraw from frozen account
* Invariant: Balance must stay >= 0 for standard accounts
* Invariant: Balance must stay >= -creditLimit for credit accounts
*/
public Result<AccountError, Void> withdraw(Money amount) {
// Guard: Check status
if (status == AccountStatus.CLOSED) {
return failure(new AccountClosedError(id));
}
if (status == AccountStatus.FROZEN) {
return failure(new AccountFrozenError(id));
}
// Guard: Check amount
if (amount == null) {
return failure(new InvalidOperationError("Amount cannot be null"));
}
if (amount.isNegativeOrZero()) {
return failure(new InvalidAmountError(amount, "Amount must be positive"));
}
// Guard: Check balance constraints
try {
Money newBalance = balance.subtract(amount);
if (accountType == AccountType.STANDARD && newBalance.isNegative()) {
return failure(
new InsufficientFundsError(amount, balance)
);
}
if (accountType == AccountType.CREDIT) {
if (newBalance.negate().greaterThan(creditLimit)) {
return failure(new CreditLimitExceededError(creditLimit, newBalance));
}
}
// Execute state change
this.balance = newBalance;
this.updatedAt = Instant.now();
raise(new WithdrawnEvent(id, amount, balance));
return success(null);
} catch (Exception e) {
return failure(new InvalidOperationError("Currency mismatch during withdrawal"));
}
}
/**
* Freeze account (blocks debit operations).
*
* Invariant: Cannot freeze closed account
*/
public Result<AccountError, Void> freeze() {
if (status == AccountStatus.CLOSED) {
return failure(new AccountClosedError(id));
}
this.status = AccountStatus.FROZEN;
this.updatedAt = Instant.now();
raise(new AccountFrozenEvent(id));
return success(null);
}
/**
* Unfreeze account (allows debit operations again).
*
* Invariant: Cannot unfreeze closed account
*/
public Result<AccountError, Void> unfreeze() {
if (status == AccountStatus.CLOSED) {
return failure(new AccountClosedError(id));
}
this.status = AccountStatus.ACTIVE;
this.updatedAt = Instant.now();
raise(new AccountUnfrozenEvent(id));
return success(null);
}
/**
* Close account (prevents all future modifications).
*/
public Result<AccountError, Void> close() {
if (status == AccountStatus.CLOSED) {
return failure(new AlreadyClosedError(id));
}
this.status = AccountStatus.CLOSED;
this.updatedAt = Instant.now();
raise(new AccountClosedEvent(id));
return success(null);
}
/**
* Add a new holder to account.
*
* Invariant: Cannot add to closed account
*/
public Result<AccountError, Void> addHolder(AccountHolder holder) {
if (status == AccountStatus.CLOSED) {
return failure(new AccountClosedError(id));
}
if (holder == null) {
return failure(new InvalidAccountDataError("Holder cannot be null"));
}
// Check if holder already exists
if (holders.stream().anyMatch(h -> h.id().equals(holder.id()))) {
return failure(new DuplicateHolderError(holder.id()));
}
holders.add(holder);
this.updatedAt = Instant.now();
raise(new HolderAddedEvent(id, holder.id()));
return success(null);
}
/**
* Remove a holder from account.
*
* Invariant: Cannot remove last OWNER
* Invariant: Cannot modify closed account
*/
public Result<AccountError, Void> removeHolder(AccountHolderId holderId) {
if (status == AccountStatus.CLOSED) {
return failure(new AccountClosedError(id));
}
// Find the holder
AccountHolder holderToRemove = holders.stream()
.filter(h -> h.id().equals(holderId))
.findFirst()
.orElse(null);
if (holderToRemove == null) {
return failure(new HolderNotFoundError(holderId));
}
// Guard: Check invariant - must have at least one OWNER
long ownerCount = holders.stream()
.filter(h -> h.role() == AccountRole.OWNER && !h.id().equals(holderId))
.count();
if (ownerCount == 0) {
return failure(new CannotRemoveLastOwnerError(id));
}
// Remove holder
holders.remove(holderToRemove);
this.updatedAt = Instant.now();
raise(new HolderRemovedEvent(id, holderId));
return success(null);
}
// ================== Getters (property-style accessors) ==================
public AccountId id() {
return id;
}
public Money balance() {
return balance;
}
public AccountStatus status() {
return status;
}
public AccountType accountType() {
return accountType;
}
public Money creditLimit() {
return creditLimit;
}
public List<AccountHolder> holders() {
return Collections.unmodifiableList(holders);
}
public Instant createdAt() {
return createdAt;
}
public Instant updatedAt() {
return updatedAt;
}
/**
* Get and clear pending domain events.
* Call after persisting to ensure events are published only once.
*/
public List<DomainEvent> events() {
List<DomainEvent> result = new ArrayList<>(events);
events.clear();
return result;
}
// ================== Private Helper Methods ==================
private void raise(DomainEvent event) {
events.add(event);
}
// ================== Equality & Hash Code ==================
/**
* Equality based on aggregate ID (entity identity).
*/
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Account account)) return false;
return id.equals(account.id);
}
@Override
public int hashCode() {
return id.hashCode();
}
@Override
public String toString() {
return "Account{" +
"id=" + id +
", balance=" + balance +
", status=" + status +
", holders=" + holders.size() +
'}';
}
}
```
## Domain Events
```java
package com.example.domain.account;
import java.time.Instant;
/**
* Base class for domain events.
* Raised when aggregate state changes.
*/
public sealed interface DomainEvent permits
AccountCreatedEvent,
DepositedEvent,
WithdrawnEvent,
AccountFrozenEvent,
AccountUnfrozenEvent,
AccountClosedEvent,
HolderAddedEvent,
HolderRemovedEvent {
AccountId aggregateId();
Instant occurredAt();
}
public record AccountCreatedEvent(
AccountId id,
Money initialBalance
) implements DomainEvent {
@Override
public AccountId aggregateId() { return id; }
@Override
public Instant occurredAt() { return Instant.now(); }
}
public record DepositedEvent(
AccountId id,
Money amount,
Money newBalance
) implements DomainEvent {
@Override
public AccountId aggregateId() { return id; }
@Override
public Instant occurredAt() { return Instant.now(); }
}
public record WithdrawnEvent(
AccountId id,
Money amount,
Money newBalance
) implements DomainEvent {
@Override
public AccountId aggregateId() { return id; }
@Override
public Instant occurredAt() { return Instant.now(); }
}
// Additional events...
```
## Domain Errors (Sealed Interface)
```java
package com.example.domain.account;
/**
* Account domain errors - business rule violations.
* Use sealed interface to restrict implementations and enable pattern matching.
*/
public sealed interface AccountError permits
InvalidAccountDataError,
AccountClosedError,
AccountFrozenError,
InvalidAmountError,
InvalidOperationError,
InsufficientFundsError,
CreditLimitExceededError,
CannotRemoveLastOwnerError,
HolderNotFoundError,
DuplicateHolderError,
AlreadyClosedError {
String message();
}
public record InvalidAccountDataError(String reason) implements AccountError {
@Override
public String message() {
return "Invalid account data: " + reason;
}
}
public record AccountClosedError(AccountId id) implements AccountError {
@Override
public String message() {
return "Account is closed: " + id;
}
}
public record AccountFrozenError(AccountId id) implements AccountError {
@Override
public String message() {
return "Account is frozen: " + id;
}
}
public record InvalidAmountError(Money amount, String reason) implements AccountError {
@Override
public String message() {
return String.format("Invalid amount %s: %s", amount, reason);
}
}
public record InsufficientFundsError(Money required, Money available) implements AccountError {
@Override
public String message() {
return String.format(
"Insufficient funds: required %s, available %s",
required, available
);
}
}
public record CreditLimitExceededError(Money limit, Money newBalance) implements AccountError {
@Override
public String message() {
return String.format(
"Credit limit exceeded: limit %s, would be %s",
limit, newBalance
);
}
}
public record CannotRemoveLastOwnerError(AccountId id) implements AccountError {
@Override
public String message() {
return "Cannot remove last owner from account: " + id;
}
}
// Additional error types...
```
## Key Features
1. **Private Constructor**: Only accessible via factory methods
2. **Public Static Factory**: Returns `Result<Error, Aggregate>` for validation
3. **Invariant Documentation**: All invariants documented in comments
4. **Guard Clauses**: Check invariants before state changes
5. **Result Returns**: All mutation methods return `Result` instead of throwing
6. **Domain Events**: Raise events when state changes for eventually consistent communication
7. **ID-Based Equality**: Aggregates equal if their IDs are equal
8. **Immutable Collections**: Return defensive copies of internal collections
9. **Temporal Tracking**: Track creation and update times
10. **Sealed Errors**: Use sealed interfaces for type-safe error handling
## Testing Pattern
```java
public class AccountTest {
@Test
void shouldCreateAccountSuccessfully() {
var result = Account.create(
new AccountId("acc-123"),
Money.usd(100_00), // 100 USD
new AccountHolder(...)
);
assertThat(result).satisfies(r -> {
assertThat(r).isInstanceOf(Success.class);
if (r instanceof Success<AccountError, Account> s) {
assertThat(s.value().balance()).isEqualTo(Money.usd(100_00));
}
});
}
@Test
void shouldRejectNegativeInitialBalance() {
var result = Account.create(
new AccountId("acc-123"),
Money.usd(-100_00), // Negative!
new AccountHolder(...)
);
assertThat(result).satisfies(r -> {
assertThat(r).isInstanceOf(Failure.class);
if (r instanceof Failure<AccountError, Account> f) {
assertThat(f.error())
.isInstanceOf(InvalidAccountDataError.class);
}
});
}
@Test
void shouldPreventWithdrawalFromClosedAccount() {
var account = Account.create(...)
.orElseThrow(e -> new AssertionError("Setup failed"));
account.close(); // Close first
var result = account.withdraw(Money.usd(10_00));
assertThat(result).satisfies(r -> {
assertThat(r).isInstanceOf(Failure.class);
if (r instanceof Failure<AccountError, Void> f) {
assertThat(f.error())
.isInstanceOf(AccountClosedError.class);
}
});
}
}
```
## Notes
- Use `Money.zero()`, `Money.usd()`, `Money.eur()` factory methods for convenience
- Raise domain events **before** returning success so they're captured
- Document all invariants in the class Javadoc
- Use `static` reconstruct method for database hydration (bypasses validation)
- Always use `instanceof Success` and `instanceof Failure` for pattern matching
- Defensive copy collections in getters using `Collections.unmodifiableList()`

View file

@ -0,0 +1,600 @@
# Entity Template (Java)
Template for creating child entities within aggregates in Java 21+.
## Pattern
An entity is:
- A mutable object that exists only within an aggregate boundary
- Package-private constructor (not accessible from other packages)
- Static package-private factory method for validation
- Equality based on its ID (entity identity)
- Only accessible through aggregate root methods
- Cannot be directly instantiated outside its aggregate
## Example 1: AccountHolder Entity
```java
package com.example.domain.account;
import java.util.Objects;
/**
* AccountHolder entity - a child of Account aggregate.
*
* Represents a person with access rights to an account.
* Only Account aggregate can create and modify holders.
*
* Package-private - not accessible outside this package.
* Equality based on holder ID.
*/
public class AccountHolder {
private final AccountHolderId id;
private final String name;
private final AccountRole role;
private final String emailAddress;
/**
* Package-private constructor - only Account can use.
* Validation done in static factory.
*/
AccountHolder(
AccountHolderId id,
String name,
AccountRole role,
String emailAddress
) {
this.id = id;
this.name = name;
this.role = role;
this.emailAddress = emailAddress;
}
/**
* Static package-private factory - validates data.
* Used by Account aggregate when creating/adding holders.
*
* @param id unique holder identifier
* @param name holder's full name
* @param role access role (OWNER, OPERATOR, VIEWER)
* @param emailAddress contact email
* @return holder if valid, exception if invalid
*/
static AccountHolder create(
AccountHolderId id,
String name,
AccountRole role,
String emailAddress
) {
// Guard: Validate ID
if (id == null) {
throw new IllegalArgumentException("Holder ID cannot be null");
}
// Guard: Validate name
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Holder name cannot be empty");
}
if (name.length() > 100) {
throw new IllegalArgumentException("Holder name too long (max 100 chars)");
}
// Guard: Validate role
if (role == null) {
throw new IllegalArgumentException("Role cannot be null");
}
// Guard: Validate email
if (emailAddress == null || emailAddress.isBlank()) {
throw new IllegalArgumentException("Email cannot be empty");
}
if (!isValidEmail(emailAddress)) {
throw new IllegalArgumentException("Invalid email format: " + emailAddress);
}
return new AccountHolder(id, name, role, emailAddress);
}
// ================== Getters ==================
public AccountHolderId id() {
return id;
}
public String name() {
return name;
}
public AccountRole role() {
return role;
}
public String emailAddress() {
return emailAddress;
}
// ================== Package-Private Mutations ==================
/**
* Package-private - only Account aggregate can change role.
* Used when promoting/demoting holders.
*/
void changeRole(AccountRole newRole) {
if (newRole == null) {
throw new IllegalArgumentException("Role cannot be null");
}
// In reality, this would be: this.role = newRole;
// But role should be immutable, so prefer creating new instance
// or use a separate RoleChange method that returns Result
}
// ================== Equality & Hash Code ==================
/**
* Equality based on holder ID (entity identity).
* Two holders are equal if they have the same ID,
* even if other attributes differ.
*/
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof AccountHolder that)) return false;
return id.equals(that.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
@Override
public String toString() {
return "AccountHolder{" +
"id=" + id +
", name='" + name + '\'' +
", role=" + role +
'}';
}
// ================== Private Helper Methods ==================
private static boolean isValidEmail(String email) {
// Simple email validation
return email.matches("^[A-Za-z0-9+_.-]+@(.+)$");
}
}
```
## Example 2: OrderLine Entity
```java
package com.example.domain.order;
import java.util.Objects;
/**
* OrderLine entity - a child of Order aggregate.
*
* Represents one line item in an order.
* Multiple OrderLines compose an Order.
*
* Package-private - only Order aggregate can create/access.
* Equality based on line item ID.
*
* Invariant: Quantity must be positive
* Invariant: Unit price must be non-negative
*/
public class OrderLine {
private final OrderLineId id;
private final ProductId productId;
private final String productName;
private final Money unitPrice;
private int quantity;
/**
* Package-private constructor.
* Used by OrderLine.create() factory and Order.reconstruct().
*/
OrderLine(
OrderLineId id,
ProductId productId,
String productName,
Money unitPrice,
int quantity
) {
this.id = id;
this.productId = productId;
this.productName = productName;
this.unitPrice = unitPrice;
this.quantity = quantity;
}
/**
* Static package-private factory with validation.
* Called by Order when adding line items.
*/
static OrderLine create(
OrderLineId id,
ProductId productId,
String productName,
Money unitPrice,
int quantity
) {
// Guard: Validate ID
if (id == null || productId == null) {
throw new IllegalArgumentException("IDs cannot be null");
}
// Guard: Validate name
if (productName == null || productName.isBlank()) {
throw new IllegalArgumentException("Product name cannot be empty");
}
// Guard: Validate unit price
if (unitPrice == null || unitPrice.isNegative()) {
throw new IllegalArgumentException("Unit price cannot be negative");
}
// Invariant: Quantity must be positive
if (quantity <= 0) {
throw new IllegalArgumentException("Quantity must be positive");
}
return new OrderLine(id, productId, productName, unitPrice, quantity);
}
// ================== Getters ==================
public OrderLineId id() {
return id;
}
public ProductId productId() {
return productId;
}
public String productName() {
return productName;
}
public Money unitPrice() {
return unitPrice;
}
public int quantity() {
return quantity;
}
/**
* Calculate total for this line (unitPrice × quantity).
*/
public Money total() {
return unitPrice.multiply(quantity);
}
// ================== Package-Private Mutations ==================
/**
* Package-private - only Order aggregate can call.
* Updates quantity after validation.
*
* Invariant: New quantity must be positive
*/
void updateQuantity(int newQuantity) {
if (newQuantity <= 0) {
throw new IllegalArgumentException("Quantity must be positive");
}
this.quantity = newQuantity;
}
/**
* Package-private - only Order can remove entire line.
*/
void remove() {
// Mark as removed or actually remove from parent
// Implementation depends on how Order tracks removals
}
// ================== Equality & Hash Code ==================
/**
* Equality based on OrderLineId (entity identity).
*/
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof OrderLine that)) return false;
return id.equals(that.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
@Override
public String toString() {
return "OrderLine{" +
"id=" + id +
", productName='" + productName + '\'' +
", quantity=" + quantity +
", unitPrice=" + unitPrice +
'}';
}
}
```
## Using Entities in Aggregates
```java
package com.example.domain.order;
import com.example.shared.result.Result;
import static com.example.shared.result.Result.success;
import static com.example.shared.result.Result.failure;
public class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<OrderLine> lineItems;
private OrderStatus status;
// ... constructor, factory, etc ...
/**
* Add a line item to the order.
*
* Invariant: Cannot modify PLACED orders
* Invariant: Line cannot be duplicate product (or can it?)
*/
public Result<OrderError, Void> addLineItem(
OrderLineId lineId,
ProductId productId,
String productName,
Money unitPrice,
int quantity
) {
// Guard: Check status
if (status != OrderStatus.DRAFT) {
return failure(
new CannotModifyOrderError(id, "Only DRAFT orders can be modified")
);
}
// Guard: Validate line - throws exceptions, caller wraps if needed
try {
OrderLine line = OrderLine.create(
lineId,
productId,
productName,
unitPrice,
quantity
);
// Check for duplicate product
if (lineItems.stream()
.anyMatch(l -> l.productId().equals(productId))) {
return failure(
new DuplicateProductError(productId)
);
}
lineItems.add(line);
return success(null);
} catch (IllegalArgumentException e) {
return failure(
new InvalidLineItemError(e.getMessage())
);
}
}
/**
* Update quantity of a line item.
*/
public Result<OrderError, Void> updateLineQuantity(
OrderLineId lineId,
int newQuantity
) {
if (status != OrderStatus.DRAFT) {
return failure(
new CannotModifyOrderError(id, "Only DRAFT orders can be modified")
);
}
OrderLine line = lineItems.stream()
.filter(l -> l.id().equals(lineId))
.findFirst()
.orElse(null);
if (line == null) {
return failure(new LineNotFoundError(lineId));
}
try {
line.updateQuantity(newQuantity);
return success(null);
} catch (IllegalArgumentException e) {
return failure(
new InvalidQuantityError(newQuantity, e.getMessage())
);
}
}
/**
* Remove a line item.
*/
public Result<OrderError, Void> removeLineItem(OrderLineId lineId) {
if (status != OrderStatus.DRAFT) {
return failure(
new CannotModifyOrderError(id, "Only DRAFT orders can be modified")
);
}
boolean removed = lineItems.removeIf(l -> l.id().equals(lineId));
if (!removed) {
return failure(new LineNotFoundError(lineId));
}
return success(null);
}
/**
* Get immutable view of line items.
*/
public List<OrderLine> lineItems() {
return Collections.unmodifiableList(lineItems);
}
/**
* Calculate order total.
*/
public Money total() {
return lineItems.stream()
.map(OrderLine::total)
.reduce(Money.zero("USD"), Money::add);
}
}
```
## Value Object IDs for Entities
```java
package com.example.domain.account;
import java.util.Objects;
import java.util.UUID;
/**
* Value object for AccountHolder identity.
* Every entity needs a unique ID represented as a value object.
*/
public final record AccountHolderId(String value) {
public AccountHolderId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("AccountHolderId cannot be blank");
}
}
public static AccountHolderId random() {
return new AccountHolderId(UUID.randomUUID().toString());
}
@Override
public String toString() {
return value;
}
}
public final record OrderLineId(String value) {
public OrderLineId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("OrderLineId cannot be blank");
}
}
public static OrderLineId random() {
return new OrderLineId(UUID.randomUUID().toString());
}
}
```
## Package-Private Visibility Example
```java
// In com.example.domain.order package
// ✅ CORRECT: Can access OrderLine from Order in same package
public class Order {
public void addLineItem(OrderLine line) { // Package-private constructor
lineItems.add(line); // OK
}
}
// ❌ WRONG: Cannot access OrderLine from outside package
// In com.example.application.order package
public class CreateOrderUseCase {
public void execute(OrderLineData lineData) {
// OrderLine line = new OrderLine(...); // Compile error!
// Must go through Order aggregate
order.addLineItem(...); // ✅ Correct
}
}
```
## Testing Entities
```java
public class OrderLineTest {
@Test
void shouldCreateLineWithValidData() {
OrderLine line = OrderLine.create(
OrderLineId.random(),
new ProductId("prod-123"),
"Widget",
Money.usd(10_00), // $10
5
);
assertThat(line.quantity()).isEqualTo(5);
assertThat(line.total()).isEqualTo(Money.usd(50_00)); // $50
}
@Test
void shouldRejectZeroQuantity() {
assertThatThrownBy(() ->
OrderLine.create(
OrderLineId.random(),
new ProductId("prod-123"),
"Widget",
Money.usd(10_00),
0 // Invalid
)
).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Quantity must be positive");
}
@Test
void shouldEquateByIdOnly() {
OrderLineId id = OrderLineId.random();
OrderLine line1 = OrderLine.create(id, productId, "A", price, 5);
OrderLine line2 = OrderLine.create(id, productId, "B", price, 10);
// Same ID = equal (other fields don't matter)
assertThat(line1).isEqualTo(line2);
}
@Test
void shouldUpdateQuantity() {
OrderLine line = OrderLine.create(...);
line.updateQuantity(10);
assertThat(line.quantity()).isEqualTo(10);
}
}
```
## Key Features
1. **Package-Private Constructor**: Only aggregate can create
2. **Static Factory Method**: Validates before construction, can throw exceptions
3. **ID-Based Equality**: Two entities equal if their IDs are equal
4. **Package-Private Mutations**: Only aggregate can modify
5. **No Getters for Collections**: Return defensive copies or immutable views
6. **Invariant Enforcement**: Validate in factory and mutation methods
7. **Immutable Fields**: All fields effectively final (or truly final)
8. **Aggregate Access Only**: Never instantiate outside aggregate package
9. **Domain-Specific Types**: Use value objects for IDs and important concepts
10. **Clear Ownership**: Always document which aggregate owns this entity
## Best Practices
- Mark class `public` but constructor `package-private` (no explicit modifier)
- Use `static` factory with same visibility (package-private) for validation
- Throw `IllegalArgumentException` in entity factories (aggregates wrap in Result)
- Override `equals()` and `hashCode()` based on entity ID only
- Never expose mutable collections from entities
- Document in Javadoc which aggregate owns this entity
- Use `final` on value fields when possible
- Keep entities focused on a single responsibility
- Don't create entities with complex validation - that's the aggregate's job

View file

@ -0,0 +1,721 @@
# Repository Template (Java)
Template for creating repositories in Java 21+ following DDD and Clean Architecture.
## Pattern
A repository:
- Is an interface in the domain layer (no implementation details)
- Provides methods to persist and retrieve aggregates
- All methods return `Result` types for safety
- Methods are named after domain concepts (not CRUD)
- Implementation lives in infrastructure layer
- Acts as a collection-like interface for aggregates
## Domain Layer: Interface
```java
package com.example.domain.account;
import com.example.shared.result.Result;
import java.util.List;
/**
* AccountRepository defines persistence contract for Account aggregates.
*
* Lives in domain layer - no infrastructure dependencies.
* Implementation in infrastructure layer.
*
* All methods return Result<RepositoryError, T> for safe error handling.
* Domain layer never has hidden failures - all errors explicit in type.
*/
public interface AccountRepository {
/**
* Save an account (create or update).
*
* @param account the aggregate to persist
* @return success if saved, failure with reason
*/
Result<RepositoryError, Void> save(Account account);
/**
* Retrieve account by ID.
*
* @param id the account identifier
* @return success with account if found, failure if not found
*/
Result<RepositoryError, Account> findById(AccountId id);
/**
* Retrieve all accounts for a holder.
*
* @param userId the user ID to search for
* @return success with list (possibly empty), failure if query fails
*/
Result<RepositoryError, List<Account>> findByHolderUserId(UserId userId);
/**
* Check if account exists.
*
* @param id the account identifier
* @return success with boolean, failure if query fails
*/
Result<RepositoryError, Boolean> exists(AccountId id);
/**
* Delete account (soft or hard).
*
* @param id the account to delete
* @return success if deleted, failure otherwise
*/
Result<RepositoryError, Void> delete(AccountId id);
}
/**
* Repository error type - infrastructure failures.
* Sealed interface for type-safe error handling.
*/
public sealed interface RepositoryError permits
NotFoundError,
ConflictError,
ConnectionError,
SerializationError,
UnexpectedError {
String message();
}
public record NotFoundError(
String entityType,
String entityId
) implements RepositoryError {
@Override
public String message() {
return String.format("%s not found: %s", entityType, entityId);
}
}
public record ConflictError(
String reason,
String entityId
) implements RepositoryError {
@Override
public String message() {
return String.format("Conflict: %s (ID: %s)", reason, entityId);
}
}
public record ConnectionError(
String reason,
Throwable cause
) implements RepositoryError {
@Override
public String message() {
return "Database connection failed: " + reason;
}
}
public record SerializationError(
String reason,
Throwable cause
) implements RepositoryError {
@Override
public String message() {
return "Serialization error: " + reason;
}
}
public record UnexpectedError(
String reason,
Throwable cause
) implements RepositoryError {
@Override
public String message() {
return "Unexpected error: " + reason;
}
}
```
## Infrastructure Layer: JDBC Implementation
```java
package com.example.infrastructure.persistence.account;
import com.example.domain.account.*;
import com.example.shared.result.Result;
import static com.example.shared.result.Result.success;
import static com.example.shared.result.Result.failure;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.*;
import java.util.*;
import javax.sql.DataSource;
/**
* JdbcAccountRepository - JDBC implementation of AccountRepository.
*
* Lives in infrastructure layer - handles all database details.
* Translates between domain objects and relational schema.
*
* Exception handling: catches SQL exceptions, transforms to domain errors.
* Logging: logs technical errors, not business rule violations.
*/
public class JdbcAccountRepository implements AccountRepository {
private static final Logger logger = LoggerFactory.getLogger(JdbcAccountRepository.class);
private final DataSource dataSource;
private final AccountRowMapper rowMapper;
public JdbcAccountRepository(DataSource dataSource, AccountRowMapper rowMapper) {
this.dataSource = Objects.requireNonNull(dataSource);
this.rowMapper = Objects.requireNonNull(rowMapper);
}
/**
* Save account to database (insert or update).
*/
@Override
public Result<RepositoryError, Void> save(Account account) {
String sql = "INSERT INTO accounts (id, balance, status, account_type, " +
"credit_limit, created_at, updated_at) " +
"VALUES (?, ?, ?, ?, ?, ?, ?) " +
"ON CONFLICT(id) DO UPDATE SET " +
"balance = EXCLUDED.balance, status = EXCLUDED.status, " +
"updated_at = EXCLUDED.updated_at";
Connection conn = null;
try {
conn = dataSource.getConnection();
// Start transaction
conn.setAutoCommit(false);
try (var stmt = conn.prepareStatement(sql)) {
stmt.setString(1, account.id().value());
stmt.setBigDecimal(2, account.balance().amount());
stmt.setString(3, account.status().toString());
stmt.setString(4, account.accountType().toString());
stmt.setBigDecimal(5, account.creditLimit().amount());
stmt.setTimestamp(6, Timestamp.from(account.createdAt()));
stmt.setTimestamp(7, Timestamp.from(account.updatedAt()));
stmt.executeUpdate();
}
// Persist holders
saveholders(conn, account.id(), account.holders());
// Commit transaction
conn.commit();
return success(null);
} catch (SQLException e) {
if (conn != null) {
try { conn.rollback(); } catch (SQLException rollbackEx) {
logger.warn("Rollback failed", rollbackEx);
}
}
logger.error("SQL error saving account " + account.id().value(), e);
return failure(
new ConnectionError("Failed to save account: " + e.getMessage(), e)
);
} finally {
if (conn != null) {
try { conn.close(); } catch (SQLException e) {
logger.warn("Error closing connection", e);
}
}
}
}
/**
* Find account by ID.
*/
@Override
public Result<RepositoryError, Account> findById(AccountId id) {
String sql = "SELECT * FROM accounts WHERE id = ?";
Connection conn = null;
try {
conn = dataSource.getConnection();
try (var stmt = conn.prepareStatement(sql)) {
stmt.setString(1, id.value());
var rs = stmt.executeQuery();
if (!rs.next()) {
return failure(new NotFoundError("Account", id.value()));
}
Account account = rowMapper.mapRow(rs);
// Load holders
List<AccountHolder> holders = loadHolders(conn, id);
account = Account.reconstruct(
account.id(),
account.balance(),
account.accountType(),
account.creditLimit(),
account.status(),
holders,
account.createdAt(),
account.updatedAt()
);
return success(account);
}
} catch (SQLException e) {
logger.error("SQL error finding account " + id.value(), e);
return failure(
new ConnectionError("Failed to find account: " + e.getMessage(), e)
);
} finally {
if (conn != null) {
try { conn.close(); } catch (SQLException e) {
logger.warn("Error closing connection", e);
}
}
}
}
/**
* Find all accounts for a user.
*/
@Override
public Result<RepositoryError, List<Account>> findByHolderUserId(UserId userId) {
String sql = "SELECT DISTINCT a.* FROM accounts a " +
"JOIN account_holders h ON a.id = h.account_id " +
"WHERE h.user_id = ? " +
"ORDER BY a.created_at DESC";
Connection conn = null;
try {
conn = dataSource.getConnection();
try (var stmt = conn.prepareStatement(sql)) {
stmt.setString(1, userId.value());
var rs = stmt.executeQuery();
List<Account> accounts = new ArrayList<>();
while (rs.next()) {
Account account = rowMapper.mapRow(rs);
// Load holders
List<AccountHolder> holders = loadHolders(conn, account.id());
account = Account.reconstruct(
account.id(),
account.balance(),
account.accountType(),
account.creditLimit(),
account.status(),
holders,
account.createdAt(),
account.updatedAt()
);
accounts.add(account);
}
return success(accounts);
}
} catch (SQLException e) {
logger.error("SQL error finding accounts for user " + userId.value(), e);
return failure(
new ConnectionError("Failed to find accounts: " + e.getMessage(), e)
);
} finally {
if (conn != null) {
try { conn.close(); } catch (SQLException e) {
logger.warn("Error closing connection", e);
}
}
}
}
/**
* Check if account exists.
*/
@Override
public Result<RepositoryError, Boolean> exists(AccountId id) {
String sql = "SELECT 1 FROM accounts WHERE id = ?";
Connection conn = null;
try {
conn = dataSource.getConnection();
try (var stmt = conn.prepareStatement(sql)) {
stmt.setString(1, id.value());
var rs = stmt.executeQuery();
return success(rs.next());
}
} catch (SQLException e) {
logger.error("SQL error checking account existence for " + id.value(), e);
return failure(
new ConnectionError("Failed to check account: " + e.getMessage(), e)
);
} finally {
if (conn != null) {
try { conn.close(); } catch (SQLException e) {
logger.warn("Error closing connection", e);
}
}
}
}
/**
* Delete account.
*/
@Override
public Result<RepositoryError, Void> delete(AccountId id) {
String sql = "DELETE FROM accounts WHERE id = ?";
Connection conn = null;
try {
conn = dataSource.getConnection();
conn.setAutoCommit(false);
try (var stmt = conn.prepareStatement(sql)) {
stmt.setString(1, id.value());
int deleted = stmt.executeUpdate();
if (deleted == 0) {
conn.rollback();
return failure(new NotFoundError("Account", id.value()));
}
// Delete holders too
deleteHolders(conn, id);
conn.commit();
return success(null);
} catch (SQLException e) {
conn.rollback();
throw e;
}
} catch (SQLException e) {
logger.error("SQL error deleting account " + id.value(), e);
return failure(
new ConnectionError("Failed to delete account: " + e.getMessage(), e)
);
} finally {
if (conn != null) {
try { conn.close(); } catch (SQLException e) {
logger.warn("Error closing connection", e);
}
}
}
}
// ================== Private Helpers ==================
private void saveholders(Connection conn, AccountId accountId, List<AccountHolder> holders)
throws SQLException {
String deleteOldSql = "DELETE FROM account_holders WHERE account_id = ?";
try (var stmt = conn.prepareStatement(deleteOldSql)) {
stmt.setString(1, accountId.value());
stmt.executeUpdate();
}
String insertSql = "INSERT INTO account_holders (account_id, holder_id, name, role, email) " +
"VALUES (?, ?, ?, ?, ?)";
try (var stmt = conn.prepareStatement(insertSql)) {
for (AccountHolder holder : holders) {
stmt.setString(1, accountId.value());
stmt.setString(2, holder.id().value());
stmt.setString(3, holder.name());
stmt.setString(4, holder.role().toString());
stmt.setString(5, holder.emailAddress());
stmt.executeUpdate();
}
}
}
private List<AccountHolder> loadHolders(Connection conn, AccountId accountId)
throws SQLException {
String sql = "SELECT * FROM account_holders WHERE account_id = ? ORDER BY name";
List<AccountHolder> holders = new ArrayList<>();
try (var stmt = conn.prepareStatement(sql)) {
stmt.setString(1, accountId.value());
var rs = stmt.executeQuery();
while (rs.next()) {
AccountHolder holder = AccountHolder.create(
new AccountHolderId(rs.getString("holder_id")),
rs.getString("name"),
AccountRole.valueOf(rs.getString("role")),
rs.getString("email")
);
holders.add(holder);
}
}
return holders;
}
private void deleteHolders(Connection conn, AccountId accountId) throws SQLException {
String sql = "DELETE FROM account_holders WHERE account_id = ?";
try (var stmt = conn.prepareStatement(sql)) {
stmt.setString(1, accountId.value());
stmt.executeUpdate();
}
}
}
```
## Row Mapper for Hydration
```java
package com.example.infrastructure.persistence.account;
import com.example.domain.account.*;
import java.sql.ResultSet;
import java.sql.SQLException;
/**
* AccountRowMapper - maps SQL result sets to Account aggregates.
*
* Part of infrastructure layer - handles database schema details.
* Separated from repository for testability and single responsibility.
*/
public class AccountRowMapper {
/**
* Map a single database row to Account aggregate.
* Note: Loads main account data but not child holders.
*/
public Account mapRow(ResultSet rs) throws SQLException {
AccountId id = new AccountId(rs.getString("id"));
Money balance = new Money(
rs.getBigDecimal("balance"),
"USD" // Assume currency column or hardcode
);
AccountStatus status = AccountStatus.valueOf(rs.getString("status"));
AccountType type = AccountType.valueOf(rs.getString("account_type"));
Money creditLimit = new Money(
rs.getBigDecimal("credit_limit"),
"USD"
);
java.time.Instant createdAt = rs.getTimestamp("created_at")
.toInstant();
java.time.Instant updatedAt = rs.getTimestamp("updated_at")
.toInstant();
// Return empty aggregate - holders loaded separately
return Account.reconstruct(
id,
balance,
type,
creditLimit,
status,
List.of(), // Holders loaded separately
createdAt,
updatedAt
);
}
}
```
## SQL Queries as Constants
```java
package com.example.infrastructure.persistence.account;
/**
* AccountQueries - SQL constants for accounts.
*
* Centralizes SQL to make schema changes easier.
* Easier to unit test and review SQL.
*/
public class AccountQueries {
private AccountQueries() {}
public static final String SAVE_ACCOUNT =
"INSERT INTO accounts (id, balance, status, account_type, credit_limit, " +
"created_at, updated_at) " +
"VALUES (?, ?, ?, ?, ?, ?, ?) " +
"ON CONFLICT(id) DO UPDATE SET " +
"balance = EXCLUDED.balance, status = EXCLUDED.status, " +
"updated_at = EXCLUDED.updated_at";
public static final String FIND_BY_ID =
"SELECT * FROM accounts WHERE id = ?";
public static final String FIND_BY_HOLDER_USER_ID =
"SELECT DISTINCT a.* FROM accounts a " +
"JOIN account_holders h ON a.id = h.account_id " +
"WHERE h.user_id = ? " +
"ORDER BY a.created_at DESC";
public static final String EXISTS =
"SELECT 1 FROM accounts WHERE id = ?";
public static final String DELETE =
"DELETE FROM accounts WHERE id = ?";
public static final String SAVE_HOLDERS =
"INSERT INTO account_holders (account_id, holder_id, name, role, email) " +
"VALUES (?, ?, ?, ?, ?)";
public static final String LOAD_HOLDERS =
"SELECT * FROM account_holders WHERE account_id = ? ORDER BY name";
public static final String DELETE_HOLDERS =
"DELETE FROM account_holders WHERE account_id = ?";
}
```
## Testing the Repository
```java
public class JdbcAccountRepositoryTest {
private JdbcAccountRepository repository;
private DataSource dataSource;
@Before
public void setup() {
// Use test database
dataSource = createTestDataSource();
repository = new JdbcAccountRepository(dataSource, new AccountRowMapper());
}
@Test
public void shouldSaveAndRetrieveAccount() {
// Arrange
AccountId id = AccountId.random();
Account account = Account.create(
id,
Money.usd(100_00),
createOwner()
).orElseThrow();
// Act
var saveResult = repository.save(account);
var findResult = repository.findById(id);
// Assert
assertThat(saveResult).satisfies(r -> {
assertThat(r).isInstanceOf(Success.class);
});
assertThat(findResult).satisfies(r -> {
assertThat(r).isInstanceOf(Success.class);
if (r instanceof Success<RepositoryError, Account> s) {
assertThat(s.value().id()).isEqualTo(id);
assertThat(s.value().balance()).isEqualTo(Money.usd(100_00));
}
});
}
@Test
public void shouldReturnNotFoundForMissingAccount() {
// Act
var result = repository.findById(new AccountId("nonexistent"));
// Assert
assertThat(result).satisfies(r -> {
assertThat(r).isInstanceOf(Failure.class);
if (r instanceof Failure<RepositoryError, Account> f) {
assertThat(f.error())
.isInstanceOf(NotFoundError.class);
}
});
}
@Test
public void shouldDeleteAccount() {
// Arrange
Account account = Account.create(...).orElseThrow();
repository.save(account);
// Act
var deleteResult = repository.delete(account.id());
var findResult = repository.findById(account.id());
// Assert
assertThat(deleteResult).satisfies(r -> {
assertThat(r).isInstanceOf(Success.class);
});
assertThat(findResult).satisfies(r -> {
assertThat(r).isInstanceOf(Failure.class);
});
}
}
```
## Contract Tests
Use these to ensure all repository implementations follow the contract:
```java
public abstract class AccountRepositoryContractTest {
protected abstract AccountRepository createRepository();
private AccountRepository repository;
@Before
public void setup() {
repository = createRepository();
}
@Test
public void shouldSaveNewAccount() {
// Every repository implementation must support this
Account account = createTestAccount();
var result = repository.save(account);
assertThat(result).satisfies(r -> {
assertThat(r).isInstanceOf(Success.class);
});
}
@Test
public void shouldFindSavedAccount() {
Account account = createTestAccount();
repository.save(account);
var result = repository.findById(account.id());
assertThat(result).satisfies(r -> {
assertThat(r).isInstanceOf(Success.class);
});
}
// Other contract tests...
}
// Implementation-specific test inherits contract
public class JdbcAccountRepositoryContractTest extends AccountRepositoryContractTest {
@Override
protected AccountRepository createRepository() {
return new JdbcAccountRepository(dataSource, new AccountRowMapper());
}
}
```
## Key Features
1. **Interface in Domain**: Repository interface lives in domain layer only
2. **Result Returns**: All methods return `Result<Error, T>` for safety
3. **No Exceptions in Domain**: Infrastructure errors wrapped in Result
4. **Implementation Separate**: Repository implementation in infrastructure layer
5. **SQL Isolation**: SQL and row mapping in dedicated classes
6. **Transaction Boundaries**: Handle savepoints and rollback in implementation
7. **Exception Logging**: Log technical errors before transformation
8. **No Leaking Details**: Domain layer never sees SQL or JDBC types
9. **Collection-Like API**: Methods named after domain concepts, not CRUD
10. **Contract Tests**: Use abstract test classes to verify all implementations
## Best Practices
- Never expose repository implementation details to domain layer
- Use Result types to maintain clean architecture boundary
- Log infrastructure errors (ERROR level), business errors (WARN level)
- Wrap external exceptions at repository boundary
- Test repositories with contract tests
- Use row mappers for schema-to-domain transformations
- Keep queries in separate constant classes
- Handle transactions explicitly
- Use prepared statements to prevent SQL injection
- Document which aggregate boundaries the repository crosses

View file

@ -0,0 +1,772 @@
# Use Case Template (Java)
Template for creating application layer use cases in Java 21+.
## Pattern
A use case:
- Is one file, one use case (not a service with multiple methods)
- Has a single `execute()` method
- Uses constructor injection for dependencies
- Returns `Result<Error, Response>` for safe error handling
- Orchestrates domain objects and repositories
- Maps domain errors to application errors at boundaries
- Can raise domain events for eventually consistent communication
## Example 1: WithdrawMoneyUseCase
```java
package com.example.application.account;
import com.example.domain.account.*;
import com.example.shared.result.Result;
import static com.example.shared.result.Result.success;
import static com.example.shared.result.Result.failure;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* WithdrawMoneyUseCase - withdraw money from an account.
*
* One use case per file - clear responsibility.
* Coordinates domain and infrastructure:
* 1. Load account from repository
* 2. Execute withdrawal on aggregate
* 3. Persist updated aggregate
* 4. Publish domain events for subscribers
* 5. Return result with response DTO
*
* Errors:
* - Account not found → repository error
* - Insufficient funds → domain error
* - Database failure → infrastructure error
*/
public class WithdrawMoneyUseCase {
private static final Logger logger = LoggerFactory.getLogger(WithdrawMoneyUseCase.class);
private final AccountRepository accountRepository;
private final DomainEventPublisher eventPublisher;
private final UnitOfWork unitOfWork;
/**
* Constructor injection - dependencies passed, not created.
* Easier to test and swap implementations.
*/
public WithdrawMoneyUseCase(
AccountRepository accountRepository,
DomainEventPublisher eventPublisher,
UnitOfWork unitOfWork
) {
this.accountRepository = accountRepository;
this.eventPublisher = eventPublisher;
this.unitOfWork = unitOfWork;
}
/**
* Execute withdrawal use case.
*
* @param request containing account ID and amount
* @return success with response (new balance), or failure with reason
*/
public Result<WithdrawError, WithdrawResponse> execute(
WithdrawRequest request
) {
// Phase 1: Validate request
try {
request.validate();
} catch (IllegalArgumentException e) {
logger.warn("Invalid withdrawal request: " + e.getMessage());
return failure(new InvalidRequestError(e.getMessage()));
}
// Phase 2: Load aggregate
Result<RepositoryError, Account> accountResult = accountRepository.findById(
request.accountId()
);
if (accountResult instanceof Failure f) {
logger.warn("Account not found: " + request.accountId().value());
return failure(mapRepositoryError(f.error()));
}
Account account = ((Success<RepositoryError, Account>) (Object) accountResult).value();
// Phase 3: Execute domain logic
Result<AccountError, Void> withdrawResult = account.withdraw(
request.amount()
);
if (withdrawResult instanceof Failure f) {
// Map domain error to application error
AccountError domainError = f.error();
WithdrawError appError = mapDomainError(domainError);
if (domainError instanceof InsufficientFundsError) {
logger.warn("Withdrawal rejected: " + appError.message());
} else {
logger.warn("Withdrawal failed: " + appError.message());
}
return failure(appError);
}
// Phase 4: Persist updated aggregate
Result<RepositoryError, Void> saveResult = unitOfWork.withTransaction(() ->
accountRepository.save(account)
);
if (saveResult instanceof Failure f) {
logger.error("Failed to save account after withdrawal: " + f.error().message());
return failure(mapRepositoryError(f.error()));
}
// Phase 5: Publish domain events
account.events().forEach(event -> {
logger.info("Publishing event: " + event.getClass().getSimpleName());
eventPublisher.publish(event);
});
// Phase 6: Return response
return success(new WithdrawResponse(
account.id().value(),
account.balance().formatted(),
account.status().toString()
));
}
// ================== Error Mapping ==================
/**
* Map domain errors to application-level errors.
* Each use case can map differently depending on context.
*/
private WithdrawError mapDomainError(AccountError error) {
return switch (error) {
case InsufficientFundsError e ->
new InsufficientFundsError(
e.required().formatted(),
e.available().formatted()
);
case AccountClosedError e ->
new AccountClosedError(e.id().value());
case AccountFrozenError e ->
new AccountFrozenError(e.id().value());
case InvalidAmountError e ->
new InvalidAmountError(e.amount().formatted());
case InvalidOperationError e ->
new OperationFailedError(e.message());
default ->
new OperationFailedError("Unexpected error: " + error.message());
};
}
/**
* Map repository/infrastructure errors to application errors.
* Hides infrastructure details from API layer.
*/
private WithdrawError mapRepositoryError(RepositoryError error) {
return switch (error) {
case NotFoundError e ->
new AccountNotFoundError(e.entityId());
case ConnectionError e -> {
logger.error("Database connection error: " + e.message());
yield new OperationFailedError("Database unavailable");
}
case SerializationError e -> {
logger.error("Serialization error: " + e.message());
yield new OperationFailedError("System error");
}
default -> {
logger.error("Unexpected repository error: " + error.message());
yield new OperationFailedError("System error");
}
};
}
}
```
## Input/Output DTOs
```java
package com.example.application.account;
import com.example.domain.account.AccountId;
import com.example.domain.account.Money;
import java.util.Objects;
/**
* WithdrawRequest - input DTO.
*
* Validation in constructor (compact if record, or method if class).
* Separates API layer concerns from domain.
*/
public record WithdrawRequest(
AccountId accountId,
Money amount
) {
public WithdrawRequest {
if (accountId == null) {
throw new IllegalArgumentException("Account ID required");
}
if (amount == null) {
throw new IllegalArgumentException("Amount required");
}
}
/**
* Explicit validation for complex rules.
*/
public void validate() {
if (amount.isNegativeOrZero()) {
throw new IllegalArgumentException("Amount must be positive");
}
}
}
/**
* WithdrawResponse - output DTO.
*
* Contains information for API response.
* Never contains internal IDs or implementation details.
*/
public record WithdrawResponse(
String accountId,
String newBalance,
String accountStatus
) {}
```
## Use Case Errors
```java
package com.example.application.account;
/**
* WithdrawError - application-layer error for withdrawal.
*
* Sealed interface restricts implementations.
* More specific than domain errors, less specific than domain.
*/
public sealed interface WithdrawError permits
InsufficientFundsError,
AccountClosedError,
AccountFrozenError,
InvalidAmountError,
AccountNotFoundError,
InvalidRequestError,
OperationFailedError {
String message();
}
public record InsufficientFundsError(
String required,
String available
) implements WithdrawError {
@Override
public String message() {
return String.format(
"Insufficient funds: need %s, have %s",
required, available
);
}
}
public record AccountClosedError(String accountId) implements WithdrawError {
@Override
public String message() {
return "Account is closed: " + accountId;
}
}
public record AccountFrozenError(String accountId) implements WithdrawError {
@Override
public String message() {
return "Account is frozen: " + accountId;
}
}
public record InvalidAmountError(String amount) implements WithdrawError {
@Override
public String message() {
return "Invalid amount: " + amount;
}
}
public record AccountNotFoundError(String accountId) implements WithdrawError {
@Override
public String message() {
return "Account not found: " + accountId;
}
}
public record InvalidRequestError(String reason) implements WithdrawError {
@Override
public String message() {
return "Invalid request: " + reason;
}
}
public record OperationFailedError(String reason) implements WithdrawError {
@Override
public String message() {
return "Operation failed: " + reason;
}
}
```
## Example 2: TransferMoneyUseCase
```java
package com.example.application.transfer;
import com.example.domain.account.*;
import com.example.domain.transfer.*;
import com.example.shared.result.Result;
import static com.example.shared.result.Result.success;
import static com.example.shared.result.Result.failure;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* TransferMoneyUseCase - transfer money between accounts.
*
* More complex use case coordinating multiple aggregates.
* Handles transaction boundaries and eventual consistency.
*
* Two ways to handle:
* 1. Distributed transaction (2-phase commit) - complex
* 2. Saga pattern - better for async, but more code
* 3. Event sourcing - captures all state changes
*
* This example uses approach 1 (simplified).
*/
public class TransferMoneyUseCase {
private static final Logger logger = LoggerFactory.getLogger(TransferMoneyUseCase.class);
private final AccountRepository accountRepository;
private final TransferRepository transferRepository;
private final DomainEventPublisher eventPublisher;
private final UnitOfWork unitOfWork;
private final TransferIdGenerator transferIdGenerator;
public TransferMoneyUseCase(
AccountRepository accountRepository,
TransferRepository transferRepository,
DomainEventPublisher eventPublisher,
UnitOfWork unitOfWork,
TransferIdGenerator transferIdGenerator
) {
this.accountRepository = accountRepository;
this.transferRepository = transferRepository;
this.eventPublisher = eventPublisher;
this.unitOfWork = unitOfWork;
this.transferIdGenerator = transferIdGenerator;
}
/**
* Execute money transfer between accounts.
*/
public Result<TransferError, TransferResponse> execute(
TransferRequest request
) {
// Validate request
try {
request.validate();
} catch (IllegalArgumentException e) {
logger.warn("Invalid transfer request: " + e.getMessage());
return failure(new InvalidTransferRequestError(e.getMessage()));
}
// Load source account
Result<RepositoryError, Account> sourceResult = accountRepository.findById(
request.sourceAccountId()
);
if (sourceResult instanceof Failure f) {
return failure(new SourceAccountNotFoundError(request.sourceAccountId().value()));
}
Account source = ((Success) (Object) sourceResult).value();
// Load destination account
Result<RepositoryError, Account> destResult = accountRepository.findById(
request.destinationAccountId()
);
if (destResult instanceof Failure f) {
return failure(new DestinationAccountNotFoundError(request.destinationAccountId().value()));
}
Account destination = ((Success) (Object) destResult).value();
// Check accounts are different
if (source.id().equals(destination.id())) {
logger.warn("Transfer attempted to same account");
return failure(new SameAccountError());
}
// Execute transfer within transaction
return unitOfWork.withTransaction(() -> {
// Withdraw from source
Result<AccountError, Void> withdrawResult = source.withdraw(request.amount());
if (withdrawResult instanceof Failure f) {
AccountError error = f.error();
if (error instanceof InsufficientFundsError e) {
logger.warn("Transfer rejected: insufficient funds");
return failure(mapError(e));
}
logger.warn("Source withdrawal failed: " + error.message());
return failure(new TransferFailedError(error.message()));
}
// Deposit to destination
Result<AccountError, Void> depositResult = destination.deposit(request.amount());
if (depositResult instanceof Failure f) {
logger.error("Destination deposit failed (source already withdrawn!)");
// This is a problem - source is withdrawn but dest failed
// In production, would need compensating transaction
return failure(new TransferFailedError("Deposit failed after withdrawal"));
}
// Create transfer aggregate to track it
TransferId transferId = transferIdGenerator.generate();
Result<TransferError, Transfer> createResult = Transfer.create(
transferId,
source.id(),
destination.id(),
request.amount()
);
if (createResult instanceof Failure f) {
logger.error("Transfer aggregate creation failed");
return failure(f.error());
}
Transfer transfer = ((Success) (Object) createResult).value();
// Mark transfer as completed
transfer.complete();
// Persist both accounts and transfer
accountRepository.save(source);
accountRepository.save(destination);
transferRepository.save(transfer);
// Publish all events
source.events().forEach(eventPublisher::publish);
destination.events().forEach(eventPublisher::publish);
transfer.events().forEach(eventPublisher::publish);
logger.info("Transfer completed: " + transferId.value() +
" from " + source.id().value() +
" to " + destination.id().value() +
" amount " + request.amount().formatted());
return success(new TransferResponse(
transfer.id().value(),
transfer.status().toString(),
request.amount().formatted()
));
});
}
// Error mapping helpers...
}
public record TransferRequest(
AccountId sourceAccountId,
AccountId destinationAccountId,
Money amount
) {
public TransferRequest {
if (sourceAccountId == null || destinationAccountId == null || amount == null) {
throw new IllegalArgumentException("All fields required");
}
}
public void validate() {
if (amount.isNegativeOrZero()) {
throw new IllegalArgumentException("Amount must be positive");
}
}
}
public record TransferResponse(
String transferId,
String status,
String amount
) {}
```
## Use Case with Query (Read Operation)
```java
package com.example.application.account;
import com.example.domain.account.*;
import com.example.shared.result.Result;
import static com.example.shared.result.Result.success;
import static com.example.shared.result.Result.failure;
/**
* GetAccountBalanceUseCase - retrieve account balance (query, no mutation).
*
* Read-only use case - doesn't modify domain state.
* Still returns Result for consistency.
*/
public class GetAccountBalanceUseCase {
private final AccountRepository accountRepository;
public GetAccountBalanceUseCase(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
/**
* Execute query for account balance.
*/
public Result<GetBalanceError, GetBalanceResponse> execute(
GetBalanceRequest request
) {
Result<RepositoryError, Account> result = accountRepository.findById(
request.accountId()
);
if (result instanceof Failure f) {
return failure(new AccountNotFoundError(request.accountId().value()));
}
Account account = ((Success<RepositoryError, Account>) (Object) result).value();
return success(new GetBalanceResponse(
account.id().value(),
account.balance().formatted(),
account.status().toString()
));
}
}
public record GetBalanceRequest(AccountId accountId) {
public GetBalanceRequest {
if (accountId == null) {
throw new IllegalArgumentException("Account ID required");
}
}
}
public record GetBalanceResponse(
String accountId,
String balance,
String status
) {}
public sealed interface GetBalanceError permits
AccountNotFoundError,
OperationFailedError {}
```
## Testing Use Cases
```java
public class WithdrawMoneyUseCaseTest {
private WithdrawMoneyUseCase useCase;
private AccountRepository accountRepository;
private DomainEventPublisher eventPublisher;
private UnitOfWork unitOfWork;
@Before
public void setup() {
// Use mock implementations
accountRepository = mock(AccountRepository.class);
eventPublisher = mock(DomainEventPublisher.class);
unitOfWork = mock(UnitOfWork.class);
useCase = new WithdrawMoneyUseCase(
accountRepository,
eventPublisher,
unitOfWork
);
}
@Test
public void shouldWithdrawSuccessfully() {
// Arrange
Account account = createTestAccount(Money.usd(100_00));
when(accountRepository.findById(any()))
.thenReturn(success(account));
when(unitOfWork.withTransaction(any()))
.thenAnswer(inv -> {
// Execute the lambda
var lambda = inv.getArgument(0);
return ((java.util.function.Supplier) lambda).get();
});
// Act
var result = useCase.execute(new WithdrawRequest(
account.id(),
Money.usd(50_00)
));
// Assert
assertThat(result).satisfies(r -> {
assertThat(r).isInstanceOf(Success.class);
if (r instanceof Success<WithdrawError, WithdrawResponse> s) {
assertThat(s.value().newBalance())
.contains("50.00"); // $100 - $50 = $50
}
});
// Verify events published
verify(eventPublisher, times(1)).publish(any());
}
@Test
public void shouldRejectInsufficientFunds() {
// Arrange
Account account = createTestAccount(Money.usd(30_00));
when(accountRepository.findById(any()))
.thenReturn(success(account));
// Act
var result = useCase.execute(new WithdrawRequest(
account.id(),
Money.usd(50_00) // More than balance
));
// Assert
assertThat(result).satisfies(r -> {
assertThat(r).isInstanceOf(Failure.class);
if (r instanceof Failure<WithdrawError, WithdrawResponse> f) {
assertThat(f.error())
.isInstanceOf(InsufficientFundsError.class);
}
});
// Verify account not saved
verify(accountRepository, never()).save(any());
}
@Test
public void shouldReturnAccountNotFoundError() {
// Arrange
when(accountRepository.findById(any()))
.thenReturn(failure(
new NotFoundError("Account", "nonexistent")
));
// Act
var result = useCase.execute(new WithdrawRequest(
new AccountId("nonexistent"),
Money.usd(10_00)
));
// Assert
assertThat(result).satisfies(r -> {
assertThat(r).isInstanceOf(Failure.class);
if (r instanceof Failure<WithdrawError, WithdrawResponse> f) {
assertThat(f.error())
.isInstanceOf(AccountNotFoundError.class);
}
});
}
}
```
## UnitOfWork Pattern for Transactions
```java
package com.example.application.shared;
import com.example.shared.result.Result;
/**
* UnitOfWork - manages transaction boundaries.
*
* Ensures all changes in a use case are committed atomically.
* Implementation handles JDBC transactions, Spring, Hibernate, etc.
*/
public interface UnitOfWork {
/**
* Execute work within a transaction.
* Commits if no exception, rolls back on exception or Result.failure.
*/
<E, T> Result<E, T> withTransaction(
java.util.function.Supplier<Result<E, T>> work
);
/**
* Synchronous version - work runs in transaction.
*/
void withTransactionVoid(Runnable work) throws Exception;
}
/**
* JDBC implementation of UnitOfWork.
*/
public class JdbcUnitOfWork implements UnitOfWork {
private final DataSource dataSource;
public JdbcUnitOfWork(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public <E, T> Result<E, T> withTransaction(
java.util.function.Supplier<Result<E, T>> work
) {
Connection conn = null;
try {
conn = dataSource.getConnection();
conn.setAutoCommit(false);
// Execute work
Result<E, T> result = work.get();
if (result instanceof Failure) {
conn.rollback();
} else {
conn.commit();
}
return result;
} catch (Exception e) {
if (conn != null) {
try { conn.rollback(); } catch (SQLException ex) {
// Log rollback error
}
}
throw new RuntimeException("Transaction failed", e);
} finally {
if (conn != null) {
try { conn.close(); } catch (SQLException e) {
// Log close error
}
}
}
}
}
```
## Key Features
1. **One Use Case Per File**: Single responsibility, easy to find and test
2. **Constructor Injection**: Dependencies passed in, not created
3. **Result Returns**: All methods return `Result<Error, Response>`
4. **Error Mapping**: Domain errors → Application errors at boundary
5. **Orchestration**: Coordinates domain objects and repositories
6. **Transaction Boundaries**: Uses UnitOfWork for atomicity
7. **Event Publishing**: Raises domain events for subscribers
8. **Logging Strategy**: WARN for business errors, ERROR for technical
9. **Input Validation**: Validate DTOs before using
10. **No Business Logic in DTOs**: DTOs are data, logic in aggregates
## Best Practices
- One execute() method per use case class
- Use constructor injection for testability
- Catch all exceptions at infrastructure boundary
- Log domain errors at WARN level (expected failures)
- Log infrastructure errors at ERROR level (unexpected)
- Return Result types for all operations
- Map errors at layer boundaries
- Use UnitOfWork for transaction coordination
- Publish domain events after persistence succeeds
- Never expose domain objects in responses (use DTOs)
- Keep use cases focused on orchestration, not logic

View file

@ -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