[DotNetBook] Exceptions: type system architecture

  • Tutorial

With this article, I continue to publish a whole series of articles, the result of which will be a book on the work of the .NET CLR, and .NET as a whole. For links - welcome under cat.


Exception Architecture


Probably one of the most important issues related to the topic of exceptions is the issue of building an exception architecture in your application. This question is interesting for many reasons. As for me, the main thing is the apparent simplicity with which it is not always obvious what to do. This property is inherent in all basic structures that are used everywhere: this and IEnumerable, and IDisposableand IObservableand other-other. On the one hand, with their simplicity they attract, involve themselves in using in a variety of situations. And on the other hand, they are full of whirlpools and fords, from which, without knowing how, sometimes, they can’t get out at all. And, perhaps, looking at the future volume, the question has matured: so what is this about in exceptional situations?


Note


The chapter published on Habré is not updated and it is possible that it is already somewhat outdated. So, please ask for a more recent text to the original:



But in order to come to some conclusions regarding the construction of the architecture of classes of exceptional situations, we need to save some experience with you regarding their classification. After all, only by understanding what we will deal with, how and in what situations the programmer should choose the type of error, and in which - make a choice regarding interception or omission of exceptions, one can understand how you can build a type system so that it becomes obvious to your user. code. Therefore, let us try to classify exceptional situations (not the types of exceptions themselves, but the situations themselves) according to various criteria.


By the theoretical possibility of intercepting a projected exception


According to the theoretical interception, exceptions can be easily divided into two types: those that will intercept precisely and those that will not intercept with a high degree of probability. Why with a high degree of probability ? Because there is always someone who tries to intercept, although it did not have to do it at all.


Let us first discuss the features of the first group: exceptions that should and will intercept.


When we introduce an exception of this type, then on the one hand we inform the external subsystem that we have entered a situation where further actions within our data do not make sense. And on the other hand, we mean that nothing global was broken and if we were removed, then nothing would change, and therefore this exception can be easily intercepted to remedy the situation. This property is very important: it determines the criticality of the error and the belief that if you catch the exception and simply clear the resources, you can safely execute the code further.


The second group, however strange it may sound, is responsible for exceptions that are not necessary to intercept. They can only be used to write to the error log, but not to be able to somehow correct the situation. The simplest example is group exceptions ArgumentExceptionand NullReferenceException. Indeed, in a normal situation, you should not, for example, catch the exception ArgumentNullExceptionbecause the source of the problem here will be you, and not anyone else. If you intercept this exception, then you admit that you made a mistake and gave to the method what was impossible to give to it:


void SomeMethod(object argument)
{
    try {
        AnotherMethod(argument);
    } catch (ArgumentNullException exception)
    {
        // Log it
    }
}

In this method we try to intercept ArgumentNullException. But in my opinion, his interception looks very strange: throwing the correct arguments to the method is completely our concern. It would not be correct to react after the fact: in such a situation, the most correct thing you can do is to check the transmitted data in advance, before calling the method, or even better, build the code so that getting the wrong parameters would not be possible.


Another group is the exclusion of fatal errors. If a certain cache is broken and the subsystem will not work correctly anyway? Then this is a fatal error and the code closest to the stack will not be guaranteed to intercept it:


T GetFromCacheOrCalculate()
{
    try {
        if(_cache.TryGetValue(Key, out var result))
        {
            return result;
        } else {
            T res = Strategy(Key);
            _cache[Key] = res;
            return res;
        }
    } catch (CacheCorreptedException exception)
    {
        RecreateCache();
        return GetFromCacheOrCalculate();
    }
}

And let CacheCorreptedException- this is an exception, meaning "cache on the hard disk is not consistent." Then it turns out that if the cause of such an error is fatal for the caching subsystem (for example, there are no access rights to the cache file), then further code if it cannot recreate the cache with the command RecreateCache, and therefore the fact that it caught the exception is an error in itself.


By actual interception of the exception


Another question that stops our flight of thought in programming algorithms is an understanding: is it worth it to intercept certain exceptions, or should we let them through ourselves to someone more understanding. Translating into language of terms the question that we need to solve is to delineate the areas of responsibility. Let's look at the following code:



namespace JetFinance.Strategies
{
    public class WildStrategy : StrategyBase
    {
        private Random random =  new Random();
        public void PlayRussianRoulette()
        {
            if(DateTime.Now.Second == (random.Next() % 60))
            {
                throw new StrategyException();
            }
        }
    }
    public class StrategyException : Exception { /* .. */ }
}
namespace JetFinance.Investments
{
    public class WildInvestment
    {
        WildStrategy _strategy;
        public WildInvestment(WildStrategy strategy)
        {
            _strategy = strategy;
        }
        public void DoSomethingWild()
        {
            ?try?
            {
                _strategy.PlayRussianRoulette();
            }
            catch(StrategyException exception)
            {
            }
        }
    }
}
using JetFinance.Strategies;
using JetFinance.Investments;
void Main()
{
    var foo = new WildStrategy();
    var boo = new WildInvestment(foo);
    ?try?
    {
        boo.DoSomethingWild();
    }
    catch(StrategyException exception)
    {
    }
}

Which of the two proposed strategies is more correct? Area of ​​responsibility is very important. Initially it may seem that since the work WildInvestmentand its consistency are entirely dependent on WildStrategy, then if you WildInvestmentjust ignore this exception, it will go to a higher level and you don’t need to do anything else. However, please note that there is a purely architectural problem: the method Maincatches an exception from the architectural one layer, causing the architectural method to another. How does it look in terms of use? Yes, in general, it looks like this:


  • the concern for this exception was simply left over to us;
  • the user of this class is not sure that this exception is passed through a number of methods before us specifically
  • we begin to draw extra dependencies, which we have got rid of, causing the intermediate layer.

However, from this conclusion follows another: catchwe must put in the method DoSomethingWild. And this is somewhat strange for us: it WildInvestmentseems to be very dependent on someone. Those. if PlayRussianRoulettehe could not work, then DoSomethingWildhe did it too: he does not have return codes, but he is obliged to play roulette. What to do in such a seemingly hopeless situation? The answer is really simple: being in another layer, you DoSomethingWildshould throw out your own exception, which relates to this layer and wrap the original one as the original source of the problem - in InnerException:



namespace JetFinance.Strategies
{
    pubilc class WildStrategy
    {
        private Random random =  new Random();
        public void PlayRussianRoulette()
        {
            if(DateTime.Now.Second == (random.Next() % 60))
            {
                throw new StrategyException();
            }
        }
    }
    public class StrategyException : Exception { /* .. */ }
}
namespace JetFinance.Investments
{
    public class WildInvestment
    {
        WildStrategy _strategy;
        public WildInvestment(WildStrategy strategy)
        {
            _strategy = strategy;
        }
        public void DoSomethingWild()
        {
            try
            {
                _strategy.PlayRussianRoulette();
            }
            catch(StrategyException exception)
            {
                throw new FailedInvestmentException("Oops", exception);
            }
        }
    }
    public class InvestmentException : Exception { /* .. */ }
    public class FailedInvestmentException : Exception { /* .. */ }
}
using JetFinance.Investments;
void Main()
{
    var foo = new WildStrategy();
    var boo = new WildInvestment(foo);
    try
    {
        boo.DoSomethingWild();
    }
    catch(FailedInvestmentException exception)
    {
    }
}

Wrapping the exception to others, we essentially transfer the problematic from one application layer to another, making its work more predictable from the point of view of the user of this class: method Main.


For reuse


Very often, we face a difficult task: on the one hand, we are too lazy to create a new type of exception, and when we do decide, it is not always clear what to start with: what type to take as a basis as a baseline. But it is these solutions that determine the entire architecture of exceptional situations. Let's go over popular solutions and draw some conclusions.


When choosing the type of exceptions, you can try to take an existing solution: find an exception with a similar meaning in the name and use it. For example, if we are given an entity through a parameter that for some reason does not suit us, we can throw it away InvalidArgumentException, indicating the cause of the error - in the Message. This scenario looks good, especially considering that it InvalidArgumentExceptionis in the group of exceptions that are not subject to mandatory interception. But the choice will be bad InvalidDataExceptionif you work with any data. Just because this type is in the zone.System.IO, and this is hardly what you do. Those. it turns out that finding an existing type because it’s lazy to make one’s own will almost always be the wrong approach. Exceptions that are created for the general range of tasks almost does not exist. Almost all of them are created for specific situations and their reuse will be a gross violation of the architecture of exceptional situations. Moreover, having received an exception of a certain type (for example, the same one System.IO.InvalidDataException), the user will be confused: on the one hand, he will see the source of the problem in the System.IOnamespace of the exception, and on the other - a completely different namespace of the release point. Plus, thinking about the rules for throwing this exception will go to referencesource.microsoft.com and find all the places of its release :


  • internal class System.IO.Compression.Inflater

And understand that just someone has crooked hands the choice of the type of exception confused it, because the method that threw the exception did not deal with compression.


Also, in order to simplify the reuse, you can simply take and create some one exception, by declaring his field ErrorCodewith an error code and live happily ever after. It would seem: a good solution. Throwing the same exception everywhere, putting the code, catch only one, catchthereby increasing the stability of the application: and nothing else to do. However, please disagree with this position. Acting in this way throughout the application, on the one hand, of course, you simplify your life. But on the other hand, you drop the opportunity to catch a subgroup of exceptions united by some common feature. How is this done, for example, withArgumentExceptionwhich unites the whole group of exceptions by inheritance. The second serious drawback is excessively large and unreadable sheets of code that will organize filtering by error code. But if we take a different situation: when specifying an error should not be important for an end user, the introduction of a generic type plus an error code already looks like a much more correct application:


public class ParserException : Exception
{
    public ParserError ErrorCode { get; }
    public ParserException(ParserError errorCode)
    {
        ErrorCode = errorCode;
    }
    public override string Message
    {
        get {
            return Resources.GetResource($"{nameof(ParserException)}{Enum.GetName(typeof(ParserError), ErrorCode)}");
        }
    }
}
public enum ParserError
{
    MissingModifier,
    MissingBracket,
    // ...
}
// Usage
throw new ParserException(ParserError.MissingModifier);

The code that protects the call to the parser almost always makes no difference for what reason the parsing was filled up: the fact of the error is important to it. However, if it all the same becomes important, the user will always be able to isolate the error code from the property ErrorCode. To do this, it is not necessary to look for the right words by substring in Message.


If you start from ignoring reuse issues, you can create an exception type for each situation. On the one hand, it looks logical: one type of error - one type of exception. However, here, as in everything, the main thing is not to overdo it: having the type of exceptional operations at each emission point causes you to intercept problems: the code of the calling method will be overloaded with blocks catch. After all, he needs to handle all types of exceptions that you want to give him. Another minus is purely architectural. If you do not use inheritance, then you are disorienting the user of these exceptions: there may be a lot in common between them, and you have to intercept them separately.


However, there are good scenarios for introducing individual types for specific situations. For example, when a breakdown occurs not for the whole entity, but for a specific method. Then this type should be in the hierarchy of inheritance to be in such a place so that there is no thought to intercept it along with something else: for example, selecting it through a separate branch of inheritance.


Additionally, if you combine these two approaches, you can get a very powerful toolkit for working with a group of errors: you can enter a generalizing abstract type from which to inherit specific particular situations. The base class (our generic type) must be supplied with an abstract property that stores the error code, and the heirs redefining this property will specify this error code:


public abstract class ParserException : Exception
{
    public abstract ParserError ErrorCode { get; }
    public override string Message
    {
        get {
            return Resources.GetResource($"{nameof(ParserException)}{Enum.GetName(typeof(ParserError), ErrorCode)}");
        }
    }
}
public enum ParserError
{
    MissingModifier,
    MissingBracket
}
public class MissingModifierParserException : ParserException
{
    public override ParserError ErrorCode { get; } => ParserError.MissingModifier;
}
public class MissingBracketParserException : ParserException
{
    public override ParserError ErrorCode { get; } => ParserError.MissingBracket;
}
// Usage
throw new MissingModifierParserException(ParserError.MissingModifier);

What remarkable properties will we get with this approach?


  • on the one hand, we kept catching the exception on the base type;
  • on the other hand, intercepting the exception of the base type preserved the opportunity to learn a specific situation;
  • and plus to everything, it is possible to intercept for a specific type, and not for a basic one, without using the flat structure of classes.

As for me, so very convenient option.


In relation to a single group of behavioral situations


What conclusions can be made, based on the previously described arguments? Let's try to formulate them:


For a start, let's define what is meant by situations. When we talk about classes and objects, we are accustomed to first of all operate with entities with a certain internal state over which we can perform actions. It turns out that by doing so we found the first type of behavioral situation: actions on a certain entity. Further, if you look at the object graph, as if from the outside, you can see that it is logically combined into functional groups: the first is caching, the second is working with databases, and the third performs mathematical calculations. Through all these functional groups there can be layers: the logging layer of various internal states, the logging of processes, the tracing of method calls. Layers can be more encompassing: combining several functional groups. For example, the model layer, layer controllers, view layer. These groups can be both in one assembly, and in completely different, but each of them can create its own exceptional situations.


It turns out that if you argue in this way, you can build a certain hierarchy of types of exceptional situations, based on the type of belonging to a particular group or layer, thereby creating the possibility for code that intercepts exceptions to allow easy semantic navigation in this type hierarchy.


Let's look at the code:



namespace JetFinance
{
    namespace FinancialPipe
    {
        namespace Services
        {
            namespace XmlParserService
            {
            }
            namespace JsonCompilerService
            {
            }
            namespace TransactionalPostman
            {
            }
        }
    }
    namespace Accounting
    {
        /* ... */
    }
}

What does it look like? As for me, namespaces are a great opportunity for the natural grouping of types of exceptions according to their behavioral situations: everything that belongs to certain groups should be there, including exceptions. Moreover, when you get a certain exception, in addition to the name of its type, you will see its namespace, which will clearly define its identity. Remember an example of a bad reuse of a type InvalidDataExceptionthat is actually defined in a namespace System.IO? Its belonging to this namespace means that, in essence, an exception of this type can be thrown from classes in the namespace.System.IOor in a more embedded. But the exception itself was thus thrown out of a completely different place, confusing the researcher of the problem. By focusing exception types on the same namespaces as types, throwing these exceptions, on the one hand, you keep the type architecture consistent, and on the other hand, you make it easier for the end developer to understand the causes.


What is the second way to group at the code level? Inheritance:



public abstract class LoggerExceptionBase : Exception
{
    protected LoggerExceptionBase(..);
}
public class IOLoggerException : LoggerExceptionBase
{
    internal IOLoggerException(..);
}
public class ConfigLoggerException : LoggerExceptionBase
{
    internal ConfigLoggerException(..);
}

Moreover, if in the case of ordinary application entities, inheritance means inheritance of behavior and data, combining types as belonging to a single entity group , in case of exceptions, inheritance means belonging to a single situation group , since the essence of the exception is not the essence, but the problematic.


Combining both methods of grouping, we can draw some conclusions:


  • inside the assembly ( Assembly) there should be a base type of exceptions that this assembly throws. This type of exception must be in the root for the assembly namespace. This will be the first layer of the grouping;
  • there may be one or several different namespaces inside the assembly itself. Each of them divides the assembly into some functional zones, thereby determining the groups of situations that arise in this assembly. These can be zones of controllers, database entities, data processing algorithms and others. For us, these namespaces are the grouping of types by functional affiliation, and from the point of view of exceptions, the grouping into problem zones of the same assembly;
  • exceptions can be inherited only from types in the same namespace or in a more root one. This ensures an unambiguous understanding of the situation by the end user and the absence of interception of left-wing exceptions when intercepting by the base type. Agree: it would be strange to receive global::Finiki.Logistics.OhMyException, having catch(global::Legacy.LoggerExeption exception), but the following code looks absolutely harmonious:

namespace JetFinance.FinancialPipe
{
    namespace Services.XmlParserService
    {
        public class XmlParserServiceException : FinancialPipeExceptionBase
        {
            // ..
        }
        public class Parser
        {
            public void Parse(string input)
            {
                // ..
            }
        }
    }
    public abstract class FinancialPipeExceptionBase : Exception
    {
    }
}
using JetFinance.FinancialPipe;
using JetFinance.FinancialPipe.Services.XmlParserService;
var parser = new Parser();
try {
    parser.Parse();
}
catch (XmlParserServiceException exception)
{
    // Something wrong in parser
}
catch (FinancialPipeExceptionBase exception)
{
    // Something else wrong. Looks critical because we don't know real reason
}

Notice what happens here: we, as a user code, call some library method that, as far as we know, can throw an exception under certain circumstances XmlParserServiceException. And, as far as we know, this exception is in the namespace, inheriting JetFinance.FinancialPipe.FinancialPipeExceptionBase, which indicates a possible omission of other exceptions: microservice now XmlParserServicecreates only one exception, but others may appear in the future. And since we have a convention in creating types of exceptions, we know for sure from whom this new exception will be inherited and set up in advance summarizing catchwithout affecting anything superfluous: what is not in our area of ​​responsibility will fly past.


How to build a type hierarchy?


  • First you need to make a base class for the domain. Let's call it the domain base class. The domain in this case is a word generalizing a certain number of assemblies that unites them according to a certain global feature: logging, business logic, UI. Those. the largest functional areas of the application;
  • Next, you need to enter an additional base class for exceptions that should be intercepted: all exceptions will be inherited from it, which will be intercepted by the keyword catch;
  • All exceptions that indicate fatal errors - inherit directly from the domain base class. Thus, you have separated them from the intercepted architecturally;
  • Split the domain into functional zones by namespaces and declare the basic type of exceptions that will be thrown from each functional zone. Here you should additionally use common sense: if an application has a large nesting of namespaces, then of course you shouldn’t do it by a basic type for each level of nesting. However, if at some level of nesting a branching occurs: one group of exceptions went into one subspace of names, and the other into another, then, of course, it is worthwhile to introduce two basic types for each subgroup;
  • Private exceptions inherit from functional zone exception types
  • If a group of private exceptions can be combined, combine them with another basic type: this is how you simplify their interception;
  • If it is assumed that the group will be intercepted by its base class more often, enter Mixed Mode with ErrorCode.

By source of error


Another reason for combining exceptions in some group may be the source of the error. For example, if you are developing a class library, then groups of sources can be:


  • Calling unsafe code that failed. This situation should be handled as follows: wrap the exception or the error code in its own type of exception, and the resulting data about the error (for example, the original error code) in the public property of the exception;
  • Calling code from external dependencies that caused exceptions that our library cannot catch, because they are not part of her area of ​​responsibility. This may include exceptions from the methods of those entities that were taken as parameters of the current method or the constructor of the class whose method caused external dependencies. As an example, the method of our class called the method of another class, an instance of which was obtained through the parameters of the method. If an exception says that we ourselves were the source of the problem - we generate our own exception while preserving the original - in InnerExcepton. If we understand that the problem is precisely in the work of external dependency, we skip the exception through as belonging to the group of external non-monitoring dependencies;
  • Our own code that was randomly entered into a non-consistent state. A good example is text parsing. There are no external dependencies, there is no care unsafe, but there is a parsing error.

Link to the whole book




Also popular now: