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

20 KiB

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

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:

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

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

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

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

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

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

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

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

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

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

// ❌ 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

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

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

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

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

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

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");
    }
}