Managing Business Errors in Java 21 and Spring Without Using Exceptions

I’ve been trying to get engineers to rethink how they use exceptions, urging folks to ditch the habit of using unchecked exceptions for business errors handling.

The reason things are the way they are now is because, years back, people didn’t really get the difference between checked and unchecked exceptions. Besides that, back then, the programming languages didn’t have the cool features they own today. The frameworks were a contributor too, providing fancy tools for dealing with exceptions without setting clear boundaries on how they shall be used.

Reiterating the obvious, unchecked exceptions are situations from which the system cannot recover. They are caused by programming errors and omissions or by unexpected events. Division by zero in one example. As it is impossible to perform, one might need to ensure the number is not zero before attempting the division. Another common example nowadays is the network connectivity – if the network is down, there’s not much to do about it. But that doesn’t engineers should completely ignore such situations. In the case of network errors, a resilient system could probably implement a retry mechanism.

Manual error handling is not always pretty and requires additional code, but probably the most important gain is the fact the software is more predictable. On top of that, it saves hours of debugging and produces a more correct and understandable code.

The latest Java releases pumped up the language with switch expressions, sealed classes and interfaces and exhaustiveness over the sealed types.

The following oversimplified example is a Spring Boot Web application written in Java 21 which has an endpoint to initiate bank transactions. A successful transaction can happen only if 1) the amount is positive and 2) there are enough funds and 3) there’s no other active transaction. Those are the business rules. If any of those rules are not met, the system should react in an expected manner.

With the provided requirements we can create a data model to reflect the result.

The result can be either a Success or an Error. Both are records and implement sealed interface InitiateTransactionResult

Each business error is represented by a record: InvalidAmount, InsufficientFunds and ExistingActiveTransaction. To benefit from exhaustiveness checks, the error records inherit the sealed interface Error.

@Service
public class TransactionService {
    public InitiateTransactionResult initiateTransaction(int amount) {
        if (amount <= 0) {
            return new InvalidAmount(amount);
        } else if (amount > 100.0) {
            return new InsufficientFunds();
        } else if (Math.random() < 0.2) { // Simulate a check to see if there's an ongoing transaction.
            return new ExistingActiveTransaction();
        }
        
        // Save transaction.

        return new Success(UUID.randomUUID());
    }

    public sealed interface InitiateTransactionResult
            permits InitiateTransactionResult.Error, Success {
        record Success(UUID transactionId) implements InitiateTransactionResult { }
        sealed interface Error
                extends InitiateTransactionResult
                permits
                ExistingActiveTransaction,
                InsufficientFunds,
                InvalidAmount {
            record InvalidAmount(int amount) implements Error { }
            record InsufficientFunds() implements Error { }
            record ExistingActiveTransaction() implements Error { }
        }
    }
}

Here’s comes the fun part – the error handling. Because we used sealed types, the IDEs can generate the boilerplate code.

The below sample code illustrates how to return different HTTP status codes based on the result.

@RestController
@RequestMapping("/transactions")
public class TransactionResource {
    private final TransactionService transactionService;

    public TransactionResource(TransactionService transactionService) {
        this.transactionService = transactionService;
    }

    @PostMapping
    public ResponseEntity<String> initiateTransaction(@RequestBody InitiateTransactionInput input) {
        var result = transactionService.initiateTransaction(input.amount);

        return switch (result) {
            case Error error -> switch (error) {
                case InsufficientFunds() -> status(HttpStatus.CONFLICT).body("Insufficient funds.");
                case InvalidAmount(int amount) -> badRequest().body("Invalid amount " + amount);
                case ExistingActiveTransaction() -> status(HttpStatus.CONFLICT).body("Another transaction in progress.");
            };
            case Success(UUID transactionId) -> status(HttpStatus.CREATED).body(transactionId.toString());
        };
    }

    public record InitiateTransactionInput(int amount) { }
}

Imagine a situation where we have to create new transactions based on events received from a messaging platform. This translates to a call to our initiateTransaction method. If the logic were constructed using unchecked exceptions, users of the API would struggle to identify specific problems that might occur. For instance, we might want the system to retry processing the message later if another transaction is active. By adopting the presented approach, the issue is addressed because the caller is always compelled to handle the result. This is the essence of a predictable system.

Things could be simplified by using an Either data type. It avoid the need of having an additional hierarchy level for each result. You might already have one in your project (vavr library).

@Service
public class TransactionService {
    public Either<InitiateTransactionError, UUID> initiateTransaction(int amount) {
        if (amount <= 0) {
            return left(new InvalidAmount(amount));
        } else if (amount > 100.0) {
            return left(new InsufficientFunds());
        } else if (Math.random() < 0.2) { // Simulate a check to see if there's an ongoing transaction.
            return left(new ExistingActiveTransaction());
        }

        // Save transaction.

        return right(UUID.randomUUID());
    }

    public sealed interface InitiateTransactionError
            permits
            ExistingActiveTransaction,
            InsufficientFunds,
            InvalidAmount {
        record InvalidAmount(int amount) implements InitiateTransactionError { }
        record InsufficientFunds() implements InitiateTransactionError { }
        record ExistingActiveTransaction() implements InitiateTransactionError { }
    }
}


@RestController
@RequestMapping("/transactions")
public class TransactionResource {
    private final TransactionService transactionService;

    public TransactionResource(TransactionService transactionService) {
        this.transactionService = transactionService;
    }

    @PostMapping
    public ResponseEntity<String> initiateTransaction(@RequestBody InitiateTransactionInput input) {
        return transactionService.initiateTransaction(input.amount).fold(
                error -> switch (error) {
                    case InsufficientFunds() -> status(HttpStatus.CONFLICT).body("Insufficient funds.");
                    case InvalidAmount(int amount) -> badRequest().body("Invalid amount " + amount);
                    case ExistingActiveTransaction() -> status(HttpStatus.CONFLICT).body("Another transaction in progress.");
                },
                successTransactionId -> status(HttpStatus.CREATED).body(successTransactionId.toString())
        );
    }

    public record InitiateTransactionInput(int amount) { }
}

If you don’t have an Either in your project, here’s a basic one:

import java.util.function.Function;

import static java.util.Objects.requireNonNull;

public class Either<A, B> {

    private final A left;
    private final B right;

    private Either(A left, B right) {
        this.left = left;
        this.right = right;
    }

    public <C> C fold(Function<A, C> leftFunction, Function<B, C> rightFunction) {
        requireNonNull(leftFunction);
        requireNonNull(rightFunction);

        return left != null ? leftFunction.apply(left) : rightFunction.apply(right);
    }

    public static <A, B> Either<A, B> left(A left) {
        requireNonNull(left);
        return new Either<>(left, null);
    }

    public static <A, B> Either<A, B> right(B right) {
        requireNonNull(right);
        return new Either<>(null, right);
    }
}

Despite introducing some verbosity, these practices are pivotal in crafting robust, resilient and maintainable applications.


Posted