📜 ⬆️ ⬇️

About errors and exceptions



Last time I examined two examples ( one , two ), how to move from imperative validation of input values ​​to declarative. The second example really “knows too much” about the aspects of storage and has pitfalls ( one , two ). The alternative is to break the validation into 3 parts:

  1. Model banding: expecting int , came in string - return 400
  2. Validation of values: the email field should be in the format of your@mail.com , and 123Petya - return 422
  3. Validation of business rules: expect that the user's basket is active, and it is in the archive. Return 422

Unfortunately, the standard ASP.NET MVC binding mechanism does not distinguish between type mismatch errors (got string instead of int ) and validation, so if you want to distinguish between 400 and 422 response codes, you will have to do it yourself. But it's not about that.

How can a business logic layer return an error message to the controller?


The most common (according to Habr) method ( one , two , three ) is to throw an exception. Thus, between the concept of "error" and "exception" is put an equal sign. Moreover, the “error” is interpreted in the broad sense of the word: it is not only validation, but also verification of access rights and business rules. Is it so? Is any error an “exceptional situation”? If you have ever come across accounting or tax accounting, you probably know that there is a special term “adjustment”. It means that incorrect information was submitted in the last reporting period and must be corrected. That is, in the field of accounting, without which a business cannot exist in principle, errors are first-class objects. Special terms have been introduced for them. Can we call them exceptional situations? Not. This is normal behavior. People are wrong. Programmers are simply overly optimistic people. We just never take off the rose-colored glasses.

Exception = error?


Well, maybe “exceptions” is just a bad name, but in fact they are great for working with “bugs”. Even MSDN defines “exceptions” as “run-time errors”. Let's check. What happens to a program if an unhandled exception occurs in it? Emergency shutdown. Web applications do not terminate just because, in fact, all unhandled exceptions are handled globally. All server platforms provide the ability to subscribe to "raw" errors. Should the program terminate if there is an error in the business logic? In some cases, yes, for example, if you are developing software for high-frequency trading and something went wrong in the trading algorithm. It doesn't matter how quickly you make decisions if they are wrong. And in case of an error in user input? No, we need to display a meaningful message to the user. Thus, mistakes are fatal or "not so." To use one type to designate both is fraught.
')
Imagine that you have two projects in support. Both log all unhandled exceptions in the database. In the first case, exceptions are extremely rare: 1-2 times a month, and in the second hundreds per day. In the first case, you will very carefully study the logs. If something has appeared in the log, then there is some fundamental problem and in certain cases the system can go into an indefinite state. In the second, the signal-to-noise ratio is “broken.” How to know the system is working normally or entered into the zone of turbulence, if the logs are always full of errors?

We can create a type BusinessLogicException and log them separately (or not log). Then make a similar HttpException for HttpException , DbValidationException and others. Hmm, you need to remember which exceptions you need to catch and which not. Similarly, in Java, there are checked exceptions, let's bring in .NET! It is only necessary to take into account that not all exceptions can be caught and processed and not to forget about the peculiarities of working with exceptions in the TPL . And how is it, well, this performance .

Exception = goto?


Another argument against the widespread use of exceptions is the similarity with goto. There is no way to find out where in the call chain it will be caught, because the method signature does not reveal which exceptions can be thrown inside. Moreover, method signatures in languages ​​with exceptions do not agree . It would be more correct to write not RequestDto -> IActionResult , but RequestDto -> IActionResult | Exception RequestDto -> IActionResult | Exception : the method may succeed or something may go wrong.

Exception handling in a three-tier architecture


Since we do not know exactly where exactly the error occurred, we cannot process it well, form a meaningful message to the user or use a compensating action.

Thus, if in the business logic layer, exceptions are used for all types of “errors”, we must either wrap each controller method in a try / catch block, or override the processing method at the application level. The first option is bad because you have to duplicate try / catch everywhere and keep track of the types of errors being caught. The second is that we lose the execution context.

Scott Vlashin proposed an alternative approach to working with errors in his report, Railway Oriented Programming ( translated to Habré ), and vkhorikov adapted it for C # . I took the liberty to slightly modify this version.

We finish Result


 public class Result { public bool Success { get; private set; } public string Error { get; private set; } public bool Failure { get { return !Success; } } protected Result(bool success, string error) { Contracts.Require(success || !string.IsNullOrEmpty(error)); Contracts.Require(!success || string.IsNullOrEmpty(error)); Success = success; Error = error; } //... } 

The string type is not quite convenient for working with errors. Replace the string with the type Failure . Unlike the Scott variant, Failure is not a union-type, but an ordinary class. Pattern matching to work with errors is replaceable with polymorphism. In order to save additional error information we will use the Data property. Often this data needs to be simply serialized, so a specific type is not so important.

 public class Failure { public Failure(params Failure[] failures) { if (!failures.Any()) { throw new ArgumentException(nameof(failures)); } Message = failures.Select(x => x.Message).Join(Environment.NewLine); var dict = new Dictionary<string, object>(); for(var i = 0; i < failures.Length; i++) { dict[(i + 1).ToString()] = failures[i]; } Data = new ReadOnlyDictionary<string, object>(dict); } public Failure(string message) { Message = message; } public Failure(string message, IDictionary<string, object> data) { Message = message; Data = new ReadOnlyDictionary<string, object>(data); } public string Message { get; } public ReadOnlyDictionary<string, object> Data { get; protected set; } } 

We will declare the heir-specific classes for validation errors and access rights.

 public class ValidationFailure: Failure { public ValidationResult[] ValidationResults { get; } public ValidationFailure(IEnumerable<ValidationResult> validationResults) : base(ValidationResultsToStrings(validationResults)) { ValidationResults = validationResults?.ToArray(); if (ValidationResults == null || !ValidationResults.Any()) { throw new ArgumentException(nameof(validationResults)); } Data = new ReadOnlyDictionary<string, object>( ValidationResults.ToDictionary( x => x.MemberNames.Join(","), x => (object)x.ErrorMessage)); } private static string ValidationResultsToStrings( IEnumerable<ValidationResult> validationResults) => validationResults .Select(x => x.ErrorMessage) .Join(Environment.NewLine); } 

Overloading operators and hiding Value


Add to the Result operator overload &, | true false &, | true false so that && and || . Close the value and instead provide the function Return . Now it is impossible to make a mistake and not to check the IsFaulted property: the method requires TDestination both parameter T and Failure lead to type TDestination . This fixes a problem with return codes that you can forget to check. The result simply cannot be obtained without processing the variant with an error.

 public class Result { public static implicit operator Result (Failure failure) => new Result(failure); // https://stackoverflow.com/questions/5203093/how-does-operator-overloading-of-true-and-false-work public static bool operator false(Result result) => result.IsFaulted; public static bool operator true(Result result) => !result.IsFaulted; public static Result operator &(Result result1, Result result2) => Result.Combine(result1, result2); public static Result operator |(Result result1, Result result2) => result1.IsFaulted ? result2 : result1; public Failure Failure { get; private set; } public bool IsFaulted => Failure != null; } 

In the context of a web operation, the implementation of a transformation method may look like this:

 result.Return<IActionResult>(Ok, x => BadRequest(x.Message)); 

Or for the case of Scott's example : get a request, perform validation, update the information in the database and, if successful, send an email with confirmation like this:

 public IActionResult Post(ChangeUserNameCommand command) { var res = command.Validate(); if (res.IsFaulted) return res; return ChangeUserName(command) .OnSuccess(SendEmail) .Return<IActionResult>(Ok, x => BadRequest(x.Message)); } 

Support LINQ syntax (per fan)


If there are more steps, then the if(res.IsFaulted) return res; will have to be repeated after each step. I would like to avoid this. This is SelectMany cycle of Eric Lippert ’s articles about the nature of SelectMany and the letter with the letter M can be by the way. In general, LINQ syntax supports not only IEnumerable , but also any other types. The main SelectMany implement SelectMany aka Bind . Add some scary code with templates. Here I will not go into details on how bind works. If interested, read through Lippert or Vlashin .

 public static class ResultExtensions { public static Result<TDestination> Select<TSource, TDestination>( this Result<TSource> source, Func<TSource, TDestination> selector) => source.IsFaulted ? new Result<TDestination>(source.Failure) : selector(source.Value); public static Result<TDestination> SelectMany<TSource, TDestination>( this Result<TSource> source, Func<TSource, Result<TDestination>> selector) => source.IsFaulted ? new Result<TDestination>(source.Failure) : selector(source.Value); public static Result<TDestination> SelectMany<TSource, TIntermediate, TDestination>( this Result<TSource> result, Func<TSource, Result<TIntermediate>> inermidiateSelector, Func<TSource, TIntermediate, TDestination> resultSelector) => result.SelectMany<TSource, TDestination>(s => inermidiateSelector(s) .SelectMany<TIntermediate, TDestination>(m => resultSelector(s, m))); } 

It looks a bit unusual, but you can build chains of calls and combine them into one pipe. All if(result.IsFaulted) are performed “under the hood” using the LINQ syntax.

 public Result<UserNameChangedEvent> Declarative(ChangeUserNameCommand command) => from validatedCommand in command.Validate() from domainEvent in ChangeUserName(validatedCommand).OnSuccess(SendEmail) select domainEvent; 

Conclusion


I do not urge to refuse exceptions. This is a very good tool for, surprise, “exceptional situations” - mistakes that we didn’t expect at all. They prevent the system from going into an undefined state and can serve as an excellent indicator of the normal / emergency operation of the application. However, using exceptions everywhere, we deprive ourselves of this tool. Regular situation by definition can not be considered exceptional.

In the C # version, there is currently no mechanism in the language for working with “non-exceptional” situations, i.e. errors that will probably occur and which we must handle. Perhaps in future versions we will get these features. It is up to you to reserve special types of exceptions as “not exceptional situations” or introduce other specialized types, for example, as in this article.

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


All Articles