How to get along with almost no exceptions, replacing them with notifications

Original author: Martin Fowler
  • Transfer
Hello, Habr.

Sometimes you come across articles that you want to translate simply for the name. Even more interesting, when such an article may be useful to specialists in different languages, but it contains examples in Java. Very soon we hope to share with you our latest idea regarding the publication of a large book on Java, but for now we offer you to familiarize yourself with the publication of Martin Fowler from December 2014, which has not yet been translated into Russian. The translation is made with small reductions.

If you are validating data, you should usually not use exceptions as a signal that the validation has failed. Here I will describe refactoring of a similar code using the “Notification” pattern.

Recently I came across a code that performed the simplest validation of JSON messages. It looked something like this:

publicvoidcheck(){
   if (date == null) thrownew IllegalArgumentException("date is missing");
   LocalDate parsedDate;
   try {
     parsedDate = LocalDate.parse(date);
   }
   catch (DateTimeParseException e) {
     thrownew IllegalArgumentException("Invalid format for date", e);
   }
   if (parsedDate.isBefore(LocalDate.now())) thrownew IllegalArgumentException("date cannot be before today");
   if (numberOfSeats == null) thrownew IllegalArgumentException("number of seats cannot be null");
   if (numberOfSeats < 1) thrownew IllegalArgumentException("number of seats must be positive");
 }


This is how validation is usually performed. You apply several verification options to the data (the above are just a few fields of the whole class). If at least one verification step fails, an exception is thrown with an error message.

I was having some problems with this approach. Firstly, I do not like to use exceptions in such cases. An exception is a signal of some extraordinary event in the code in question. But if you check some external input, then you assume that the messages you enter may contain errors - that is, errors are expected, and it is incorrect to apply exceptions in this case.

The second problem with such a code is that if it crashes after the first error detected, it is more advisable to report all errors that occurred in the input data, and not just about the first. In this case, the client will be able to immediately display all errors to the user, so that he corrects them in one operation, and not force the user to play cat and mouse with the computer.

In such cases, I prefer to organize reporting of validation errors using the “Notification” pattern. A notification is an object that collects errors; for each failed validation act, another error is added to the notification. The validation method returns a notification, which you can then parse to find out more information. A simple example is the following code for performing checks.

privatevoidvalidateNumberOfSeats(Notification note){
  if (numberOfSeats < 1) note.addError("number of seats must be positive");
  // другие подобные проверки
}

You can then make a simple call like aNotification.hasErrors () to respond to errors, if any. Other methods in the notification may delve into more detailed error information.



When to apply such refactoring

Here I note that I do not urge to get rid of exceptions in your entire code base. Exceptions are a very convenient way of handling emergency situations and removing them from the main logical stream. The proposed refactoring is appropriate only in those cases when the result reported using the exception is in fact not exceptional, and therefore should be processed by the main logic of the program. The validation considered here is just such a case.

A convenient “iron rule” that should be used when implementing exceptions is found in the Pragmatic Programmers:

We believe that exceptions should only be used sporadically as part of the normal course of the program; it is necessary to resort to them precisely in exceptional events. Assume that an uncaught exception terminates your program, and ask yourself: “will this code continue to function if exception handlers are removed from it”? If the answer is no, then probably the exceptions apply in non-exclusive situations.


- Dave Thomas and Andy Hunt.

This is an important consequence: the decision on whether to use exceptions for a specific task depends on its context. So, Dave and Andy continue, reading from an unknown file may or may not be an exception in different contexts. If you are trying to read a file from a well-known location, such as / etc / hosts on a Unix system, it is logical to assume that the file should be there, and otherwise it is advisable to throw an exception. On the other hand, if you are trying to read a file located along the path entered by the user on the command line, you must assume that the file is not there and use a different mechanism - one that signals the non-exclusive nature of the error.

There is a case in which it would be wise to use exceptions for validation errors. The situation is implied: there is data that should have already been validated at an earlier stage of processing, but you want to do such a check again to be safe from software errors, due to which some invalid data could slip through.

This article talks about replacing exclusions with notifications in the context of raw input validation. Such a technique will be useful to you in cases where notification is a more appropriate option than an exception, but here we will focus on the option with validation as the most common.

Start

So far I have not mentioned the subject area, because I described only the most general structure of the code. But then, with the development of this example, it will be necessary to more accurately outline the subject area. It will be a code that receives in JSON format messages about booking seats in the theater. The code is a booking request class, populated based on JSON information using the gson library.

gson.fromJson(jsonString, BookingRequest.class)

Gson takes a class, looks for any fields that match the key in the JSON document, and then fills in those fields.

This booking request contains only two elements that we will validate: the date of the event and the number of reserved seats

class BookingRequest ...

private Integer numberOfSeats; 
  private String date;
Валидационные операции — такие, которые уже упоминались выше
classBookingRequestpublicvoidcheck() {
     if (date == null) thrownew IllegalArgumentException("date is missing");
     LocalDate parsedDate;
     try {
       parsedDate = LocalDate.parse(date);
     }
     catch (DateTimeParseException e) {
       thrownew IllegalArgumentException("Invalid format for date", e);
     }
     if (parsedDate.isBefore(LocalDate.now())) thrownew IllegalArgumentException("date cannot be before today");
     if (numberOfSeats == null) thrownew IllegalArgumentException("number of seats cannot be null");
     if (numberOfSeats < 1) thrownew IllegalArgumentException("number of seats must be positive");
   }

Creating a notification

To use a notification, you need to create a special object for it. Notification can be very simple, sometimes it consists of just a list of lines.

Notification accumulates errors

List<String> notification = new ArrayList<>();
if (numberOfSeats < 5) notification.add("number of seats too small");
// сделать еще несколько проверок// затем…if ( ! notification.isEmpty()) // обработать условие ошибки

Although a simple list idiom provides a lightweight implementation of the pattern, I prefer not to stop there and write a simple class.

publicclassNotification{
  private List<String> errors = new ArrayList<>();
  publicvoidaddError(String message){ errors.add(message); }
  publicbooleanhasErrors(){
    return ! errors.isEmpty();
  }
  …


Using a real class, I express my intention more clearly - the code reader does not have to mentally correlate the idiom and its full meaning.

We decompose the verification method into parts

First of all, I will divide the verification method into two parts. The internal part will only work with notifications and not throw any exceptions. The external part will retain the actual behavior of the verification method - that is, it will throw an exception when the validation fails.

To do this, the first thing I use is the allocation of the method in an unusual way: I will put the whole body of the verification method into a validation method.

class BookingRequest ...

publicvoidcheck(){
    validation();
  }
  publicvoidvalidation(){
    if (date == null) thrownew IllegalArgumentException("date is missing");
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      thrownew IllegalArgumentException("Invalid format for date", e);
    }
    if (parsedDate.isBefore(LocalDate.now())) thrownew IllegalArgumentException("date cannot be before today");
    if (numberOfSeats == null) thrownew IllegalArgumentException("number of seats cannot be null");
    if (numberOfSeats < 1) thrownew IllegalArgumentException("number of seats must be positive");
  }


Then I will correct the validation method so that it creates and returns a notification.

class BookingRequest ...

public Notification validation(){
    Notification note = new Notification();
    if (date == null) thrownew IllegalArgumentException("date is missing");
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      thrownew IllegalArgumentException("Invalid format for date", e);
    }
    if (parsedDate.isBefore(LocalDate.now())) thrownew IllegalArgumentException("date cannot be before today");
    if (numberOfSeats == null) thrownew IllegalArgumentException("number of seats cannot be null");
    if (numberOfSeats < 1) thrownew IllegalArgumentException("number of seats must be positive");
    return note;
  }


Now I can check the notification and throw an exception if it contains errors.

class BookingRequest ...

publicvoidcheck(){
    if (validation().hasErrors()) 
      thrownew IllegalArgumentException(validation().errorMessage());
  }

I made the validation method public, since I expect that most callers will prefer to use this method over the prospect rather than the test one.

By dividing the original method into two parts, I distinguish between the validation check and the decision on how to respond to the error.

At this stage, I have not touched the code behavior at all. The notification will not contain any errors, and any failed validation checks will continue to throw exceptions, ignoring any new machinery that I add here. But I'm setting the stage to replace the issue of exceptions with notifications.

Before starting this, you need to say something about error messages. When refactoring, there is a rule: avoid changes in the observed behavior. In situations like this, this rule immediately raises the question for us: what behavior is observable? Obviously, throwing the correct exception will be noticeable to some extent for the external program - but to what extent is the error message relevant to it? As a result, the notification accumulates a lot of errors and can generalize them into a single message, something like this:

class Notification ...

public String errorMessage(){
    return errors.stream()
      .collect(Collectors.joining(", "));
  }

But here a problem arises if, at a higher level, program execution is tied to receiving a message only about the first error found, and then you need something like:

class Notification ...

public String errorMessage(){ return errors.get(0); }


You will have to pay attention not only to the calling function, but also to all exception handlers in order to determine an adequate response to this situation.

Although I could not have provoked any problems here, I will compile and test this code before making any new changes.

Validation of the number

The most obvious step in this case is to replace the first validation of the

class BookingRequest ...

public Notification validation(){
    Notification note = new Notification();
    if (date == null) note.addError("date is missing");
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      thrownew IllegalArgumentException("Invalid format for date", e);
    }
    if (parsedDate.isBefore(LocalDate.now())) thrownew IllegalArgumentException("date cannot be before today");
    if (numberOfSeats == null) thrownew IllegalArgumentException("number of seats cannot be null");
    if (numberOfSeats < 1) thrownew IllegalArgumentException("number of seats must be positive");
    return note;
  }

The obvious step, but bad, as it will break the code. If you pass an empty date to the function, the code will add an error to the notification, but then immediately try to parse it and throw a null pointer exception - and we are not interested in this exception.

Therefore, in this case, it is better to do a less straightforward, but more effective thing - a step back.

class BookingRequest ...

public Notification validation(){
    Notification note = new Notification();
    if (date == null) thrownew IllegalArgumentException("date is missing");
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      thrownew IllegalArgumentException("Invalid format for date", e);
    }
    if (parsedDate.isBefore(LocalDate.now())) thrownew IllegalArgumentException("date cannot be before today");
    if (numberOfSeats == null) thrownew IllegalArgumentException("number of seats cannot be null");
    if (numberOfSeats < 1) note.addError("number of seats must be positive");
    return note;
  }

The previous check is a zero check, so we need a conditional construct that would allow us not to throw a null pointer exception.

class BookingRequest ...

public Notification validation(){
    Notification note = new Notification();
    if (date == null) thrownew IllegalArgumentException("date is missing");
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      thrownew IllegalArgumentException("Invalid format for date", e);
    }
    if (parsedDate.isBefore(LocalDate.now())) thrownew IllegalArgumentException("date cannot be before today");
    if (numberOfSeats == null) note.addError("number of seats cannot be null");
    elseif (numberOfSeats < 1) note.addError("number of seats must be positive");
    return note;
  }

I see that the next check affects a different field. Not only will I have to introduce a conditional construct at the previous stage of refactoring - now it seems to me that the validation method is excessively complicated and could be decomposed. So, we select the parts responsible for the validation of numbers.

class BookingRequest ...

public Notification validation(){
    Notification note = new Notification();
    if (date == null) thrownew IllegalArgumentException("date is missing");
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      thrownew IllegalArgumentException("Invalid format for date", e);
    }
    if (parsedDate.isBefore(LocalDate.now())) thrownew IllegalArgumentException("date cannot be before today");
    validateNumberOfSeats(note);
    return note;
  }
  privatevoidvalidateNumberOfSeats(Notification note){
    if (numberOfSeats == null) note.addError("number of seats cannot be null");
    elseif (numberOfSeats < 1) note.addError("number of seats must be positive");
  }

I look at the highlighted number validation, and I don't like its structure. I don’t like to use if-then-else blocks when validating, because code with an excessive number of attachments can be so easy. I prefer a linear code that stops working as soon as the execution of the program becomes impossible - such a point can be determined using the boundary condition. This is how I implement the replacement of nested conditional constructions with boundary conditions.

class BookingRequest ...

privatevoidvalidateNumberOfSeats(Notification note){
    if (numberOfSeats == null) {
      note.addError("number of seats cannot be null");
      return;
    }
    if (numberOfSeats < 1) note.addError("number of seats must be positive");
  }


When refactoring, you should always try to take minimal steps to maintain the existing behavior.

My decision to take a step back plays a key role in refactoring. The essence of refactoring is to change the structure of the code, but in such a way that the transformations performed do not change its behavior. Therefore, refactoring should always be done in small steps. So we insure against errors that can overtake us in the debugger.

Date

validation When validating the date, I start again by highlighting the method :

class BookingRequest ...

public Notification validation(){
    Notification note = new Notification();
    validateDate(note);
    validateNumberOfSeats(note);
    return note;
  }
  privatevoidvalidateDate(Notification note){
    if (date == null) thrownew IllegalArgumentException("date is missing");
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      thrownew IllegalArgumentException("Invalid format for date", e);
    }
    if (parsedDate.isBefore(LocalDate.now())) thrownew IllegalArgumentException("date cannot be before today");
  }

When I used automated method allocation in my IDE, the resulting code did not include a notification argument. So I tried to add it manually.

Now let's go back when validating the date:

class BookingRequest ...

privatevoidvalidateDate(Notification note){
    if (date == null) thrownew IllegalArgumentException("date is missing");
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      thrownew IllegalArgumentException("Invalid format for date", e);
    }
    if (parsedDate.isBefore(LocalDate.now())) note.addError("date cannot be before today");
  }

At the second stage, a complication arises with error handling, since there is a condition exception in the thrown exception. To process it, you will need to modify the notification so that it can accept such exceptions. Since I'm just halfway there: I refuse to throw exceptions and proceed to work with notifications - my code is red. So, I roll back to leave the validateDate method in the above form, and prepare a notification to receive a conditional exception.

Starting to change the notification, I add the addError method, which accepts the condition, and then change the original method so that it can call a new method.

class Notification ...

publicvoidaddError(String message){
    addError(message, null);
  }
  publicvoidaddError(String message, Exception e){
    errors.add(message);
  }

Thus, we accept the conditional exception, but ignore it. To place it somewhere, I need to turn the error record from a simple string into a slightly more complex object.

class Notification ...

privatestaticclassError{
    String message;
    Exception cause;
    privateError(String message, Exception cause){
      this.message = message;
      this.cause = cause;
    }
  }


I do not like private fields in Java, however, since we are dealing with a private inner class here, everything suits me. If I were going to open access to this error class somewhere outside of the notification, I would encapsulate these fields.

So I have a class. Now you need to modify the notification to use it, rather than a string.

class Notification ...

private List<Error> errors = new ArrayList<>();
  publicvoidaddError(String message, Exception e){
    errors.add(new Error(message, e));
  }
  public String errorMessage(){
    return errors.stream()
            .map(e -> e.message)
            .collect(Collectors.joining(", "));
  }


Having a new notification, I can make changes to the request for booking

class BookingRequest ...

privatevoidvalidateDate(Notification note){
    if (date == null) thrownew IllegalArgumentException("date is missing");
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      note.addError("Invalid format for date", e);
      return;
    }
    if (parsedDate.isBefore(LocalDate.now())) note.addError("date cannot be before today");

Since I am already in the selected method, it is easy to undo the remaining validation using the return command.

The latest change is a very simple

BookingRequest class ...

privatevoidvalidateDate(Notification note){
    if (date == null) {
      note.addError("date is missing");
      return;
    }
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      note.addError("Invalid format for date", e);
      return;
    }
    if (parsedDate.isBefore(LocalDate.now())) note.addError("date cannot be before today");
  }


Up the stack

Now that we have a new method, the next task is to see who calls the original verification method and adjust these elements so that they can use the new validation method in the future. Here we have to consider in a wider context how such a structure fits into the general logic of the application, so this problem is beyond the scope of the refactoring considered here. But our medium-term goal is to get rid of the use of exceptions that are widely applied in all cases of possible failure of validation.

In many situations, this will allow you to completely get rid of the verification method - then all the tests associated with it will need to be rewritten so that they work with the validation method. In addition, a test correction may be needed to verify that errors in the notification are accumulated correctly.

Frameworks

A number of frameworks provide the ability to validate using a notification pattern. In Java, it is Java Bean Validation and Spring Validation . These frameworks serve as original interfaces that initiate validation and use notification to collect errors (Set для валидации и Errors
в случае Spring).

Присмотритесь к вашему языку и платформе и проверьте, есть ли там валидационный механизм, использующий уведомления. Детали этого механизма отразятся на ходе рефакторинга, но в целом должно получиться очень похоже.

Also popular now: