# 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 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 Error type (must be sealed interface or final class) * @param Success value type */ public sealed interface Result permits Failure, Success { /** * Applies function if result is success. */ Result map(java.util.function.Function fn); /** * Chains Result-returning operations. */ Result flatMap(java.util.function.Function> fn); /** * Applies error function if result is failure. */ Result mapError(java.util.function.Function fn); /** * Pattern matching on Result. */ U match( java.util.function.Function onError, java.util.function.Function onSuccess ); /** * Returns value or throws if error. */ T orElseThrow(java.util.function.Function exceptionFn); /** * Returns value or default if error. */ T orElse(T defaultValue); /** * Success result (right side). */ static Result success(T value) { return new Success<>(value); } /** * Failure result (left side). */ static Result failure(E error) { return new Failure<>(error); } /** * Alias for success(). */ static Result ok(T value) { return success(value); } /** * Alias for failure(). */ static Result err(E error) { return failure(error); } } /** * Success case - carries the successful value. */ final class Success implements Result { private final T value; Success(T value) { this.value = value; } @Override public Result map(java.util.function.Function fn) { return new Success<>(fn.apply(value)); } @Override public Result flatMap(java.util.function.Function> fn) { return fn.apply(value); } @Override public Result mapError(java.util.function.Function fn) { return this; } @Override public U match( java.util.function.Function onError, java.util.function.Function onSuccess ) { return onSuccess.apply(value); } @Override public T orElseThrow(java.util.function.Function 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 implements Result { private final E error; Failure(E error) { this.error = error; } @Override public Result map(java.util.function.Function fn) { return new Failure<>(error); } @Override public Result flatMap(java.util.function.Function> fn) { return new Failure<>(error); } @Override public Result mapError(java.util.function.Function fn) { return new Failure<>(fn.apply(error)); } @Override public U match( java.util.function.Function onError, java.util.function.Function onSuccess ) { return onError.apply(error); } @Override public T orElseThrow(java.util.function.Function 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 Result ok(T value) { return Result.success(value); } public static Result fail(E error) { return Result.failure(error); } } // Usage with static import import static com.example.shared.result.Results.*; Result result = ok(account); Result 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 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 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 withdrawn = fromAccount.withdraw(amount); if (withdrawn instanceof Failure failure) { return Result.failure(new WithdrawalFailed(failure.error().message())); } Result 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 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 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 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 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 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 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 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 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 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"); } } ```