Jak charakterystyki architektoniczne mogą kształtować obsługę wyjątków w .NET

W świecie .NET można wyróżnić wiele sposobów obsługi błędów. Należą do nich: rzucanie wyjątków, metody TryXXX, obiekty Result, a nawet mechanizmy oparte na typach OneOf. Choć temat jest znany i opisywany od lat, wiele decyzji dotyczących ich obsługi podejmujemy bez świadomości, że są to decyzje architektoniczne.

W zależności od rodzaju systemu, jego etapu rozwoju oraz otoczenia biznesowego, jedne charakterystyki architektoniczne mogą dominować nad pozostałymi. To właśnie je powinniśmy brać pod uwagę podczas wyboru stylu obsługi błędów. Oznacza to, że nie ma jednego słusznego rozwiązania, a wybór zależy od tego, co chcemy osiągnąć jako projekt.

W artykule przedstawię różne podejścia do zarządzania nieoczekiwanym stanem w C#, zwracając uwagę na to, jak wybory mogą wspierać wymagania niefunkcjonalne naszej aplikacji.

Wymagania niefunkcjonalne, a podejście do błędów

Resiliency (odporność)

Posłużmy się prostym systemem śledzenia przesyłek, w którym klienci sprawdzają status swoich paczek. Aby ograniczyć obciążenie systemu, dane o przesyłkach są przechowywane w cache z czasem życia ustawionym na jedną godzinę.

Jeśli podczas odświeżania pamięci podręcznej wystąpi błąd (np. przez chwilową niedostępność zewnętrznej usługi), system nie powinien przerywać działania. Zamiast tego może:

  • Zwrócić wartość domyślną lub pusty wynik.

  • Zarejestrować problem i użyć nieaktualnych danych.

  • Podjąć próbę naprawy.

W rozwiązaniach stawiających na resiliency kluczowe jest unikanie nadmiernego używania wyjątków, co pozwala świadomie decydować o sposobie reakcji na problem i zapewnia większą elastyczność działań naprawczych.

Zamiast przerywać działanie:

throw new ExternalServiceUnavailableException();

Można zareagować w kontrolowany sposób:

if (!_shipmentApi.TryRefreshShipmentStatus(trackingId, out var shipmentStatus))
{
    return Result.Fail("Shipment status could not be refreshed.");
}

return Result.Ok(shipmentStatus);

Albo podjąć działanie naprawcze:

var cachedData = _cache.GetShipmentStatus(trackingId);
if (cachedData.IsExpired && !_shipmentApi.TryRefreshShipmentStatus(trackingId, out var shipmentStatus))
{
    Log.Warning("Failed to refresh expired shipment status. Using cached data.");
    return cachedData;
}

return shipmentStatus ?? cachedData;

Maintainability (utrzymywalność)

Pomyślmy o starym, wielki system finansowy w stadium utrzymania.

Programiści, którzy pracują przy jego łataniu, zdecydowaną większość czasu spędzają na czytaniu istniejącego kodu i szukaniu źródeł problemów. Żeby usprawnić proces, możemy:

  • Weryfikować poprawność danych jak najbliżej punktu wejścia oraz jasno sygnalizować wykryte problemy.

  • Jasno komunikować, dlaczego dane nie mogą zostać przetworzone.

  • Udostępniać informacje, które pomogą w namierzeniu nieprawidłowości.

W legacy, zamiast pozwalać na propagację nieprawidłowego stanu i ręczną diagnostykę na podstawie wyjątków systemowych takich jak NullReferenceException w zestawieniu z ręcznie wyszukiwanymi danymi, staramy się maksymalnie uprościć szukanie przyczyny problemów.

Zamiast pozwalać na ciche błędy:

public string GetClientAddress(Guid clientId)
{
    var client = GetClientDetails(clientId);
    return client.Address;
}

Lepiej jasno i od razu sygnalizować problem:

public string GetClientAddress(Guid clientId)
{
    var client = GetClientDetails(clientId);
    if (client is null)
    throw new ClientNotFoundException($"Client details could not be retrieved. Client ID: {clientId}");

    if (string.IsNullOrWhiteSpace(client.Address))
    throw new InvalidClientDataException($"Client address is missing. Client ID: {clientId}");

    return client.Address;
}

Simplicity (prostota)

Wyobraźmy sobie szybki projekt MVP — aplikację do zamawiania kawy online, która ma jedynie sprawdzić zainteresowanie użytkowników przed rozbudową pełnej platformy.

W projekcie typu MVP lub PoC celowo rezygnujemy z rozbudowanej walidacji, koncentrując się na szybkim dostarczeniu wartości zamiast perfekcyjnej obsługi wszystkich przypadków. Jeśli coś pójdzie nie tak, aplikacja może po prostu zakończyć działanie błędem, co jest akceptowalne na tym etapie rozwoju. W takim podejściu możemy:

  • Nie sprawdzać przypadków negatywnych — ewentualne błędy zostaną wykryte naturalnie przez „wykrzaczenie” systemu.

  • Rzucać proste wyjątki, gdy napotkamy problem, bez tworzenia rozbudowanej hierarchii wyjątków.

Zamiast tworzyć walidację i własne wyjątki:

public Order CreateOrder(OrderRequest request)
{
    if (request == null)
    throw new InvalidOrderException("Order request is null.");

    if (request.ProductId == null)
    throw new InvalidOrderException("Product ID is missing.");

    return new Order(request.ProductId, request.Quantity);
}

Lepiej pozwolić systemowi samemu zgłosić problem:

public Order CreateOrder(OrderRequest request)
{ 
    // Brak walidacji, jeśli coś pójdzie nie tak, pojawi się naturalny wyjątek
    return _orders.First();
}

Jeśli już reagujemy, to prosto i bez zbędnych klas wyjątków:

if (!isValid)
throw new InvalidOperationException("Invalid request");

Performance (wydajność)

W .NET 9 wprowadzono istotne usprawnienia w mechanizmie obsługi wyjątków, znacząco poprawiając jego wydajność. Nowa implementacja opiera się na architekturze NativeAOT, co zauważalnie obniża koszt obsługi wyjątków, szczególnie w prostych blokach catch i podczas operacji asynchronicznych. Szczegóły tych zmian można znaleźć w oficjalnej dokumentacji Microsoft:
What's new in .NET 9 – Runtime Improvements.

Mimo tych optymalizacji wyjątki nadal przerywają przepływ wykonania, co może utrudniać optymalizację kodu i negatywnie wpływać na jego przewidywalność, zwłaszcza w sekcjach krytycznych pod względem wydajności. Dlatego w takich miejscach warto:

  • Unikać wyjątków (np. w pętlach, przy parsowaniu, w przetwarzaniu równoległym).

  • Stosować metody TryXXX lub prostą walidację, by zachować płynność wykonania.

Przykład do zastosowania w kodzie krytycznym wydajnościowo:

if (!decimal.TryParse(input, out var value))
return Result.Fail($"Not parsed into decimal: {input}");

Podsumowanie

Nie ma jednej, uniwersalnej strategii obsługi wyjątków — każda decyzja powinna wynikać ze świadomego wyboru, zgodnego z charakterystykami architektonicznymi systemu. Raz będzie to prostota, innym razem pełna kontrola nad poprawnością danych, a jeszcze innym — odporność systemu na nieprzewidziane sytuacje.

A Ty? Jakie podejście najczęściej stosujesz w swoich projektach?

  • Pozwalasz, by wyjątki systemowe propagowały się dalej?

  • Rzucasz własne wyjątki z dodatkowymi informacjami?

  • Stosujesz wzorzec Result zamiast wyjątków?

  • A może próbujesz automatycznie naprawiać dane i kontynuować przetwarzanie?

Czy przy wyborze sposobu obsługi błędów uwzględniasz charakterystyki architektoniczne swojego systemu?

0
Subscribe to my newsletter

Read articles from Beniamin Lenarcik directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Beniamin Lenarcik
Beniamin Lenarcik

.NET Developer | I build applications from concept to production. On my blog, I share practical examples (.NET “by example”) and thoughts on software architecture and bridging technology with business.