Functional error handling with Java 17
In every program, there are situations where something goes wrong and an error occurs. Because of that, every language has some kind of mechanism that helps developers to handle such situations. In Java, almost all code and libraries handle encountered errors by throwing exceptions. Throwing exceptions is a very powerful solution and it has many benefits when you first think about it. On the other hand, exceptions can also be overused when it’s not necessary, and this approach certainly has some drawbacks.
Now, with Java 17, by using new features like pattern matching and sealed interfaces, it is easier than ever to do error handling in a slightly different, more functional way. Because of that, I would like to show you how the standard way of handling errors can be replaced by utilizing all these new features. We will look at a simple application that might fail at several steps. We will first see how error handling can be done with exceptions and then how we can achieve the same thing without throwing a single exception.
Example application
The application that will serve as our example is pretty simple but, at the same time, complex enough to simulate real-world problems. It's an HTTP endpoint in which we need to provide two inputs: userId
and reportName
. In the output, it returns a Report. The logic to obtain the Report object is as follows:
- Find User with a given
userId
in internal DB to findemail
of this user - Use an external SDK that has the method
Report getReport(String reportName, String email)
where we provide email and report name and receive Report.
As you can see, we need to chain two operations to return the result and both operations might fail in several ways. In the first operation, we might have some unexpected DB issue, given userId might not exist in the DB or some other unexpected errors related to DB.
In the second operation, we use an external SDK and don't have access to the source code, we just know from the documentation that it throws some unchecked exceptions like InvalidReportNameException
, ReportForUserNotFoundException
, etc.
Error handling with exceptions
Let's look at how we would solve our problem with the use of exceptions. We have ReportService
in which we use ReportClient
, which is a class that comes from an external library (hence we don't have access to its code).
public class ReportService {
private final ReportClient reportClient;
public Report getReportV1(String email, String reportName) {
return reportClient.getReport(email, reportName);
}
}
And a UserService
which just calls a repository generated by Spring Data:
public class UserService {
private final UserRepository userRepository;
public User getUserV1(String userId) {
return userRepository.getUser(userId).get();
}
Now we need to create an HTTP endpoint in our Spring-based application that should return Report
value for the given userId
and reportName
. So the controller looks like this:
public class ReportController {
private final ReportService reportService;
private final UserService userService;
@GetMapping("/report")
public Report getReport(@RequestParam String userId, @RequestParam String reportName) {
User user = userService.getUserV1(userId);
return reportService.getReportV1(user.email(), reportName);
}
Now everything will work fine unless some error occurs, like the getReport()
method of ReportClient
throws some exception. In such a case, if the caller of our endpoint provides userId
that doesn't exist, we return status 500 with some generic error message. This is not perfect for the user of our API, and probably we would like to return a 404 with a nice error message. The standard way to do this would be to catch exceptions that are of our interest and rethrow some custom exception that will be then handled in the ErrorHandler
class annotated with the @ExceptionHandler
annotation.
So our methods become this:
public User getUserV1(String userId) {
Optional<User> optionalUser;
try {
optionalUser = userRepository.getUser(userId);
} catch (Exception e) {
throw new DatabaseAccessException(e);
}
return optionalUser.orElseThrow(() -> new UserNotFoundInDbException(userId));
}
public Report getReportV1(String email, String reportName) {
try {
return reportClient.getReport(email, reportName);
} catch (InvalidReportNameException e) {
throw new ReportNameNotFoundInReportApiException();
} catch (ReportForUserNotFoundException e) {
throw new UserNotFoundInReportApiException();
} catch (Exception e) {
throw new ReportClientUnexpectedException(e);
}
}
And now we need a class that handles exceptions and maps them to an HTTP response:
@RestControllerAdvice
public class ErrorHandler {
@ExceptionHandler(ReportNameNotFoundInReportApiException.class)
@ResponseStatus(BAD_REQUEST)
public ApiErrorResponse handleReportNameNotFoundInReportApiException(ReportNameNotFoundInReportApiException ex) {
return new ApiErrorResponse(400, "Invalid Report name.");
}
@ExceptionHandler(UserNotFoundInDbException.class)
@ResponseStatus(NOT_FOUND)
public ApiErrorResponse handleUserNotFoundInDbException(UserNotFoundInDbException ex) {
return new ApiErrorResponse(404, "Provided user id doesn't exist.");
}
// and so on...
}
Migrate to Java 21. Partner with Java experts to modernize your codebase, improve developer experience and employee retention. Explore the offer >>
Drawbacks of the standard way
If you look carefully at our current logic, you can notice that the place where we jump from ClientService
to ErrorHandler
by throwing an exception is in essence a sophisticated GOTO statement. I would say it's even worse. With exceptions, we don't really know
where it will be handled, whereas with GOTO, we at least clearly know it. As we all know, having GOTO statements should be avoided at all costs. It just makes our code harder to reason about. If we have many services that might throw exceptions and we chain results of each service in multiple layers, then the number of possible combinations of code execution increases drastically, and it's hard to reason about every possible failure at any given point.
Apart from that, throwing an exception is a relatively expensive operation. When we create and throw exceptions, we need to build a stack trace and to do it, we need to walk through the stack to collect all method names, classes, and line numbers. The farther we catch the exception, the bigger stack trace we have. Apart from spending CPU time for building the stack trace, we need to keep it in memory, which sometimes might be also expensive.
Now, let's recall the definition of an exception. According to Oracle:
An exception is an event, which occurs during the execution of a program, that disrupts the normal flow of the program's instructions.
If we think of a read operation from a database that fails because there is a connection issue, it perfectly fits this definition. However, it's pretty common to see the usage of exceptions to control the flow of the program, including the "normal" flow of the program. Now, let's ask ourselves a question. Do we expect that our client might provide a userId that doesn't exist? In our case, the answer is yes, and in my opinion, in the mentioned example, it's a completely normal case, not exceptional at all. So we need to notice that in our example, we have two types of errors, some really unexpected errors like DB connection issues, and some that are part of normal execution flow like user not existing in the database, or validation errors. When we throw exceptions both for "normal" errors and for unexpected errors, it might be difficult to distinguish real problems in the logs, because there will be tons of exceptions.
Functional error handling
Either type as a return type
If we would like to change it, we could take inspiration from the world of functional languages. In functional programming, everything is a value, including errors. To accomplish that, we can make our function return a special data type with which we can represent both an error and a success value. An example of such a data type is the Either
construct. In our case, you can think of it like Java Optional
, but optional that carries some additional information, like the reason why a value is not present. In Java, the Either
data type is not present in the standard library, but it can be easily added with a library called Vavr
, which brings us many great features from the functional programming world into Java.
Now, our ReportService
might become this:
public class ReportService {
private final ReportClient reportClient;
public Either<GeneralError, Report> getReportV2(String email, String reportName) {
return Try.of(() -> reportClient.getReport(email, reportName)).toEither()
.mapLeft(reportServiceException -> switch (reportServiceException) {
case InvalidReportNameException e -> new InvalidReportNameError(reportName);
case ReportForUserNotFoundException e -> new ReportForUserNotFoundError(email);
default -> new ReportClientUnexpectedError(reportServiceException);
});
}
and UserService
this:
public class UserService {
private final UserRepository userRepository;
public Either<GeneralError, User> getUserV2(String userId) {
return Try.of(() -> userRepository.getUser(userId))
.toEither()
.map(Option::ofOptional)
.<GeneralError>mapLeft(DatabaseError::new)
.flatMap(optionalUser -> optionalUser.toEither(new ReportForUserNotFoundError(userId)));
}
Error Model and sealed interfaces
Right now, our method from UserService
might return either GeneralError
or the User
object. The class GeneralError
is an interface, defined by us, with which we are going to represent our error model. Java 15 introduced something called sealed interfaces as a preview feature. Now, in Java 17, this feature has been finalized, and it is a perfect choice for our error model.
public sealed interface GeneralError permits ReportError, UserError, ServiceError {
}
public sealed interface ReportError extends GeneralError {
record ReportForUserNotFoundError(String email) implements ReportError { }
record InvalidReportNameError(String wrongName) implements ReportError { }
record ReportClientUnexpectedError(Throwable cause) implements ReportError { }
}
public sealed interface ServiceError extends GeneralError {
record DatabaseError(Throwable cause) implements ServiceError { }
record ReportApiError(Throwable cause) implements ServiceError { }
}
public sealed interface UserError extends GeneralError {
record UserNotFoundError(String userId) implements UserError { }
}
Pattern matching
Finally, we can use our new Services and Error model and refactor the Controller layer to the below version and completely remove the ErrorHandler
class:
public class FunctionalReportController {
private final ObjectMapper objectMapper;
private final ReportService reportService;
private final UserService userService;
@GetMapping("/report")
public ResponseEntity<String> getReport(@RequestParam String userId, @RequestParam String reportName) {
return userService.getUserV2(userId) // Either<GeneralError, User>
.flatMap(user -> reportService.getReportV2(user.email(), reportName)) // Either<GeneralError, Report>
.mapLeft(this::mapError) // Either<ApiErrorResponse, Report>
.fold(this::createErrorResponse, this::createSuccessResponse);
}
private ApiErrorResponse mapError(GeneralError obj) {
return switch (obj) {
case InvalidReportNameError e -> new ApiErrorResponse(400, "Invalid report name.");
case ReportForUserNotFoundError e -> new ApiErrorResponse(404, "Report for given user not found.");
case UserNotFoundError e -> new ApiErrorResponse(404, "Provided user id doesn't exist.");
case DatabaseError e -> new ApiErrorResponse(500, "Internal server error.");
case ReportApiError e -> new ApiErrorResponse(500, "Internal server error.");
case ReportClientUnexpectedError e -> new ApiErrorResponse(500, "Internal server error.");
};
}
private ResponseEntity<String> createSuccessResponse(Object responseBody) {
return ResponseEntity.status(200).body(toJson(responseBody));
}
private ResponseEntity<String> createErrorResponse(ApiErrorResponse errorResponse) {
return ResponseEntity.status(errorResponse.statusCode()).body(toJson(errorResponse));
}
private String toJson(Object object) {
return Try.of(() -> objectMapper.writeValueAsString(object))
.getOrElseThrow(e -> new RuntimeException("Cannot deserialize response", e));
}
}
You can notice that the Controller class has increased in terms of line count. One can say it's a disadvantage but it is not necessarily true. In the exception-driven controller example, we had just two lines in the controller method but it was hard to reason about what could go wrong. Here, you see all the possible cases and do the error handling part at the latest possible stage. Moreover, when it comes to the line count, it seems that it is longer, but please remember that we completely removed the ErrorHandler
class, so in terms of line counts, both solutions are similar.
It’s also worth to discuss two very important things that are possible thanks to Java 17, which are switch case pattern matching and pattern exhaustion.
Before Java 17, we had switch statements but the type of the variable on which we were performing a switch operation had to be of type String, Integer, or Enum. In our example, we are passing an object of type GeneralError
to a switch statement and mapping it to the appropriate error response. This is a very useful feature and makes our code easy to read, and is possible because of introduction of pattern matching to switch statements.
Another thing worth noticing is that we use a switch statement but there is no default case. Until now, Java was not able to check if we covered every possible case in the switch statement (except for enums) and we usually had to put a default case. In our case, it would not be so good because if we introduced some new error type and forgot to handle it in one of many cases, it would be handled as a default one. Now, if we add some new error and do not add a case for it in the switch statement, we will see a compilation error with a clear message which case is missing, so it is now impossible to forget to handle new errors.
One could ask: How is that possible? Well, earlier I said that a sealed interface is the perfect choice for our GeneralError
class. It's because pattern exhaustion is possible thanks to sealed classes. In the GeneralError
class, we declared that only UserError
, ReportError
and GeneralError
can extend this interface. That's how the compiler knows what the possible types of GeneralError
are and can detect that we haven't covered all possible cases.
Summary
We have seen how we can solve the same problem of handling errors in two ways, first by throwing exceptions and then by using the Either
construct from the Vavr library and utilizing the latest Java 17 features.
The purpose of this blog post is not to convince you to stop using exceptions, but rather to show a different approach. I think that we can't, or even shouldn't, get rid of exceptions completely, and probably some kind of hybrid approach could work the best for most cases.
I think that before we throw an exception, we should think if we are dealing with an exceptional situation or not. Whether it is something that we would like to have a stack trace for and analyze later why and where it happened, or we are just handling a usual business path.
Source code of the complete application can be found here.
Find out what Java Experts say about the newest Java release: Java21