Wyjątek czy rezultat?

Gdy piszemy metodę, która ma coś wykonać i ta operacja się nie powiedzie, zastanawiamy się co w takiej sytuacji zrobić: czy lepiej jest rzucić wyjątek, czy może zwrócić rezultat z odpowiednim statusem? Przyjrzyjmy się obu rozwiązaniom.

Wprowadzenie

Na początek załóżmy, że mamy 3 interfejsy:

public interface ISomeObject1Service
{
    Task DoSomeWork(int value);
}

public interface ISomeObject2Service
{
    Task DoSomeMoreWork(int value);
}

public interface ISomeObject3Service
{
    Task DoSomeOtherWork(int value);
}

Te interfejsy są używane w jakiejś klasie:

public class SomeObjectManager
{
    private readonly ISomeObject1Service _someObject1Service;
    private readonly ISomeObject2Service _someObject2Service;
    private readonly ISomeObject3Service _someObject3Service;

    public SomeObjectManager(
        ISomeObject1Service someObject1Service,
        ISomeObject2Service someObject2Service,
        ISomeObject3Service someObject3Service
    )
    {
        _someObject1Service = someObject1Service;
        _someObject2Service = someObject2Service;
        _someObject3Service = someObject3Service;
    }

    public async Task<bool> DoSomething(int value)
    {
        await _someObject1Service.DoSomeWork(value);
        await _someObject2Service.DoSomeMoreWork(value);
        await _someObject3Service.DoSomeOtherWork(value);

        return true;
    }
}

A ta klasa (SomeObjectManager) jest użyta w jakiejś jeszcze innej klasie, np. w kontrolerze:

public class SomeObjectController : ControllerBase
{
    private readonly SomeObjectManager _someObjectManager;

    public SomeObjectController(SomeObjectManager someObjectManager)
    {
        _someObjectManager = someObjectManager;
    }

    [HttpGet("{value}")]
    public async Task<IActionResult> Execute(int value)
    {
        var result = await _someObjectManager.DoSomething(value);

        return new OkObjectResult(result);
    }
}

Na ten moment nie mamy żadnej obsługi sytuacji, w której coś się może nie powieść. Spróbujmy te klasy zmodyfikować tak, aby tę obsługę dodać. Najpierw zrobimy to z użyciem wyjątków, a następnie z użyciem rezultatu.

Załóżmy, że implementacje naszych interfejsów wyglądają jak poniżej, a sytuacja „coś idzie nie tak” jest wtedy, kiedy funkcja nie wchodzi w warunek if:

public class SomeObject1Service : ISomeObject1Service
    {
        public async Task DoSomeWork(int value)
        {
            if (value > 10)
            {
                ...
            }
        }
    }

    public class SomeObject2Service : ISomeObject2Service
    {
        public async Task DoSomeMoreWork(int value)
        {
            if (value > 5)
            {
                ...
            }
        }
    }

    public class SomeObject3Service : ISomeObject3Service
    {
        public async Task DoSomeOtherWork(int value)
        {
            if (value > 20)
            {
                ...
            }
        }
    }

Wyjątek

Na początek zdefiniujmy wyjątek, który może być zwracany, gdy coś pójdzie nie tak:

public class MyException : Exception
{
    public MyException() : base("Operation cannot be processed.")
    {
    }
}

Teraz w implementacji każdego z interfejsów dodajemy rzucanie tego wyjątku, kiedy warunek if nie jest spełniony:

public class SomeObject1Service : ISomeObject1Service
{
    public async Task DoSomeWork(int value)
    {
        if (value > 10)
        {
            ...
            return;
        }

        throw new MyException();
    }
}

public class SomeObject2Service : ISomeObject2Service
{
    public async Task DoSomeMoreWork(int value)
    {
        if (value > 5)
        {
            ...
            return;
        }

        throw new MyException();
    }
}

public class SomeObject3Service : ISomeObject3Service
{
    public async Task DoSomeOtherWork(int value)
    {
        if (value > 20)
        {
            ...
            return;
        }

        throw new MyException();
    }
}

Ten wyjątek musimy przechwycić. Najlepiej jest to zrobić w klasie, która łapie wszystkie nieprzechwycone wyjątki, np. w GlobalExceptionFilter (pisałem o tym tutaj). Taką klasę prawdopodobnie i tak zawsze będziemy mieli już zdefiniowaną, a na dodatek nie będziemy musieli zmieniać nic więcej.

Jeśli jednak mamy potrzebę złapania tego wyjątku w naszej metodzie, możemy to zrobić tak:

public async Task<IActionResult> Execute(int value)
{
    try
    {
        var item = await _someObjectManager.DoSomething(value);

        return new OkObjectResult(item);
    }
    catch (MyException e)
    {
        return new BadRequestObjectResult(e.Message);
    }
}

W większości przypadków nie ma najmniejszej potrzeby, aby zmieniać cokolwiek w klasie SomeObjectManager. Ta klasa może pozostać w takiej formie, jaką ją zdefiniowaliśmy na początku – czystą i schludną.

Teraz przyjrzyjmy się, jak nasz kod wyglądałby, jeśli zamiast rzucać wyjątek, zwrócilibyśmy rezultat.

Rezultat

Na początku zdefiniujmy enum, w który będzie zawierał potrzebne nam statusy:

public enum OperationResultStatus
{
    Success,
    Error
}

Teraz przejdźmy do stworzenia klasy naszego rezultatu. Może ona wyglądać tak:

public class OperationResult
{
    public OperationResult(string message, OperationResultStatus status)
    {
        Message = message;
        Status = status;
    }

    public string Message { get; }
    public OperationResultStatus Status { get; }

    public static OperationResult Success()
    {
        return new OperationResult(string.Empty, OperationResultStatus.Success);
    }

    public static OperationResult Error(string message)
    {
        return new OperationResult(message, OperationResultStatus.Error);
    }
}

To nam wystarczy dla metod, które nic nie zwracają. Jeśli mielibyśmy potrzebę wraz ze statusem operacji zwrócić jakąś wartość, to możemy następująco rozszerzyć nasz OperationResult:

public class OperationResult<T> : OperationResult
{
    public OperationResult(T value, string message, OperationResultStatus status) 
        : base(message, status)
    {
        Value = value;
    }

    public T Value { get; }

    public static OperationResult<T> Success(T value)
    {
        return new OperationResult<T>(value, string.Empty,
            OperationResultStatus.Success);
    }

    public static OperationResult<T> Error(OperationResult operationResult)
    {
        return new OperationResult<T>(default, operationResult.Message,
            OperationResultStatus.Error);
    }
}

Klasy potrzebne do zwracania rezultatu operacji mamy już zdefiniowane, przejdźmy teraz do modyfikacji naszego kodu.

Metoda Execute w naszym kontrolerze mogłaby wyglądać następująco:

public async Task<IActionResult> Execute(int value)
{
    var doSomethingResult = await _someObjectManager.DoSomething(value);

    if (doSomethingResult.Status != OperationResultStatus.Error)
    {
        return new OkObjectResult(doSomethingResult.Value);
    }

    return new BadRequestObjectResult(doSomethingResult.Message);
}

Natomiast metoda DoSomething z klasy SomeObjectManager wyglądałaby tak:

public async Task<OperationResult<bool>> DoSomething(int value)
{
    var doSomeWorkResult = await _someObject1Service.DoSomeWork(value);
    if (doSomeWorkResult.Status == OperationResultStatus.Error)
    {
        return OperationResult<bool>.Error(doSomeWorkResult);
    }

    var doSomeMoreWorkResult = await _someObject2Service.DoSomeMoreWork(value);
    if (doSomeMoreWorkResult.Status == OperationResultStatus.Error)
    {
        return OperationResult<bool>.Error(doSomeMoreWorkResult);
    }

    var doSomeOtherWorkResult = await _someObject3Service.DoSomeOtherWork(value);
    if (doSomeOtherWorkResult.Status == OperationResultStatus.Error)
    {
        return OperationResult<bool>.Error(doSomeOtherWorkResult);
    }

    return OperationResult<bool>.Success(true);
}

Tutaj warto zwrócić uwagę, że zmienił się typ zwracany przez tę metodę. Początkowo zwracaliśmy Task<bool>, a teraz zwracamy Task<OperationResult<bool>>.

Podobnie zmieni się typ metod w naszych interfejsach – z Task nas Task<OperationResult>:

public interface ISomeObject1Service : ITransient
{
    Task<OperationResult> DoSomeWork(int value);
}

public interface ISomeObject2Service : ITransient
{
    Task<OperationResult> DoSomeMoreWork(int value);
}

public interface ISomeObject3Service : ITransient
{
    Task<OperationResult> DoSomeOtherWork(int value);
}

I na koniec nasze serwisy. Teraz wyglądałby one następująco:

public class SomeObject1Service : ISomeObject1Service
{
    public async Task<OperationResult> DoSomeWork(int value)
    {
        if (value > 10)
        {
            ...
            return OperationResult.Success();
        }

        return OperationResult.Error("Operation cannot be processed.");
    }
}

public class SomeObject2Service : ISomeObject2Service
{
    public async Task<OperationResult> DoSomeMoreWork(int value)
    {
        if (value > 5)
        {
            ...
            return OperationResult.Success();
        }

        return OperationResult.Error("Operation cannot be processed.");
    }
}

public class SomeObject3Service : ISomeObject3Service
{
    public async Task<OperationResult> DoSomeOtherWork(int value)
    {
        if (value > 20)
        {
            ...
            return OperationResult.Success();
        }

        return OperationResult.Error("Operation cannot be processed.");
    }
}

Co lepsze?

Mamy dwa rozwiązania, które mogą nam pomóc w danej sytuacji. Spróbujmy przeanalizować wady i zalety każdego z nich:

Wyjątek

Główną zaletą tego rozwiązania jest to, że mamy mniej kodu do modyfikacji – nie musimy zmieniać typów zwracanych przez metody. Ponadto mamy mniej kodu do dodania – wyjątek możemy przechwycić dopiero w metodzie wejściowej albo w klasie, która służy do łapania wszystkich wyjątków. Moim zdaniem również kod napisany z użyciem wyjątków wydaje się prostszy, bardziej schludny i elegancki – brak w nim nadmiarowych if, oraz pisząc go, nie musimy się przejmować tym, że coś może pójść nie tak.

Niestety to rozwiązanie ma też swoje wady. Największą z nich jest ograniczona kontrola nad tym, co się dzieje – gdy rzucamy wyjątek, aplikacja automatycznie przechodzi do instrukcji catch. Ta instrukcja, aby kod był „czystszy”, z reguły znajduje się albo w metodzie wejściowej, albo w klasie, która łapie wszystkie wyjątki. Jeśli przyjdzie potrzeba zareagowania na danych wyjątek gdzieś w środku, nasz kod zaczyna być ciężki w zrozumieniu. Gdybyśmy potrzebowali wywołanie każdej metody opakować w blok try {} catch {}, to… nie idźmy tą drogą. Bywają też sytuacje, kiedy w momencie pojawienia się jakiegoś wyjątku, chcemy wywołać pewną metodę. Ta metoda również może spowodować wyjątek i musimy umieścić ją w bloku try {} catch {}. Takie podejście powoduje, że nasz kod staje się trudniejszy w zrozumieniu. Dodatkowo nawigowanie po takim kodzie jest męczące. Nie mówiąc już o tym, że czasem może być ciężko połapać się, gdy podczas debugowania jesteśmy w jednym miejscu pliku i nagle aplikacja przenosi nas do zupełnie innego pliku, w zupełnie innym projekcie, ponieważ rzuciliśmy wyjątek.

Rezultat

Tutaj zacząłbym od wad. Największą z nich jest to, że musimy zarówno dodać, jak i zmodyfikować, dużą ilość kodu – zmiana w każdej metodzie zwracanego typu na OperationResult<> może spowodować zniechęcenie. A gdy do tego dodamy jeszcze, że do każdego wywołania metody musimy dodać instrukcję if sprawdzającą status operacji, to może nas rozboleć głowa. Metoda składająca się z 4 linijek (wywołanie 3 metod i zwrócenie rezultatu), przeradza się w metodę składającą się z 13 linijek (dla każdego wywołania metody dochodzą 3 linijka dla instrukcji if) – nie licząc pustych linii, która sprawią, że nasz kod będzie chociaż trochę czytelny. Nie wygląda to najładniej.

Z drugiej jednak strony, jeśli chodzi o zalety, to w zasadzie są one przeciwieństwem wad wyjątków. Tutaj mamy ogromną kontrolę nad tym, co się dzieje. Możemy dowolnie zareagować na daną sytuację. Ponadto, gdy logika obsługi błędów zacznie się rozrastać, nasz kod wciąż będzie wyglądał mniej więcej tak samo, jego jakość się znacząco nie pogorszy. Dodatkowo możemy zdefiniować różne rezultaty, obsługujące różne statusy – wtedy patrząc na typ zwracany przez daną metodę, od razu będziemy wiedzieli, czego możemy się po niej spodziewać.

Co wybrać?

Nie ma złotego środka. Patrząc po wadach i zaletach, można mieć wrażenie, że lepszą opcją jest od samego początku skorzystanie z rezultatu – nasz kod nie będzie aż tak ładny, ale za to będziemy mieli pełną kontrolę i łatwość w obsłudze kolejnych sytuacji. Jednakże jak chciałem pokazać na zaprezentowany przeze mnie przykładzie – czasem kod z użyciem wyjątków wcale nie jest taki zły i w danej sytuacji może prezentować się lepiej. Najważniejsze to zdawać sobie sprawę z wad i zalet każdego z podejść, i stosować je z głową. Czasem nawet nie musimy decydować się na jedno z nich – możemy je połączyć i otrzymać najlepsze na dany moment rozwiązanie.

7 myśli na “Wyjątek czy rezultat?”

  1. Pingback: dotnetomaniak.pl

    1. Jak najbardziej się zgadzam — w wielu sytuacjach będzie to antywzorzec. W większości przypadków lepiej sprawdzi się zwracanie rezultaty. Są jednak sytuacje, kiedy można z tego skorzystać, jednak należy to zrobić z głową, w pełni świadomie.

  2. Kurde, myślałem że dasz mi odpowiedź co lepsze. Ja ciągle nie wiem i nie potrafię się zdecydować. Preferuje Result, ale nie umiem tego mocno obronić.
    Widziałem także rozwiązania, które zwracały jednocześnie sukces i błąd (nie umiem tego teraz znaleźć) co powodowało, że zamiast ifów (jawnych) były przypięte wywołania metod – onSuccess lub onError. Niby jaśnie, ale kodu jeszcze więcej i zakręcenia, myślę że to mogłoby działać, gdyby wszyscy w projekcie to ogarniali.
    Tak czy siak, ciężki wybór 😉

    1. Ja nadal nie stosowałbym wyjątków do sterowania przepływem aplikacji (dobrze wspomina Maciej). Wyjątki są nie od tego.
      Od czego są zatem te wyjątki? W moim odczuciu do komunikowania, że zaszło coś nieoczekiwanego. Jeśli piszemy logike biznesową, a w niej if (else), to śmiem twierdzić, że było to przynajmniej spodziewane.

  3. Dobry temat 🙂 Dodam swoje 5 groszy. Od jakichś dwóch lat preferuję właśnie w kodzie koncepcję OperationResult (pod krótszą postacią Result) i widzę wady i zalety podobne do tych, które wymieniłeś Adrian. W kodzie zdecydowałem się przyjąć filozofię, że wszelkie publiczne metody serwisów zwracają Result. Co do wyjątków, to dalej je stosuję, ale już tylko w bebechach serwisów. Chodzi o to, aby żaden wyjątek nie wyskoczył poza serwis, bo tylko on tak naprawdę będzie najlepiej wiedział co ma zrobić z tym wyjątkiem i jak go najsensowniej interpretować. Klienci serwisów operują już tylko na Result IsSuccess i Error, u mnie, typu IError, który zawiera treść komunikatu błędu i jakieś dowolne info kontekstowe pod typem object (no tutaj, akurat omijam typowanie… ale mi to zbytnio nie przeszkadza, bo i tak świetnie się serializuje do JSON’a, kiedy jest potrzeba) 🙂 Klienci serwisów najczęściej jedynie ograniczają sie do zalogowania błędu, kiedy IsSuccess jest false i ewentualne opakowanie i przepchanie dalej, jeżeli jest sens, obiektu Error.

Leave a Reply