mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 19:30:16 +01:00
24 KiB
24 KiB
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
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
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
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
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)
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
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
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
- One Use Case Per File: Single responsibility, easy to find and test
- Constructor Injection: Dependencies passed in, not created
- Result Returns: All methods return
Result<Error, Response> - Error Mapping: Domain errors → Application errors at boundary
- Orchestration: Coordinates domain objects and repositories
- Transaction Boundaries: Uses UnitOfWork for atomicity
- Event Publishing: Raises domain events for subscribers
- Logging Strategy: WARN for business errors, ERROR for technical
- Input Validation: Validate DTOs before using
- 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