1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 19:40:18 +01:00
effigenix/bin/.claude/skills/ddd-model/languages/java/templates/UseCase.java.md
2026-02-18 23:25:12 +01:00

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

  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