mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 10:29:35 +01:00
20 KiB
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
- Use sealed interfaces for all domain/application errors
- Use records for error data classes (immutable, concise)
- Add
message()method to every error type - Transform errors at boundaries (domain → application → infrastructure)
- Log original exceptions before transformation
- Use static imports for Result.success/failure
- Pattern match instead of instanceof checks
- Avoid null returns - use Result.failure instead
- Document invariants that cause errors
- 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");
}
}