# 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` 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 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 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) (Object) accountResult).value(); // Phase 3: Execute domain logic Result 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 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 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 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 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 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 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 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 execute( GetBalanceRequest request ) { Result result = accountRepository.findById( request.accountId() ); if (result instanceof Failure f) { return failure(new AccountNotFoundError(request.accountId().value())); } Account account = ((Success) (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 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 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 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. */ Result withTransaction( java.util.function.Supplier> 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 Result withTransaction( java.util.function.Supplier> work ) { Connection conn = null; try { conn = dataSource.getConnection(); conn.setAutoCommit(false); // Execute work Result 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` 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