📜 ⬆️ ⬇️

How to do almost no exceptions, replacing them with notifications

Hello, Habr.

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

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

Recently, I caught the eye of code that performed the simplest validation of JSON messages. He looked like this:
')
public void check() { if (date == null) throw new IllegalArgumentException("date is missing"); LocalDate parsedDate; try { parsedDate = LocalDate.parse(date); } catch (DateTimeParseException e) { throw new IllegalArgumentException("Invalid format for date", e); } if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today"); if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null"); if (numberOfSeats < 1) throw new IllegalArgumentException("number of seats must be positive"); } 


This is usually how validation is performed. You apply several validation 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 had some problems with this approach. First, 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 subject some external input to verification, then you assume that the entered messages may contain errors - that is, errors are expected, and it is wrong to use exceptions in this case.

The second problem with such a code is that if it crashes after the first error detected, it is better to report all errors that occurred in the input data, and not just the first one. In this case, the client will be able to display all errors to the user at once, 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 the reporting of validation errors using the “Notification” pattern. A notification is an object that collects errors; for each failed validation act, a regular error is added to the notification. The validation method returns a notification, which you can then parse for additional information. A simple example is the following code to perform checks.

 private void validateNumberOfSeats(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 can get to the details of errors.



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 to handle abnormal situations and remove them from the main logical flow. The proposed refactoring is relevant only in cases where the result reported by means of an exception is not really exceptional, which means it must be processed by the main logic of the program. The validation considered here is just such a case.

Convenient "iron rule", which is to use when implementing exceptions, we find in Pragmatic Programmers:

We believe that exceptions should only be used sporadically in the normal course of a program; it is necessary to resort to them at exceptional events. Suppose that an uncaught exception completes your program, and ask yourself: "Will this code continue to function if you remove exception handlers from it?" If the answer is no, then exceptions are likely to apply in non-exclusive situations.


- Dave Thomas and Andy Hunt

Hence the important consequence: the decision whether to use exceptions for a specific task depends on its context. So, continue with Dave and Andy, reading from a file not found in different contexts may or may not be an exceptional situation. If you are trying to read a file from a well-known location, for example / 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 try to read a file located along the path entered by the user on the command line, you must assume that the file will not be there, and use another 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 re-do such a check in order to be safe from program errors that could cause some unacceptable data to slip.

This article talks about replacing exceptions with notifications in the context of validating raw input. This technique will be useful to you in those cases where notification is a more expedient 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, since I have described only the most general structure of the code. But further with the development of this example, it will be necessary to more precisely outline the subject area. It will be a question of the code accepting in the JSON format messages on booking seats in the theater. The code is a booking request class that is populated based on JSON information using the gson library.

 gson.fromJson(jsonString, BookingRequest.class) 

Gson takes a class, searches for any fields that satisfy the key in a JSON document, and then fills in such 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;   — ,     class BookingRequest… public void check() { if (date == null) throw new IllegalArgumentException("date is missing"); LocalDate parsedDate; try { parsedDate = LocalDate.parse(date); } catch (DateTimeParseException e) { throw new IllegalArgumentException("Invalid format for date", e); } if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today"); if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null"); if (numberOfSeats < 1) throw new IllegalArgumentException("number of seats must be positive"); } 

Create notification

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

Notification accumulates errors

 List<String> notification = new ArrayList<>(); if (numberOfSeats < 5) notification.add("number of seats too small"); //     // … if ( ! notification.isEmpty()) //    

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

 public class Notification { private List<String> errors = new ArrayList<>(); public void addError(String message) { errors.add(message); } public boolean hasErrors() { return ! errors.isEmpty(); } … 


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

We decompose the verification method into pieces.

First, I will divide the verification method into two parts. The interior will eventually work only with notifications and will not issue any exceptions. The external part will retain the actual behavior of the verification method — that is, it will throw an exception if the validation fails.

To do this, I first of all use the method selection in an unusual way: I move the whole body of the verification method into the validation method.

class BookingRequest ...

  public void check() { validation(); } public void validation() { if (date == null) throw new IllegalArgumentException("date is missing"); LocalDate parsedDate; try { parsedDate = LocalDate.parse(date); } catch (DateTimeParseException e) { throw new IllegalArgumentException("Invalid format for date", e); } if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today"); if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null"); if (numberOfSeats < 1) throw new 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) throw new IllegalArgumentException("date is missing"); LocalDate parsedDate; try { parsedDate = LocalDate.parse(date); } catch (DateTimeParseException e) { throw new IllegalArgumentException("Invalid format for date", e); } if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today"); if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null"); if (numberOfSeats < 1) throw new 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 ...

  public void check() { if (validation().hasErrors()) throw new IllegalArgumentException(validation().errorMessage()); } 

I made the validation method public because I expect that most callers in the future will prefer to use this method rather than the verification method.

By splitting the original method into two parts, I delimit the validation check from deciding how to respond to an error.

At this stage, I have not touched the behavior of the code at all. The notification will not contain any errors, and any failed validation checks will continue to throw exceptions, ignoring any new machinery I add here. But I am preparing the ground to replace the issuance of exceptions to work with notifications.

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

class Notification ...

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

But a problem will arise here, if at a higher level, the program’s execution is tied to receiving a message about the first error detected, and then you need something like:

class Notification ...

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


It is necessary 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 here I could not in any way provoke any problems, I will compile and test this code before making new changes.

Validation number

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

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) { throw new IllegalArgumentException("Invalid format for date", e); } if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today"); if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null"); if (numberOfSeats < 1) throw new IllegalArgumentException("number of seats must be positive"); return note; } 

The obvious step, but bad because it will break the code. If you pass an empty date to the function, the code adds an error to the notification, but then immediately tries to parse it and throws a null pointer exception — and we are not interested in this exception.

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

class BookingRequest ...

  public Notification validation() { Notification note = new Notification(); if (date == null) throw new IllegalArgumentException("date is missing"); LocalDate parsedDate; try { parsedDate = LocalDate.parse(date); } catch (DateTimeParseException e) { throw new IllegalArgumentException("Invalid format for date", e); } if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today"); if (numberOfSeats == null) throw new 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 create an exception of the null pointer.

class BookingRequest ...

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

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

class BookingRequest ...

  public Notification validation() { Notification note = new Notification(); if (date == null) throw new IllegalArgumentException("date is missing"); LocalDate parsedDate; try { parsedDate = LocalDate.parse(date); } catch (DateTimeParseException e) { throw new IllegalArgumentException("Invalid format for date", e); } if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today"); validateNumberOfSeats(note); return note; } private void validateNumberOfSeats(Notification note) { if (numberOfSeats == null) note.addError("number of seats cannot be null"); else if (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 during validation, because it can easily turn out code with an excessive number of attachments. I prefer linear code, which stops working immediately, as soon as program execution becomes impossible - such a point can be determined using a boundary condition. So I implement the replacement of nested conditional constructions with boundary conditions.

class BookingRequest ...

  private void validateNumberOfSeats(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 preserve 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 are safe from the occurrence of errors that can overtake us in the debugger.

Date validation

When validating a date, I start again with highlighting the method :

class BookingRequest ...

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

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

Now let's go back to date validation:

class BookingRequest ...

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

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

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

class Notification ...

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

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

class Notification ...

 private static class Error { String message; Exception cause; private Error(String message, Exception cause) { this.message = message; this.cause = cause; } } 


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

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

class Notification ...

  private List<Error> errors = new ArrayList<>(); public void addError(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 notice, I can make changes to the request for booking

class BookingRequest ...

 private void validateDate(Notification note) { if (date == null) throw new 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 cancel the remaining validation using the return command.

The last change is quite simple.

class BookingRequest ...

 private void validateDate(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 stack

Now that we have a new method, the next task is to see who is calling the original verification method and correct these elements so that they use the new validation method in the future. Here we will have to consider in a broader context how this structure fits into the general logic of the application, so this problem goes beyond the scope of refactoring considered here. But our medium-term task is to get rid of the use of exceptions, which are indiscriminately applied in all cases of possible failure to validate.

In many situations, this will allow to get rid of the verification method altogether - then all tests related to it will have to be rewritten so that they work with the validation method. In addition, correction of tests may be needed to check whether errors in the notification are accumulated correctly.

Frameworks

A number of frameworks provide the ability to validate using a notification pattern. In Java, this is Java Bean Validation and Spring validation . These frameworks serve as original interfaces that initiate validation and use a notification to collect errors ( Set Errors
Spring).

, , . , .
Set Errors
Spring).

, , . , .

Source: https://habr.com/ru/post/270331/


All Articles