mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 17:29:58 +01:00
docs: and skills
This commit is contained in:
parent
e4f0665086
commit
ccd4ee534a
25 changed files with 10412 additions and 0 deletions
781
bin/.claude/skills/ddd-model/languages/java/error-handling.md
Normal file
781
bin/.claude/skills/ddd-model/languages/java/error-handling.md
Normal file
|
|
@ -0,0 +1,781 @@
|
|||
# Error Handling in Java 21+
|
||||
|
||||
This guide covers error handling in Java projects following DDD principles, with emphasis on Result types and sealed interfaces.
|
||||
|
||||
## Result<E, T> Interface
|
||||
|
||||
The fundamental pattern for error handling without exceptions:
|
||||
|
||||
### Definition
|
||||
|
||||
```java
|
||||
package com.example.shared.result;
|
||||
|
||||
/**
|
||||
* Result represents either an error (left) or a value (right).
|
||||
* Inspired by Either/Result from functional languages.
|
||||
*
|
||||
* @param <E> Error type (must be sealed interface or final class)
|
||||
* @param <T> Success value type
|
||||
*/
|
||||
public sealed interface Result<E, T> permits Failure, Success {
|
||||
/**
|
||||
* Applies function if result is success.
|
||||
*/
|
||||
<U> Result<E, U> map(java.util.function.Function<T, U> fn);
|
||||
|
||||
/**
|
||||
* Chains Result-returning operations.
|
||||
*/
|
||||
<U> Result<E, U> flatMap(java.util.function.Function<T, Result<E, U>> fn);
|
||||
|
||||
/**
|
||||
* Applies error function if result is failure.
|
||||
*/
|
||||
Result<E, T> mapError(java.util.function.Function<E, E> fn);
|
||||
|
||||
/**
|
||||
* Pattern matching on Result.
|
||||
*/
|
||||
<U> U match(
|
||||
java.util.function.Function<E, U> onError,
|
||||
java.util.function.Function<T, U> onSuccess
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns value or throws if error.
|
||||
*/
|
||||
T orElseThrow(java.util.function.Function<E, ? extends RuntimeException> exceptionFn);
|
||||
|
||||
/**
|
||||
* Returns value or default if error.
|
||||
*/
|
||||
T orElse(T defaultValue);
|
||||
|
||||
/**
|
||||
* Success result (right side).
|
||||
*/
|
||||
static <E, T> Result<E, T> success(T value) {
|
||||
return new Success<>(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Failure result (left side).
|
||||
*/
|
||||
static <E, T> Result<E, T> failure(E error) {
|
||||
return new Failure<>(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for success().
|
||||
*/
|
||||
static <E, T> Result<E, T> ok(T value) {
|
||||
return success(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for failure().
|
||||
*/
|
||||
static <E, T> Result<E, T> err(E error) {
|
||||
return failure(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Success case - carries the successful value.
|
||||
*/
|
||||
final class Success<E, T> implements Result<E, T> {
|
||||
private final T value;
|
||||
|
||||
Success(T value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <U> Result<E, U> map(java.util.function.Function<T, U> fn) {
|
||||
return new Success<>(fn.apply(value));
|
||||
}
|
||||
|
||||
@Override
|
||||
public <U> Result<E, U> flatMap(java.util.function.Function<T, Result<E, U>> fn) {
|
||||
return fn.apply(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<E, T> mapError(java.util.function.Function<E, E> fn) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <U> U match(
|
||||
java.util.function.Function<E, U> onError,
|
||||
java.util.function.Function<T, U> onSuccess
|
||||
) {
|
||||
return onSuccess.apply(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public T orElseThrow(java.util.function.Function<E, ? extends RuntimeException> exceptionFn) {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public T orElse(T defaultValue) {
|
||||
return value;
|
||||
}
|
||||
|
||||
public T value() {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Success(" + value + ")";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Failure case - carries the error.
|
||||
*/
|
||||
final class Failure<E, T> implements Result<E, T> {
|
||||
private final E error;
|
||||
|
||||
Failure(E error) {
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <U> Result<E, U> map(java.util.function.Function<T, U> fn) {
|
||||
return new Failure<>(error);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <U> Result<E, U> flatMap(java.util.function.Function<T, Result<E, U>> fn) {
|
||||
return new Failure<>(error);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<E, T> mapError(java.util.function.Function<E, E> fn) {
|
||||
return new Failure<>(fn.apply(error));
|
||||
}
|
||||
|
||||
@Override
|
||||
public <U> U match(
|
||||
java.util.function.Function<E, U> onError,
|
||||
java.util.function.Function<T, U> onSuccess
|
||||
) {
|
||||
return onError.apply(error);
|
||||
}
|
||||
|
||||
@Override
|
||||
public T orElseThrow(java.util.function.Function<E, ? extends RuntimeException> exceptionFn) {
|
||||
throw exceptionFn.apply(error);
|
||||
}
|
||||
|
||||
@Override
|
||||
public T orElse(T defaultValue) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
public E error() {
|
||||
return error;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Failure(" + error + ")";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Static Imports for Readability
|
||||
|
||||
Define static import helpers in your domain:
|
||||
|
||||
```java
|
||||
// In com.example.shared.result.Results
|
||||
public class Results {
|
||||
private Results() {}
|
||||
|
||||
public static <E, T> Result<E, T> ok(T value) {
|
||||
return Result.success(value);
|
||||
}
|
||||
|
||||
public static <E, T> Result<E, T> fail(E error) {
|
||||
return Result.failure(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage with static import
|
||||
import static com.example.shared.result.Results.*;
|
||||
|
||||
Result<AccountError, Account> result = ok(account);
|
||||
Result<AccountError, Void> error = fail(new InsufficientFundsError());
|
||||
```
|
||||
|
||||
## Sealed Interfaces for Domain Errors
|
||||
|
||||
Layer-specific errors as sealed interfaces:
|
||||
|
||||
### Domain Layer Errors
|
||||
|
||||
```java
|
||||
package com.example.domain.account;
|
||||
|
||||
/**
|
||||
* Account domain errors - business rule violations.
|
||||
*/
|
||||
public sealed interface AccountError permits
|
||||
InsufficientFundsError,
|
||||
AccountClosedError,
|
||||
InvalidAmountError,
|
||||
AccountNotFoundError {
|
||||
|
||||
String message();
|
||||
}
|
||||
|
||||
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 AccountClosedError(
|
||||
AccountId accountId
|
||||
) implements AccountError {
|
||||
@Override
|
||||
public String message() {
|
||||
return "Account is closed: " + accountId;
|
||||
}
|
||||
}
|
||||
|
||||
public record InvalidAmountError(
|
||||
Money amount,
|
||||
String reason
|
||||
) implements AccountError {
|
||||
@Override
|
||||
public String message() {
|
||||
return String.format("Invalid amount %s: %s", amount, reason);
|
||||
}
|
||||
}
|
||||
|
||||
public record AccountNotFoundError(
|
||||
AccountId accountId
|
||||
) implements AccountError {
|
||||
@Override
|
||||
public String message() {
|
||||
return "Account not found: " + accountId;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Application Layer Errors
|
||||
|
||||
```java
|
||||
package com.example.application.account;
|
||||
|
||||
/**
|
||||
* Application layer errors - use case failures.
|
||||
* Maps domain errors to application context.
|
||||
*/
|
||||
public sealed interface WithdrawError permits
|
||||
InsufficientFundsError,
|
||||
AccountLockedError,
|
||||
RepositoryError,
|
||||
ConcurrencyError {
|
||||
|
||||
String message();
|
||||
}
|
||||
|
||||
public record InsufficientFundsError(
|
||||
String accountId,
|
||||
java.math.BigDecimal required,
|
||||
java.math.BigDecimal available
|
||||
) implements WithdrawError {
|
||||
@Override
|
||||
public String message() {
|
||||
return String.format(
|
||||
"Cannot withdraw: insufficient funds in account %s",
|
||||
accountId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public record AccountLockedError(
|
||||
String accountId,
|
||||
String reason
|
||||
) implements WithdrawError {
|
||||
@Override
|
||||
public String message() {
|
||||
return String.format("Account %s is locked: %s", accountId, reason);
|
||||
}
|
||||
}
|
||||
|
||||
public record RepositoryError(
|
||||
String cause
|
||||
) implements WithdrawError {
|
||||
@Override
|
||||
public String message() {
|
||||
return "Repository error: " + cause;
|
||||
}
|
||||
}
|
||||
|
||||
public record ConcurrencyError(
|
||||
String accountId
|
||||
) implements WithdrawError {
|
||||
@Override
|
||||
public String message() {
|
||||
return "Account was modified concurrently: " + accountId;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Infrastructure Layer Errors
|
||||
|
||||
```java
|
||||
package com.example.infrastructure.persistence;
|
||||
|
||||
/**
|
||||
* Infrastructure errors - technical failures.
|
||||
* Never leak to domain/application layer.
|
||||
*/
|
||||
public sealed interface PersistenceError permits
|
||||
ConnectionError,
|
||||
QueryError,
|
||||
TransactionError,
|
||||
DeserializationError {
|
||||
|
||||
String message();
|
||||
Throwable cause();
|
||||
}
|
||||
|
||||
public record ConnectionError(
|
||||
String reason,
|
||||
Throwable exception
|
||||
) implements PersistenceError {
|
||||
@Override
|
||||
public String message() {
|
||||
return "Database connection failed: " + reason;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Throwable cause() {
|
||||
return exception;
|
||||
}
|
||||
}
|
||||
|
||||
public record QueryError(
|
||||
String sql,
|
||||
Throwable exception
|
||||
) implements PersistenceError {
|
||||
@Override
|
||||
public String message() {
|
||||
return "Query execution failed: " + sql;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Throwable cause() {
|
||||
return exception;
|
||||
}
|
||||
}
|
||||
|
||||
public record TransactionError(
|
||||
String reason,
|
||||
Throwable exception
|
||||
) implements PersistenceError {
|
||||
@Override
|
||||
public String message() {
|
||||
return "Transaction failed: " + reason;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Throwable cause() {
|
||||
return exception;
|
||||
}
|
||||
}
|
||||
|
||||
public record DeserializationError(
|
||||
String reason,
|
||||
Throwable exception
|
||||
) implements PersistenceError {
|
||||
@Override
|
||||
public String message() {
|
||||
return "Deserialization failed: " + reason;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Throwable cause() {
|
||||
return exception;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern Matching Examples
|
||||
|
||||
### Switch Expression with Pattern Matching
|
||||
|
||||
```java
|
||||
public static void handleWithdrawalResult(Result<WithdrawError, Account> result) {
|
||||
switch (result) {
|
||||
case Success(Account account) -> {
|
||||
System.out.println("Withdrawal successful, new balance: " + account.balance());
|
||||
}
|
||||
case Failure(InsufficientFundsError error) -> {
|
||||
System.err.println(error.message());
|
||||
}
|
||||
case Failure(AccountLockedError error) -> {
|
||||
System.err.println("Please contact support: " + error.reason());
|
||||
}
|
||||
case Failure(WithdrawError error) -> {
|
||||
System.err.println("Unexpected error: " + error.message());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Map and FlatMap Chaining
|
||||
|
||||
```java
|
||||
public Result<TransferError, TransferRecord> transfer(
|
||||
AccountId fromId,
|
||||
AccountId toId,
|
||||
Money amount
|
||||
) {
|
||||
return accountRepository.findById(fromId)
|
||||
.mapError(e -> new SourceAccountNotFound(fromId))
|
||||
.flatMap(fromAccount ->
|
||||
accountRepository.findById(toId)
|
||||
.mapError(e -> new DestinationAccountNotFound(toId))
|
||||
.flatMap(toAccount -> {
|
||||
Result<?, Void> withdrawn = fromAccount.withdraw(amount);
|
||||
if (withdrawn instanceof Failure failure) {
|
||||
return Result.failure(new WithdrawalFailed(failure.error().message()));
|
||||
}
|
||||
|
||||
Result<?, Void> deposited = toAccount.deposit(amount);
|
||||
if (deposited instanceof Failure failure) {
|
||||
return Result.failure(new DepositFailed(failure.error().message()));
|
||||
}
|
||||
|
||||
return Result.success(new TransferRecord(fromId, toId, amount));
|
||||
})
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Match Expression for Conditional Logic
|
||||
|
||||
```java
|
||||
public String formatWithdrawalStatus(Result<WithdrawError, Account> result) {
|
||||
return result.match(
|
||||
error -> "Failed: " + error.message(),
|
||||
account -> "Success: New balance is " + account.balance().formatted()
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Layer-Specific Error Transformation
|
||||
|
||||
### Domain to Application Boundary
|
||||
|
||||
```java
|
||||
package com.example.application.account;
|
||||
|
||||
import com.example.domain.account.AccountError;
|
||||
import com.example.infrastructure.persistence.PersistenceError;
|
||||
|
||||
public class WithdrawService {
|
||||
private final AccountRepository repository;
|
||||
|
||||
/**
|
||||
* Maps domain errors to application errors.
|
||||
* Infrastructure errors caught at boundary.
|
||||
*/
|
||||
public Result<WithdrawError, Account> withdraw(
|
||||
AccountId accountId,
|
||||
Money amount
|
||||
) {
|
||||
try {
|
||||
// Repository might fail with infrastructure errors
|
||||
return repository.findById(accountId)
|
||||
.mapError(error -> mapDomainError(error))
|
||||
.flatMap(account ->
|
||||
account.withdraw(amount)
|
||||
.mapError(error -> mapDomainError(error))
|
||||
);
|
||||
} catch (Exception e) {
|
||||
// Infrastructure exception caught at boundary
|
||||
logger.error("Unexpected infrastructure error during withdrawal", e);
|
||||
return Result.failure(new RepositoryError(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private WithdrawError mapDomainError(AccountError error) {
|
||||
return switch (error) {
|
||||
case InsufficientFundsError e ->
|
||||
new InsufficientFundsError(
|
||||
e.required().toString(),
|
||||
e.available().toString()
|
||||
);
|
||||
case AccountClosedError e ->
|
||||
new AccountLockedError(e.accountId().value(), "Account closed");
|
||||
case InvalidAmountError e ->
|
||||
new RepositoryError(e.reason());
|
||||
case AccountNotFoundError e ->
|
||||
new RepositoryError("Account not found: " + e.accountId());
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Application to Infrastructure Boundary
|
||||
|
||||
```java
|
||||
package com.example.infrastructure.persistence;
|
||||
|
||||
import com.example.domain.account.Account;
|
||||
import java.sql.Connection;
|
||||
|
||||
public class JdbcAccountRepository implements AccountRepository {
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, Account> findById(AccountId id) {
|
||||
Connection conn = null;
|
||||
try {
|
||||
conn = dataSource.getConnection();
|
||||
Account account = executeQuery(conn, id);
|
||||
return Result.success(account);
|
||||
} catch (SQLException e) {
|
||||
// Log original exception
|
||||
logger.error("SQL error querying account: " + id, e);
|
||||
return Result.failure(new RepositoryError(
|
||||
"Database query failed: " + e.getMessage()
|
||||
));
|
||||
} catch (Exception e) {
|
||||
logger.error("Unexpected error deserializing account: " + id, e);
|
||||
return Result.failure(new RepositoryError(
|
||||
"Deserialization failed: " + e.getMessage()
|
||||
));
|
||||
} finally {
|
||||
if (conn != null) {
|
||||
try { conn.close(); } catch (SQLException e) {
|
||||
logger.warn("Error closing connection", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Account executeQuery(Connection conn, AccountId id) throws SQLException {
|
||||
String sql = "SELECT * FROM accounts WHERE id = ?";
|
||||
try (var stmt = conn.prepareStatement(sql)) {
|
||||
stmt.setString(1, id.value());
|
||||
var rs = stmt.executeQuery();
|
||||
if (!rs.next()) {
|
||||
throw new SQLException("Account not found: " + id);
|
||||
}
|
||||
return mapRowToAccount(rs);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Exception Boundaries
|
||||
|
||||
### Infrastructure Layer Only Catches External Exceptions
|
||||
|
||||
```java
|
||||
package com.example.infrastructure.payment;
|
||||
|
||||
import org.stripe.exception.StripeException;
|
||||
|
||||
public class StripePaymentGateway implements PaymentGateway {
|
||||
|
||||
@Override
|
||||
public Result<PaymentError, PaymentConfirmation> charge(
|
||||
String token,
|
||||
Money amount
|
||||
) {
|
||||
try {
|
||||
// Stripe API call
|
||||
var charge = Stripe.Charges.create(
|
||||
token, amount.centAmount()
|
||||
);
|
||||
return Result.success(new PaymentConfirmation(charge.id));
|
||||
|
||||
} catch (StripeException e) {
|
||||
// Log original exception
|
||||
logger.error("Stripe API error charging payment", e);
|
||||
|
||||
// Transform to domain error
|
||||
PaymentError error = switch (e.getCode()) {
|
||||
case "card_declined" ->
|
||||
new CardDeclinedError(e.getMessage());
|
||||
case "rate_limit" ->
|
||||
new PaymentUnavailableError("Rate limited by Stripe");
|
||||
default ->
|
||||
new PaymentFailedError(e.getMessage());
|
||||
};
|
||||
|
||||
return Result.failure(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Domain/Application Layers Never Catch Exceptions
|
||||
|
||||
```java
|
||||
// ❌ DON'T DO THIS in domain/application
|
||||
public class Order {
|
||||
public Result<OrderError, Void> place() {
|
||||
try { // ❌ No try/catch in domain
|
||||
validate();
|
||||
return Result.success(null);
|
||||
} catch (Exception e) { // ❌ Wrong
|
||||
return Result.failure(new OrderError(...));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ DO THIS instead
|
||||
public class Order {
|
||||
public Result<OrderError, Void> place() {
|
||||
if (!isValid()) { // ✅ Guard clauses instead
|
||||
return Result.failure(new InvalidOrderError(...));
|
||||
}
|
||||
return Result.success(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Logging Strategy
|
||||
|
||||
### ERROR Level - Infrastructure Failures
|
||||
|
||||
```java
|
||||
// Database down, network error, external service error
|
||||
logger.error("Database connection failed", e); // Log original exception
|
||||
logger.error("Stripe API error during payment", stripeException);
|
||||
logger.error("Kafka publishing failed for event: " + eventId, kafkaException);
|
||||
```
|
||||
|
||||
### WARN Level - Business Rule Violations
|
||||
|
||||
```java
|
||||
// Insufficient funds, account closed, invalid state
|
||||
logger.warn("Withdrawal rejected: insufficient funds in account " + accountId);
|
||||
logger.warn("Order cannot be placed with empty line items");
|
||||
logger.warn("Transfer cancelled: destination account closed");
|
||||
```
|
||||
|
||||
### INFO Level - Normal Operations
|
||||
|
||||
```java
|
||||
// Expected business events
|
||||
logger.info("Account created: " + accountId);
|
||||
logger.info("Transfer completed: " + transferId + " from " + fromId + " to " + toId);
|
||||
logger.info("Order placed: " + orderId + " for customer " + customerId);
|
||||
```
|
||||
|
||||
### DEBUG Level - Detailed Flow
|
||||
|
||||
```java
|
||||
// Control flow details (not shown in production)
|
||||
logger.debug("Starting account balance calculation for: " + accountId);
|
||||
logger.debug("Processing " + lineItems.size() + " line items");
|
||||
```
|
||||
|
||||
### Logging at Layer Boundaries
|
||||
|
||||
```java
|
||||
public class WithdrawService {
|
||||
public Result<WithdrawError, Account> withdraw(
|
||||
AccountId accountId,
|
||||
Money amount
|
||||
) {
|
||||
return repository.findById(accountId)
|
||||
.mapError(domainError -> {
|
||||
// Log domain error at application level
|
||||
logger.warn("Account not found during withdrawal: " + accountId);
|
||||
return mapToApplicationError(domainError);
|
||||
})
|
||||
.flatMap(account ->
|
||||
account.withdraw(amount)
|
||||
.mapError(domainError -> {
|
||||
if (domainError instanceof InsufficientFundsError e) {
|
||||
logger.warn("Insufficient funds: required " + e.required() +
|
||||
", available " + e.available());
|
||||
}
|
||||
return mapToApplicationError(domainError);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public class JdbcAccountRepository implements AccountRepository {
|
||||
@Override
|
||||
public Result<RepositoryError, Account> findById(AccountId id) {
|
||||
try {
|
||||
return executeQuery(id);
|
||||
} catch (SQLException e) {
|
||||
// Log technical error at infrastructure level
|
||||
logger.error("SQL error querying account " + id, e);
|
||||
return Result.failure(new RepositoryError("Query failed: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use sealed interfaces** for all domain/application errors
|
||||
2. **Use records** for error data classes (immutable, concise)
|
||||
3. **Add `message()` method** to every error type
|
||||
4. **Transform errors at boundaries** (domain → application → infrastructure)
|
||||
5. **Log original exceptions** before transformation
|
||||
6. **Use static imports** for Result.success/failure
|
||||
7. **Pattern match** instead of instanceof checks
|
||||
8. **Avoid null returns** - use Result.failure instead
|
||||
9. **Document invariants** that cause errors
|
||||
10. **Never expose infrastructure errors** to domain/application
|
||||
|
||||
## Testing Error Cases
|
||||
|
||||
```java
|
||||
public class WithdrawalTest {
|
||||
@Test
|
||||
void shouldRejectWithdrawalExceedingBalance() {
|
||||
var account = Account.create(
|
||||
accountId,
|
||||
Money.usd(100)
|
||||
).orElseThrow();
|
||||
|
||||
Result<WithdrawError, Account> result = account.withdraw(Money.usd(150));
|
||||
|
||||
assertThat(result).satisfies(r -> {
|
||||
assertThat(r).isInstanceOf(Failure.class);
|
||||
if (r instanceof Failure f) {
|
||||
assertThat(f.error()).isInstanceOf(InsufficientFundsError.class);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMapDomainErrorToApplicationError() {
|
||||
var domainError = new AccountNotFoundError(accountId);
|
||||
var applicationError = service.mapDomainError(domainError);
|
||||
|
||||
assertThat(applicationError)
|
||||
.isInstanceOf(RepositoryError.class);
|
||||
assertThat(applicationError.message())
|
||||
.contains("Account not found");
|
||||
}
|
||||
}
|
||||
```
|
||||
685
bin/.claude/skills/ddd-model/languages/java/structure.md
Normal file
685
bin/.claude/skills/ddd-model/languages/java/structure.md
Normal file
|
|
@ -0,0 +1,685 @@
|
|||
# Project Structure for Java DDD
|
||||
|
||||
This guide covers organizing a Java project following Clean Architecture and Domain-Driven Design principles.
|
||||
|
||||
## Maven Structure
|
||||
|
||||
Standard Maven project organization:
|
||||
|
||||
```
|
||||
project-root/
|
||||
├── pom.xml
|
||||
├── src/
|
||||
│ ├── main/
|
||||
│ │ └── java/
|
||||
│ │ └── com/example/
|
||||
│ │ ├── domain/ # Domain layer
|
||||
│ │ ├── application/ # Application layer
|
||||
│ │ ├── infrastructure/ # Infrastructure layer
|
||||
│ │ └── shared/ # Shared utilities
|
||||
│ └── test/
|
||||
│ └── java/
|
||||
│ └── com/example/
|
||||
│ ├── domain/
|
||||
│ ├── application/
|
||||
│ ├── infrastructure/
|
||||
│ └── shared/
|
||||
├── README.md
|
||||
└── .gitignore
|
||||
```
|
||||
|
||||
### Maven Dependencies Structure
|
||||
|
||||
```xml
|
||||
<project>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>banking-system</artifactId>
|
||||
<version>1.0.0</version>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.release>21</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<!-- Java 21 features (no additional dependencies needed) -->
|
||||
|
||||
<!-- Testing -->
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.10.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Logging (infrastructure layer only) -->
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
<version>2.0.7</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Database (infrastructure only) -->
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>42.6.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.11.0</version>
|
||||
<configuration>
|
||||
<release>21</release>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
```
|
||||
|
||||
## Gradle Structure
|
||||
|
||||
Gradle-based alternative:
|
||||
|
||||
```gradle
|
||||
plugins {
|
||||
id 'java'
|
||||
}
|
||||
|
||||
java {
|
||||
sourceCompatibility = '21'
|
||||
targetCompatibility = '21'
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Testing
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.0'
|
||||
|
||||
// Logging
|
||||
implementation 'org.slf4j:slf4j-api:2.0.7'
|
||||
runtimeOnly 'org.slf4j:slf4j-simple:2.0.7'
|
||||
|
||||
// Database
|
||||
implementation 'org.postgresql:postgresql:42.6.0'
|
||||
}
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Directory Structure
|
||||
|
||||
### Domain Layer
|
||||
|
||||
```
|
||||
src/main/java/com/example/domain/
|
||||
├── account/ # Bounded context: Account
|
||||
│ ├── Account.java # Aggregate root
|
||||
│ ├── AccountId.java # Value object (ID)
|
||||
│ ├── AccountStatus.java # Value object (enum-like)
|
||||
│ ├── Money.java # Value object (shared across contexts)
|
||||
│ ├── AccountError.java # Sealed interface for errors
|
||||
│ ├── AccountRepository.java # Repository interface (domain contract)
|
||||
│ └── DomainEventPublisher.java # Event publishing interface (optional)
|
||||
│
|
||||
├── transfer/ # Bounded context: Transfer
|
||||
│ ├── Transfer.java # Aggregate root
|
||||
│ ├── TransferId.java # Value object
|
||||
│ ├── TransferStatus.java # Status enum
|
||||
│ ├── TransferError.java # Errors
|
||||
│ ├── TransferRepository.java # Repository interface
|
||||
│ └── TransferService.java # Domain service (if needed)
|
||||
│
|
||||
├── shared/
|
||||
│ ├── result/
|
||||
│ │ ├── Result.java # Result<E, T> interface
|
||||
│ │ ├── Success.java # Success case
|
||||
│ │ └── Failure.java # Failure case
|
||||
│ └── DomainEvent.java # Base domain event
|
||||
```
|
||||
|
||||
### Application Layer
|
||||
|
||||
```
|
||||
src/main/java/com/example/application/
|
||||
├── account/
|
||||
│ ├── OpenAccountUseCase.java # One use case per file
|
||||
│ ├── DepositMoneyUseCase.java
|
||||
│ ├── WithdrawMoneyUseCase.java
|
||||
│ ├── GetAccountBalanceUseCase.java
|
||||
│ ├── dto/
|
||||
│ │ ├── OpenAccountRequest.java
|
||||
│ │ ├── OpenAccountResponse.java
|
||||
│ │ ├── DepositRequest.java
|
||||
│ │ └── DepositResponse.java
|
||||
│ └── AccountApplicationError.java # App-specific errors
|
||||
│
|
||||
├── transfer/
|
||||
│ ├── TransferMoneyUseCase.java
|
||||
│ ├── GetTransferStatusUseCase.java
|
||||
│ ├── dto/
|
||||
│ │ ├── TransferRequest.java
|
||||
│ │ └── TransferResponse.java
|
||||
│ └── TransferApplicationError.java
|
||||
│
|
||||
├── shared/
|
||||
│ ├── UseCase.java # Interface/base class for use cases
|
||||
│ └── UnitOfWork.java # Transaction management interface
|
||||
```
|
||||
|
||||
### Infrastructure Layer
|
||||
|
||||
```
|
||||
src/main/java/com/example/infrastructure/
|
||||
├── persistence/
|
||||
│ ├── account/
|
||||
│ │ ├── JdbcAccountRepository.java # Implements AccountRepository
|
||||
│ │ ├── AccountRowMapper.java # Database row mapping
|
||||
│ │ └── AccountQueries.java # SQL constants
|
||||
│ ├── transfer/
|
||||
│ │ ├── JdbcTransferRepository.java
|
||||
│ │ └── TransferRowMapper.java
|
||||
│ ├── connection/
|
||||
│ │ ├── ConnectionPool.java
|
||||
│ │ └── DataSourceFactory.java
|
||||
│ └── transaction/
|
||||
│ └── JdbcUnitOfWork.java # Transaction coordinator
|
||||
│
|
||||
├── http/
|
||||
│ ├── handler/
|
||||
│ │ ├── account/
|
||||
│ │ │ ├── OpenAccountHandler.java
|
||||
│ │ │ ├── WithdrawHandler.java
|
||||
│ │ │ └── GetBalanceHandler.java
|
||||
│ │ └── transfer/
|
||||
│ │ ├── TransferHandler.java
|
||||
│ │ └── GetTransferStatusHandler.java
|
||||
│ ├── router/
|
||||
│ │ └── ApiRouter.java # Route definition
|
||||
│ ├── response/
|
||||
│ │ ├── SuccessResponse.java
|
||||
│ │ └── ErrorResponse.java
|
||||
│ └── middleware/
|
||||
│ ├── ErrorHandlingMiddleware.java
|
||||
│ └── LoggingMiddleware.java
|
||||
│
|
||||
├── event/
|
||||
│ ├── DomainEventPublisherImpl.java # Publishes domain events
|
||||
│ ├── event-handlers/
|
||||
│ │ ├── AccountCreatedEventHandler.java
|
||||
│ │ └── TransferCompletedEventHandler.java
|
||||
│ └── EventDispatcher.java
|
||||
│
|
||||
├── config/
|
||||
│ └── AppConfiguration.java # Dependency injection setup
|
||||
│
|
||||
└── persistence/
|
||||
└── migrations/
|
||||
├── V001__CreateAccountsTable.sql
|
||||
└── V002__CreateTransfersTable.sql
|
||||
```
|
||||
|
||||
### Test Structure
|
||||
|
||||
```
|
||||
src/test/java/com/example/
|
||||
├── domain/
|
||||
│ ├── account/
|
||||
│ │ ├── AccountTest.java # Unit tests for Account
|
||||
│ │ ├── MoneyTest.java
|
||||
│ │ └── AccountRepositoryTest.java # Contract tests
|
||||
│ └── transfer/
|
||||
│ ├── TransferTest.java
|
||||
│ └── TransferRepositoryTest.java
|
||||
│
|
||||
├── application/
|
||||
│ ├── account/
|
||||
│ │ ├── OpenAccountUseCaseTest.java
|
||||
│ │ ├── WithdrawMoneyUseCaseTest.java
|
||||
│ │ └── fixtures/
|
||||
│ │ ├── AccountFixture.java # Test data builders
|
||||
│ │ └── MoneyFixture.java
|
||||
│ └── transfer/
|
||||
│ └── TransferMoneyUseCaseTest.java
|
||||
│
|
||||
├── infrastructure/
|
||||
│ ├── persistence/
|
||||
│ │ ├── JdbcAccountRepositoryTest.java # Integration tests
|
||||
│ │ └── JdbcTransferRepositoryTest.java
|
||||
│ └── http/
|
||||
│ └── OpenAccountHandlerTest.java
|
||||
│
|
||||
└── acceptance/
|
||||
└── OpenAccountAcceptanceTest.java # End-to-end tests
|
||||
```
|
||||
|
||||
## Three Organizational Approaches
|
||||
|
||||
### Approach 1: BC-First (Recommended for Most Projects)
|
||||
|
||||
Organize around Bounded Contexts:
|
||||
|
||||
```
|
||||
src/main/java/com/example/
|
||||
├── account/ # BC 1
|
||||
│ ├── domain/
|
||||
│ │ ├── Account.java
|
||||
│ │ ├── AccountError.java
|
||||
│ │ └── AccountRepository.java
|
||||
│ ├── application/
|
||||
│ │ ├── OpenAccountUseCase.java
|
||||
│ │ └── dto/
|
||||
│ └── infrastructure/
|
||||
│ ├── persistence/
|
||||
│ │ └── JdbcAccountRepository.java
|
||||
│ └── http/
|
||||
│ └── OpenAccountHandler.java
|
||||
│
|
||||
├── transfer/ # BC 2
|
||||
│ ├── domain/
|
||||
│ ├── application/
|
||||
│ └── infrastructure/
|
||||
│
|
||||
└── shared/ # Shared across BCs
|
||||
├── result/
|
||||
│ └── Result.java
|
||||
└── events/
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Clear BC boundaries
|
||||
- Easy to navigate between layers within a context
|
||||
- Natural place for context-specific configuration
|
||||
- Facilitates team ownership per BC
|
||||
|
||||
**Cons:**
|
||||
- Duplication across contexts
|
||||
- More code organization overhead
|
||||
|
||||
### Approach 2: Tech-First (Better for Microservices)
|
||||
|
||||
Organize by technical layer:
|
||||
|
||||
```
|
||||
src/main/java/com/example/
|
||||
├── domain/
|
||||
│ ├── account/
|
||||
│ │ ├── Account.java
|
||||
│ │ ├── AccountRepository.java
|
||||
│ │ └── AccountError.java
|
||||
│ └── transfer/
|
||||
│
|
||||
├── application/
|
||||
│ ├── account/
|
||||
│ │ ├── OpenAccountUseCase.java
|
||||
│ │ └── dto/
|
||||
│ └── transfer/
|
||||
│
|
||||
├── infrastructure/
|
||||
│ ├── persistence/
|
||||
│ │ ├── account/
|
||||
│ │ │ └── JdbcAccountRepository.java
|
||||
│ │ └── transfer/
|
||||
│ ├── http/
|
||||
│ │ ├── account/
|
||||
│ │ └── transfer/
|
||||
│ └── config/
|
||||
│
|
||||
└── shared/
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Clear layer separation
|
||||
- Easy to review layer architecture
|
||||
- Good for enforcing dependency rules
|
||||
|
||||
**Cons:**
|
||||
- Scattered BC concepts across files
|
||||
- Harder to find all code for one feature
|
||||
|
||||
### Approach 3: Hybrid (Best for Large Projects)
|
||||
|
||||
Combine both approaches strategically:
|
||||
|
||||
```
|
||||
src/main/java/com/example/
|
||||
├── domain/ # All domain objects, shared across project
|
||||
│ ├── account/
|
||||
│ ├── transfer/
|
||||
│ └── shared/
|
||||
│
|
||||
├── application/ # All application services
|
||||
│ ├── account/
|
||||
│ └── transfer/
|
||||
│
|
||||
├── infrastructure/ # Infrastructure organized by BC
|
||||
│ ├── account/
|
||||
│ │ ├── persistence/
|
||||
│ │ └── http/
|
||||
│ ├── transfer/
|
||||
│ ├── config/
|
||||
│ └── shared/
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Emphasizes domain independence
|
||||
- Clear infrastructure layer separation
|
||||
- Good for large teams
|
||||
|
||||
**Cons:**
|
||||
- Two different organizational styles
|
||||
- Requires discipline to maintain
|
||||
|
||||
## One Use Case Per File
|
||||
|
||||
### Recommended Structure
|
||||
|
||||
```
|
||||
src/main/java/com/example/application/account/
|
||||
├── OpenAccountUseCase.java
|
||||
├── DepositMoneyUseCase.java
|
||||
├── WithdrawMoneyUseCase.java
|
||||
├── GetAccountBalanceUseCase.java
|
||||
├── CloseAccountUseCase.java
|
||||
└── dto/
|
||||
├── OpenAccountRequest.java
|
||||
├── OpenAccountResponse.java
|
||||
├── DepositRequest.java
|
||||
└── DepositResponse.java
|
||||
```
|
||||
|
||||
### Use Case File Template
|
||||
|
||||
```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;
|
||||
|
||||
/**
|
||||
* OpenAccountUseCase - one file, one use case.
|
||||
*
|
||||
* Coordinates the opening of a new account:
|
||||
* 1. Create Account aggregate
|
||||
* 2. Persist via repository
|
||||
* 3. Publish domain events
|
||||
*/
|
||||
public class OpenAccountUseCase {
|
||||
private final AccountRepository accountRepository;
|
||||
private final AccountIdGenerator idGenerator;
|
||||
private final UnitOfWork unitOfWork;
|
||||
|
||||
public OpenAccountUseCase(
|
||||
AccountRepository accountRepository,
|
||||
AccountIdGenerator idGenerator,
|
||||
UnitOfWork unitOfWork
|
||||
) {
|
||||
this.accountRepository = accountRepository;
|
||||
this.idGenerator = idGenerator;
|
||||
this.unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the use case.
|
||||
*
|
||||
* @param request containing account opening parameters
|
||||
* @return success with account ID, or failure with reason
|
||||
*/
|
||||
public Result<OpenAccountError, OpenAccountResponse> execute(
|
||||
OpenAccountRequest request
|
||||
) {
|
||||
try {
|
||||
// Phase 1: Create aggregate
|
||||
AccountId accountId = idGenerator.generate();
|
||||
Result<AccountError, Account> accountResult = Account.create(
|
||||
accountId,
|
||||
request.initialBalance(),
|
||||
request.accountHolder()
|
||||
);
|
||||
|
||||
if (accountResult instanceof Failure f) {
|
||||
return failure(mapError(f.error()));
|
||||
}
|
||||
|
||||
Account account = ((Success<AccountError, Account>) accountResult).value();
|
||||
|
||||
// Phase 2: Persist
|
||||
return unitOfWork.withTransaction(() -> {
|
||||
accountRepository.save(account);
|
||||
|
||||
// Phase 3: Publish events
|
||||
account.publishedEvents().forEach(event ->
|
||||
eventPublisher.publish(event)
|
||||
);
|
||||
|
||||
return success(new OpenAccountResponse(
|
||||
account.id().value(),
|
||||
account.balance()
|
||||
));
|
||||
});
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Unexpected error opening account", e);
|
||||
return failure(new OpenAccountError.RepositoryError("Failed to save account"));
|
||||
}
|
||||
}
|
||||
|
||||
private OpenAccountError mapError(AccountError error) {
|
||||
return switch (error) {
|
||||
case InvalidAmountError e ->
|
||||
new OpenAccountError.InvalidInitialBalance(e.message());
|
||||
case InvalidAccountHolderError e ->
|
||||
new OpenAccountError.InvalidHolder(e.message());
|
||||
default ->
|
||||
new OpenAccountError.UnexpectedError(error.message());
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAccountRequest - input DTO.
|
||||
*/
|
||||
public record OpenAccountRequest(
|
||||
Money initialBalance,
|
||||
String accountHolder
|
||||
) {
|
||||
public OpenAccountRequest {
|
||||
if (initialBalance == null) {
|
||||
throw new IllegalArgumentException("Initial balance required");
|
||||
}
|
||||
if (accountHolder == null || accountHolder.isBlank()) {
|
||||
throw new IllegalArgumentException("Account holder name required");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAccountResponse - output DTO.
|
||||
*/
|
||||
public record OpenAccountResponse(
|
||||
String accountId,
|
||||
Money balance
|
||||
) {}
|
||||
|
||||
/**
|
||||
* OpenAccountError - use case specific errors.
|
||||
*/
|
||||
public sealed interface OpenAccountError permits
|
||||
InvalidInitialBalance,
|
||||
InvalidHolder,
|
||||
RepositoryError,
|
||||
UnexpectedError {
|
||||
String message();
|
||||
}
|
||||
|
||||
public record InvalidInitialBalance(String reason) implements OpenAccountError {
|
||||
@Override
|
||||
public String message() {
|
||||
return "Invalid initial balance: " + reason;
|
||||
}
|
||||
}
|
||||
|
||||
// ... other error implementations
|
||||
```
|
||||
|
||||
## Package Naming
|
||||
|
||||
```
|
||||
com.example # Root
|
||||
├── domain # Domain layer
|
||||
│ ├── account # BC 1 domain
|
||||
│ │ └── Account.java
|
||||
│ └── transfer # BC 2 domain
|
||||
│ └── Transfer.java
|
||||
├── application # Application layer
|
||||
│ ├── account # BC 1 use cases
|
||||
│ │ └── OpenAccountUseCase.java
|
||||
│ └── transfer # BC 2 use cases
|
||||
│ └── TransferMoneyUseCase.java
|
||||
├── infrastructure # Infrastructure layer
|
||||
│ ├── persistence # Persistence adapters
|
||||
│ │ ├── account # BC 1 persistence
|
||||
│ │ └── transfer # BC 2 persistence
|
||||
│ ├── http # HTTP adapters
|
||||
│ │ ├── account # BC 1 handlers
|
||||
│ │ └── transfer # BC 2 handlers
|
||||
│ └── config # Configuration
|
||||
│ └── AppConfiguration.java
|
||||
└── shared # Shared across layers
|
||||
├── result # Result<E, T>
|
||||
├── events # Domain events
|
||||
└── exceptions # Shared exceptions (use sparingly)
|
||||
```
|
||||
|
||||
## Example: Account BC Structure
|
||||
|
||||
Complete example of one bounded context:
|
||||
|
||||
```
|
||||
com/example/account/
|
||||
├── domain/
|
||||
│ ├── Account.java # Aggregate root
|
||||
│ ├── AccountId.java # ID value object
|
||||
│ ├── AccountStatus.java # Status value object
|
||||
│ ├── AccountError.java # Sealed error interface
|
||||
│ ├── AccountRepository.java # Repository interface
|
||||
│ ├── DomainEvents.java # Domain events (AccountCreated, etc.)
|
||||
│ └── AccountIdGenerator.java # Generator interface
|
||||
│
|
||||
├── application/
|
||||
│ ├── OpenAccountUseCase.java
|
||||
│ ├── DepositMoneyUseCase.java
|
||||
│ ├── WithdrawMoneyUseCase.java
|
||||
│ ├── GetAccountBalanceUseCase.java
|
||||
│ ├── ApplicationError.java # App-level errors
|
||||
│ ├── dto/
|
||||
│ │ ├── OpenAccountRequest.java
|
||||
│ │ ├── OpenAccountResponse.java
|
||||
│ │ ├── DepositRequest.java
|
||||
│ │ └── DepositResponse.java
|
||||
│ └── fixtures/ (test directory)
|
||||
│ └── AccountFixture.java
|
||||
│
|
||||
└── infrastructure/
|
||||
├── persistence/
|
||||
│ ├── JdbcAccountRepository.java
|
||||
│ ├── AccountRowMapper.java
|
||||
│ └── AccountQueries.java
|
||||
├── http/
|
||||
│ ├── OpenAccountHandler.java
|
||||
│ ├── DepositHandler.java
|
||||
│ ├── WithdrawHandler.java
|
||||
│ └── GetBalanceHandler.java
|
||||
└── events/
|
||||
└── AccountEventHandlers.java
|
||||
```
|
||||
|
||||
## Configuration Example
|
||||
|
||||
```java
|
||||
package com.example.infrastructure.config;
|
||||
|
||||
public class AppConfiguration {
|
||||
private final DataSource dataSource;
|
||||
private final DomainEventPublisher eventPublisher;
|
||||
|
||||
public AppConfiguration() {
|
||||
this.dataSource = createDataSource();
|
||||
this.eventPublisher = createEventPublisher();
|
||||
}
|
||||
|
||||
// Account BC dependencies
|
||||
public AccountRepository accountRepository() {
|
||||
return new JdbcAccountRepository(dataSource);
|
||||
}
|
||||
|
||||
public AccountIdGenerator accountIdGenerator() {
|
||||
return new UuidAccountIdGenerator();
|
||||
}
|
||||
|
||||
public OpenAccountUseCase openAccountUseCase() {
|
||||
return new OpenAccountUseCase(
|
||||
accountRepository(),
|
||||
accountIdGenerator(),
|
||||
unitOfWork()
|
||||
);
|
||||
}
|
||||
|
||||
// Transfer BC dependencies
|
||||
public TransferRepository transferRepository() {
|
||||
return new JdbcTransferRepository(dataSource);
|
||||
}
|
||||
|
||||
public TransferMoneyUseCase transferMoneyUseCase() {
|
||||
return new TransferMoneyUseCase(
|
||||
accountRepository(),
|
||||
transferRepository(),
|
||||
unitOfWork()
|
||||
);
|
||||
}
|
||||
|
||||
// Shared infrastructure
|
||||
public UnitOfWork unitOfWork() {
|
||||
return new JdbcUnitOfWork(dataSource);
|
||||
}
|
||||
|
||||
public DomainEventPublisher eventPublisher() {
|
||||
return eventPublisher;
|
||||
}
|
||||
|
||||
private DataSource createDataSource() {
|
||||
// Database pool configuration
|
||||
return new HikariDataSource();
|
||||
}
|
||||
|
||||
private DomainEventPublisher createEventPublisher() {
|
||||
return new SimpleEventPublisher();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Organize by BC first** when starting a project
|
||||
2. **One use case per file** in application layer
|
||||
3. **Keep test directory structure** parallel to main
|
||||
4. **Place DTOs near their use cases** (not in separate folder)
|
||||
5. **Shared code in `shared` package** (Result, base classes)
|
||||
6. **Database migrations** in dedicated folder
|
||||
7. **Configuration at root** of infrastructure layer
|
||||
8. **HTTP handlers** group by BC
|
||||
9. **Repository implementations** group by BC
|
||||
10. **No circular package dependencies** - enforce with checkstyle
|
||||
767
bin/.claude/skills/ddd-model/languages/java/style-guide.md
Normal file
767
bin/.claude/skills/ddd-model/languages/java/style-guide.md
Normal file
|
|
@ -0,0 +1,767 @@
|
|||
# Java 21+ Style Guide for DDD
|
||||
|
||||
This guide covers Java conventions and modern language features for Domain-Driven Design implementations.
|
||||
|
||||
## Records vs Classes for Value Objects
|
||||
|
||||
### Use Records for Value Objects
|
||||
|
||||
Records are perfect for immutable value objects with validation:
|
||||
|
||||
```java
|
||||
/**
|
||||
* Use record for simple value object.
|
||||
* Automatically generates equals, hashCode, toString.
|
||||
*/
|
||||
public record Money(
|
||||
java.math.BigDecimal amount,
|
||||
String currency
|
||||
) {
|
||||
/**
|
||||
* Compact constructor performs validation.
|
||||
*/
|
||||
public Money {
|
||||
if (amount == null) {
|
||||
throw new IllegalArgumentException("Amount cannot be null");
|
||||
}
|
||||
if (currency == null || currency.isBlank()) {
|
||||
throw new IllegalArgumentException("Currency cannot be empty");
|
||||
}
|
||||
if (amount.signum() < 0) {
|
||||
throw new IllegalArgumentException("Amount cannot be negative");
|
||||
}
|
||||
// Canonicalize to 2 decimal places
|
||||
amount = amount.setScale(2, java.math.RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Static factory for common currencies.
|
||||
*/
|
||||
public static Money usd(long cents) {
|
||||
return new Money(
|
||||
java.math.BigDecimal.valueOf(cents).scaleByPowerOfTen(-2),
|
||||
"USD"
|
||||
);
|
||||
}
|
||||
|
||||
public static Money eur(long cents) {
|
||||
return new Money(
|
||||
java.math.BigDecimal.valueOf(cents).scaleByPowerOfTen(-2),
|
||||
"EUR"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* MustXxx variant for tests (panics on error).
|
||||
*/
|
||||
public static Money mustUsd(String amount) {
|
||||
try {
|
||||
return usd(Long.parseLong(amount));
|
||||
} catch (NumberFormatException e) {
|
||||
throw new AssertionError("Invalid money for test: " + amount, e);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isNegativeOrZero() {
|
||||
return amount.signum() <= 0;
|
||||
}
|
||||
|
||||
public boolean isPositive() {
|
||||
return amount.signum() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain operation: add money (must be same currency).
|
||||
*/
|
||||
public Result<MoneyError, Money> add(Money other) {
|
||||
if (!currency.equals(other.currency)) {
|
||||
return Result.failure(
|
||||
new CurrencyMismatchError(currency, other.currency)
|
||||
);
|
||||
}
|
||||
return Result.success(
|
||||
new Money(amount.add(other.amount), currency)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain operation: multiply by factor.
|
||||
*/
|
||||
public Money multiply(int factor) {
|
||||
if (factor < 0) {
|
||||
throw new IllegalArgumentException("Factor cannot be negative");
|
||||
}
|
||||
return new Money(
|
||||
amount.multiply(java.math.BigDecimal.valueOf(factor)),
|
||||
currency
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatted display.
|
||||
*/
|
||||
public String formatted() {
|
||||
return String.format("%s %s", currency, amount);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed interface MoneyError permits CurrencyMismatchError {
|
||||
String message();
|
||||
}
|
||||
|
||||
public record CurrencyMismatchError(
|
||||
String from,
|
||||
String to
|
||||
) implements MoneyError {
|
||||
@Override
|
||||
public String message() {
|
||||
return String.format("Currency mismatch: %s vs %s", from, to);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Use Classes for Aggregates/Entities
|
||||
|
||||
Keep mutable aggregates and entities as classes for encapsulation:
|
||||
|
||||
```java
|
||||
/**
|
||||
* Aggregate root - use class for mutability.
|
||||
* Package-private constructor forces use of factory.
|
||||
*/
|
||||
public class Account {
|
||||
private final AccountId id;
|
||||
private Money balance;
|
||||
private AccountStatus status;
|
||||
private final java.time.Instant createdAt;
|
||||
private java.time.Instant updatedAt;
|
||||
|
||||
/**
|
||||
* Private constructor - use factory method.
|
||||
*/
|
||||
private Account(
|
||||
AccountId id,
|
||||
Money initialBalance,
|
||||
AccountStatus status,
|
||||
java.time.Instant createdAt
|
||||
) {
|
||||
this.id = id;
|
||||
this.balance = initialBalance;
|
||||
this.status = status;
|
||||
this.createdAt = createdAt;
|
||||
this.updatedAt = createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method in aggregate.
|
||||
*/
|
||||
public static Result<AccountError, Account> create(
|
||||
AccountId id,
|
||||
Money initialBalance
|
||||
) {
|
||||
if (initialBalance.isNegative()) {
|
||||
return Result.failure(
|
||||
new InvalidBalanceError(initialBalance)
|
||||
);
|
||||
}
|
||||
|
||||
Account account = new Account(
|
||||
id,
|
||||
initialBalance,
|
||||
AccountStatus.ACTIVE,
|
||||
java.time.Instant.now()
|
||||
);
|
||||
|
||||
return Result.success(account);
|
||||
}
|
||||
|
||||
// ✅ Getters with accessor method names (not get prefix)
|
||||
public AccountId id() { return id; }
|
||||
public Money balance() { return balance; }
|
||||
public AccountStatus status() { return status; }
|
||||
|
||||
/**
|
||||
* Invariant: Cannot withdraw from closed account
|
||||
* Invariant: Cannot withdraw more than balance
|
||||
*/
|
||||
public Result<AccountError, Void> withdraw(Money amount) {
|
||||
// Guard: Check status
|
||||
if (status == AccountStatus.CLOSED) {
|
||||
return Result.failure(new AccountClosedError(id));
|
||||
}
|
||||
|
||||
// Guard: Check amount
|
||||
if (amount.isNegativeOrZero()) {
|
||||
return Result.failure(new InvalidAmountError(amount));
|
||||
}
|
||||
|
||||
// Guard: Check balance
|
||||
if (balance.amount().compareTo(amount.amount()) < 0) {
|
||||
return Result.failure(
|
||||
new InsufficientFundsError(amount, balance)
|
||||
);
|
||||
}
|
||||
|
||||
// Execute state change
|
||||
this.balance = new Money(
|
||||
balance.amount().subtract(amount.amount()),
|
||||
balance.currency()
|
||||
);
|
||||
this.updatedAt = java.time.Instant.now();
|
||||
|
||||
return Result.success(null);
|
||||
}
|
||||
|
||||
// Equality based on ID (entity identity)
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof Account account)) return false;
|
||||
return Objects.equals(id, account.id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Account{" +
|
||||
"id=" + id +
|
||||
", balance=" + balance +
|
||||
", status=" + status +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Sealed Interfaces for Type Hierarchies
|
||||
|
||||
Use sealed interfaces for error types and domain concepts:
|
||||
|
||||
```java
|
||||
/**
|
||||
* Sealed interface - only permitted implementations.
|
||||
*/
|
||||
public sealed interface AccountError permits
|
||||
AccountClosedError,
|
||||
InsufficientFundsError,
|
||||
InvalidAmountError,
|
||||
AccountNotFoundError {
|
||||
String message();
|
||||
}
|
||||
|
||||
/**
|
||||
* Only this class can implement AccountError.
|
||||
*/
|
||||
public record AccountClosedError(AccountId id) implements AccountError {
|
||||
@Override
|
||||
public String message() {
|
||||
return "Account closed: " + id;
|
||||
}
|
||||
}
|
||||
|
||||
// More implementations...
|
||||
|
||||
/**
|
||||
* Sealed with final subclasses.
|
||||
*/
|
||||
public sealed interface OrderStatus permits
|
||||
DraftStatus,
|
||||
PlacedStatus,
|
||||
ShippedStatus,
|
||||
DeliveredStatus,
|
||||
CancelledStatus {
|
||||
String description();
|
||||
}
|
||||
|
||||
public final record DraftStatus() implements OrderStatus {
|
||||
@Override
|
||||
public String description() {
|
||||
return "Draft - being composed";
|
||||
}
|
||||
}
|
||||
|
||||
public final record PlacedStatus(java.time.Instant placedAt) implements OrderStatus {
|
||||
@Override
|
||||
public String description() {
|
||||
return "Placed on " + placedAt;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern Matching
|
||||
|
||||
### Instance Checking
|
||||
|
||||
```java
|
||||
// ❌ Old style
|
||||
public void process(OrderError error) {
|
||||
if (error instanceof InvalidAmountError) {
|
||||
InvalidAmountError e = (InvalidAmountError) error;
|
||||
System.out.println(e.amount());
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ New style - pattern matching
|
||||
public void process(OrderError error) {
|
||||
if (error instanceof InvalidAmountError e) {
|
||||
System.out.println(e.amount());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Switch with Pattern Matching
|
||||
|
||||
```java
|
||||
public String formatError(OrderError error) {
|
||||
return switch (error) {
|
||||
case InvalidAmountError e ->
|
||||
"Invalid amount: " + e.amount();
|
||||
case LineItemNotFoundError e ->
|
||||
"Line item not found: " + e.lineItemId();
|
||||
case OrderAlreadyPlacedError e ->
|
||||
"Order already placed: " + e.orderNumber();
|
||||
case OrderCancelledError e ->
|
||||
"Order cancelled: " + e.orderNumber();
|
||||
case EmptyOrderError ->
|
||||
"Cannot place order with no items";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pattern matching with Result.
|
||||
*/
|
||||
public void handleResult(Result<OrderError, Order> result) {
|
||||
switch (result) {
|
||||
case Success(Order order) -> {
|
||||
System.out.println("Order created: " + order.orderNumber());
|
||||
}
|
||||
case Failure(InvalidAmountError e) -> {
|
||||
logger.warn("Invalid amount in order: " + e.amount());
|
||||
}
|
||||
case Failure(EmptyOrderError) -> {
|
||||
logger.warn("User attempted to place empty order");
|
||||
}
|
||||
case Failure(OrderError e) -> {
|
||||
logger.error("Unexpected order error: " + e.message());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Record Pattern Matching
|
||||
|
||||
```java
|
||||
// Record patterns (Java 21+)
|
||||
public void processTransfer(Object obj) {
|
||||
if (obj instanceof Transfer(
|
||||
Money amount,
|
||||
AccountId from,
|
||||
AccountId to
|
||||
)) {
|
||||
System.out.println("Transfer " + amount + " from " + from + " to " + to);
|
||||
}
|
||||
}
|
||||
|
||||
// In switch expressions
|
||||
public String describeTransfer(Object obj) {
|
||||
return switch (obj) {
|
||||
case Transfer(Money amount, AccountId from, AccountId to) ->
|
||||
String.format("Transfer %s from %s to %s", amount, from, to);
|
||||
case Withdrawal(Money amount, AccountId account) ->
|
||||
String.format("Withdrawal %s from %s", amount, account);
|
||||
default -> "Unknown operation";
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Static Imports
|
||||
|
||||
Use static imports for readability:
|
||||
|
||||
```java
|
||||
// ❌ Verbose without static import
|
||||
Result<AccountError, Account> account = Result.success(newAccount);
|
||||
|
||||
// ✅ With static imports
|
||||
import static com.example.shared.result.Result.success;
|
||||
import static com.example.shared.result.Result.failure;
|
||||
|
||||
Result<AccountError, Account> account = success(newAccount);
|
||||
Result<AccountError, Void> error = failure(new AccountClosedError(id));
|
||||
```
|
||||
|
||||
### Import Aliases
|
||||
|
||||
```java
|
||||
// For classes with same name in different packages
|
||||
import com.example.domain.account.AccountError;
|
||||
import com.example.application.account.AccountError as AppAccountError;
|
||||
```
|
||||
|
||||
## Private Constructors + Public Static Factories
|
||||
|
||||
### Aggregate Pattern
|
||||
|
||||
```java
|
||||
/**
|
||||
* Aggregates use private constructor + public factory.
|
||||
* Ensures validation always happens.
|
||||
*/
|
||||
public class Order {
|
||||
private final OrderNumber number;
|
||||
private final CustomerId customerId;
|
||||
private final List<OrderLine> lineItems;
|
||||
private OrderStatus status;
|
||||
|
||||
/**
|
||||
* Private - access only via factory.
|
||||
*/
|
||||
private Order(
|
||||
OrderNumber number,
|
||||
CustomerId customerId,
|
||||
OrderStatus status
|
||||
) {
|
||||
this.number = number;
|
||||
this.customerId = customerId;
|
||||
this.lineItems = new ArrayList<>();
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new order (in DRAFT status).
|
||||
*/
|
||||
public static Result<OrderError, Order> create(
|
||||
OrderNumber number,
|
||||
CustomerId customerId
|
||||
) {
|
||||
// Validation before construction
|
||||
if (number == null) {
|
||||
return Result.failure(new InvalidOrderNumberError());
|
||||
}
|
||||
if (customerId == null) {
|
||||
return Result.failure(new InvalidCustomerIdError());
|
||||
}
|
||||
|
||||
Order order = new Order(number, customerId, OrderStatus.DRAFT);
|
||||
return Result.success(order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct from database (internal use).
|
||||
* Skips validation since data is from trusted source.
|
||||
*/
|
||||
static Order reconstruct(
|
||||
OrderNumber number,
|
||||
CustomerId customerId,
|
||||
List<OrderLine> lineItems,
|
||||
OrderStatus status
|
||||
) {
|
||||
Order order = new Order(number, customerId, status);
|
||||
order.lineItems.addAll(lineItems);
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* For tests - panics on error.
|
||||
*/
|
||||
public static Order mustCreate(String number, String customerId) {
|
||||
return create(
|
||||
new OrderNumber(number),
|
||||
new CustomerId(customerId)
|
||||
).orElseThrow(e ->
|
||||
new AssertionError("Failed to create test order: " + e.message())
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Value Object Pattern
|
||||
|
||||
```java
|
||||
/**
|
||||
* Value object - sealed to prevent subclassing.
|
||||
*/
|
||||
public final record Money(
|
||||
java.math.BigDecimal amount,
|
||||
String currency
|
||||
) {
|
||||
/**
|
||||
* Compact constructor validates.
|
||||
*/
|
||||
public Money {
|
||||
if (amount == null || currency == null) {
|
||||
throw new IllegalArgumentException("Cannot be null");
|
||||
}
|
||||
if (amount.signum() < 0) {
|
||||
throw new IllegalArgumentException("Amount cannot be negative");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for USD (common case).
|
||||
*/
|
||||
public static Money usd(long cents) {
|
||||
return new Money(
|
||||
java.math.BigDecimal.valueOf(cents, 2),
|
||||
"USD"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for arbitrary currency.
|
||||
*/
|
||||
public static Result<MoneyError, Money> of(String amount, String currency) {
|
||||
try {
|
||||
return Result.success(
|
||||
new Money(new java.math.BigDecimal(amount), currency)
|
||||
);
|
||||
} catch (NumberFormatException e) {
|
||||
return Result.failure(new InvalidMoneyFormatError(amount));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Must-variant for tests.
|
||||
*/
|
||||
public static Money mustUsd(long cents) {
|
||||
return usd(cents);
|
||||
}
|
||||
|
||||
public static Money mustOf(String amount, String currency) {
|
||||
return of(amount, currency)
|
||||
.orElseThrow(e ->
|
||||
new AssertionError("Test money construction failed: " + e.message())
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Package-Private for Entities
|
||||
|
||||
Hide child entities from outside aggregates:
|
||||
|
||||
```java
|
||||
package com.example.domain.order;
|
||||
|
||||
/**
|
||||
* Aggregate root - public.
|
||||
*/
|
||||
public class Order {
|
||||
private final List<OrderLine> lineItems;
|
||||
|
||||
/**
|
||||
* Public factory - creates Order and its children.
|
||||
*/
|
||||
public static Result<OrderError, Order> create(...) {
|
||||
return Result.success(new Order(...));
|
||||
}
|
||||
|
||||
/**
|
||||
* Package-private - only Order and other aggregate classes access this.
|
||||
*/
|
||||
public Result<OrderError, Void> addLineItem(...) {
|
||||
// Internal validation
|
||||
OrderLine line = new OrderLine(...); // Package-private constructor
|
||||
lineItems.add(line);
|
||||
return Result.success(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity - package-private constructor.
|
||||
* Only Order aggregate can create it.
|
||||
*/
|
||||
public class OrderLine {
|
||||
private final OrderLineId id;
|
||||
private final ProductId productId;
|
||||
private int quantity;
|
||||
|
||||
/**
|
||||
* Package-private - only Order can use.
|
||||
*/
|
||||
OrderLine(
|
||||
OrderLineId id,
|
||||
ProductId productId,
|
||||
String name,
|
||||
Money unitPrice,
|
||||
int quantity
|
||||
) {
|
||||
this.id = id;
|
||||
this.productId = productId;
|
||||
this.quantity = quantity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Package-private factory - used by Order.
|
||||
*/
|
||||
static OrderLine create(
|
||||
OrderLineId id,
|
||||
ProductId productId,
|
||||
String name,
|
||||
Money unitPrice,
|
||||
int quantity
|
||||
) {
|
||||
if (quantity <= 0) {
|
||||
throw new IllegalArgumentException("Quantity must be positive");
|
||||
}
|
||||
return new OrderLine(id, productId, name, unitPrice, quantity);
|
||||
}
|
||||
|
||||
// ✅ Package-private accessor (no public getter for modification)
|
||||
int getQuantity() { return quantity; }
|
||||
|
||||
/**
|
||||
* Package-private mutation - only Order calls this.
|
||||
*/
|
||||
void updateQuantity(int newQuantity) {
|
||||
if (newQuantity <= 0) {
|
||||
throw new IllegalArgumentException("Quantity must be positive");
|
||||
}
|
||||
this.quantity = newQuantity;
|
||||
}
|
||||
}
|
||||
|
||||
// Outside the package - cannot access OrderLine directly
|
||||
Order order = Order.create(...).success();
|
||||
// ❌ OrderLine line = new OrderLine(...); // Compile error
|
||||
// ❌ order.lineItems().get(0); // No public getter
|
||||
|
||||
// ✅ Access through Order aggregate only
|
||||
order.addLineItem(...);
|
||||
order.removeLineItem(...);
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
| Element | Convention | Example |
|
||||
|---------|-----------|---------|
|
||||
| Class | PascalCase | `Account`, `Order`, `Transfer` |
|
||||
| Record | PascalCase | `Money`, `OrderNumber`, `CustomerId` |
|
||||
| Interface | PascalCase | `Repository`, `AccountError`, `OrderStatus` |
|
||||
| Variable | camelCase | `accountId`, `initialBalance`, `orderNumber` |
|
||||
| Constant | UPPER_SNAKE_CASE | `MAX_AMOUNT`, `DEFAULT_CURRENCY` |
|
||||
| Method | camelCase (no get/set prefix) | `balance()`, `withdraw()`, `transfer()` |
|
||||
| Enum | PascalCase values | `ACTIVE`, `DRAFT`, `PLACED` |
|
||||
|
||||
## Accessor Methods (Property-Style)
|
||||
|
||||
```java
|
||||
// ✅ Preferred in DDD - property-style accessors
|
||||
public class Account {
|
||||
private Money balance;
|
||||
|
||||
public Money balance() { // Property name, not getBalance()
|
||||
return balance;
|
||||
}
|
||||
|
||||
public AccountId id() { // Not getId()
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ Avoid getter/setter naming in domain
|
||||
public class Account {
|
||||
public Money getBalance() { ... } // Too verbose for domain
|
||||
public void setBalance(Money m) { ... } // Never use setters
|
||||
}
|
||||
```
|
||||
|
||||
## Immutability
|
||||
|
||||
```java
|
||||
// ✅ Immutable record
|
||||
public record Money(
|
||||
java.math.BigDecimal amount,
|
||||
String currency
|
||||
) {
|
||||
// No setters, fields are final automatically
|
||||
}
|
||||
|
||||
// ✅ Defensive copy for mutable collection
|
||||
public class Order {
|
||||
private List<OrderLine> lineItems;
|
||||
|
||||
public List<OrderLine> lineItems() {
|
||||
return List.copyOf(lineItems); // Returns unmodifiable copy
|
||||
}
|
||||
|
||||
public void addLineItem(OrderLine line) {
|
||||
lineItems.add(line); // Internal modification only
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ Avoid direct exposure of mutable collections
|
||||
public class Order {
|
||||
public List<OrderLine> getLineItems() { // Caller could modify!
|
||||
return lineItems;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
```java
|
||||
/**
|
||||
* Order aggregate root.
|
||||
*
|
||||
* Represents a customer's order with multiple line items.
|
||||
* State transitions: DRAFT -> PLACED -> SHIPPED -> DELIVERED
|
||||
* Or: DRAFT/PLACED -> CANCELLED
|
||||
*
|
||||
* Invariant: Cannot modify order after PLACED
|
||||
* Invariant: Cannot place order with zero line items
|
||||
* Invariant: Order total = sum of line item totals
|
||||
*
|
||||
* @see OrderLine
|
||||
* @see OrderRepository
|
||||
*/
|
||||
public class Order {
|
||||
|
||||
/**
|
||||
* Creates new order in DRAFT status.
|
||||
*
|
||||
* @param number unique order identifier
|
||||
* @param customerId customer who placed the order
|
||||
* @return new Order wrapped in Result
|
||||
* @throws nothing - errors are in Result type
|
||||
*/
|
||||
public static Result<OrderError, Order> create(
|
||||
OrderNumber number,
|
||||
CustomerId customerId
|
||||
) {
|
||||
...
|
||||
}
|
||||
|
||||
/**
|
||||
* Places the order (transitions DRAFT -> PLACED).
|
||||
*
|
||||
* Invariant: Can only place DRAFT orders
|
||||
* Invariant: Order must have at least one line item
|
||||
*
|
||||
* @return success if placed, failure with reason if not
|
||||
*/
|
||||
public Result<OrderError, Void> place() {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices Summary
|
||||
|
||||
1. **Use records** for value objects, IDs, errors
|
||||
2. **Use classes** for aggregates and entities
|
||||
3. **Private constructors** on aggregates, **public factories** for validation
|
||||
4. **Sealed interfaces** for closed type hierarchies (errors, statuses)
|
||||
5. **Pattern matching** instead of instanceof + casting
|
||||
6. **Static imports** for Result.success/failure and common factories
|
||||
7. **Property-style accessors** (method name = property name)
|
||||
8. **No setters** in domain objects (return Result instead)
|
||||
9. **Defensive copies** for mutable collections
|
||||
10. **Package-private** for child entities (enforce aggregate boundaries)
|
||||
11. **Final records** by default
|
||||
12. **Compact constructors** for validation
|
||||
13. **Document invariants** in Javadoc
|
||||
14. **Use Result<E, T>** instead of throwing exceptions
|
||||
|
|
@ -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()`
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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