Obsługa błędów za pomocą sealed class

1

Piszę nowy projekt w Kotlinie i zastanawiam się jak zamodelować jego architekturę i obsługę błędów. Po pierwsze, na pewno chciałbym skorzystać z sealed class do obsługi błędów, zamiast Javowego podejścia z rzucaniem "biznesowych" RuntimeException.
No i mam powiedzmy dwie klasy które wykonują jakąś akcję, np. zapytanie do innego API, walidacje jakiś parametrów, czy zapis do bazy danych, nie ma to znaczenia, w każdym razie zwracają one jakiś sealed class który może oznaczać poprawne wykonanie operacji, albo jakiś błąd

sealed class FirstServiceResult {
    object Success: FirstServiceResult()
    object YNotFound: FirstServiceResult()
}

sealed class SecondServiceResult {
    object Success: SecondServiceResult()
    object InvalidArgumentX: SecondServiceResult()
}

No i te dwie operacje są składową jednej większej operacji, czyli np. FirstService wyszukuje coś korzystając z zewnętrznego API, a SecondService waliduje wynik wyszukiwania. Całość jest jednak wołana z jakiegoś MainService, który musi zawołać najpierw jeden a potem drugi serwis i zmapować to na swoj rezultat, czyli np.

sealed class MainServiceResult {
    object Success: MainServiceResult()
    object YNotFound: MainServiceResult()
    object InvalidArgumentX: MainServiceResult()
}

Co potem w warstwie wyżej controllera, mogę mapować na odpowiednie kody HTTP, np. 200, 404, 400

Tyle że jak widać na powyższym przykładzie, MainServiceResult musi duplikować wszystkie wartości z FirstServiceResult i SecondServiceResult, gdzie w realnym przykładzie jest dużo więcej wartości które trzeba powielić.

Jest jakiś sprytny sposób jak to rozwiązać? Myślałem by ewentualnie FirstService i SecondService, które tak naprawdę obsługują wyjątki rzucone z warstwy infra (DB, API), po prostu propagowały te wyjątki wyżej i wtedy MainService mapowałby te wyjątki na odpowiedni sealed class, jednak wydaje mi się to brzydkie z dwóch powodów - po pierwsze, wolałbym te wyjątki obsłużyć jak najniżej się da i nie propagować ich wyżej, a po drugie to troche mieszanie dwóch podejść. Z drugiej jednak strony, sealed class poniekąd wymusza duplikacje różnych rezultatów w dwóch różnych miejscach. Dlatego pytanie, jak inaczej podejść do rozwiązania tego problemu?

0

Jak zawsze, mając bardzo abstrakcyjny przykład ciężko na jego temat debatować. Wydaje mi się, że duplikacja, którą przedstawiłeś nie jest z zasady zła. Błędy MainServiceResult mogą być czymś zupełnie innym niż FirstServiceResult, bo np. NotFound może zostać obsłużone przez jakiegoś rodzaju fallback.
Jeśli Ci to przeszkadza nie widzę też problemu, żeby serwisy zwracały od razu błędy MainServiceResult, bo nadal mamy zachowaną komunikację jednostronną (jeśli rzeczywiście idą one wyżej 1:1). Musisz się zastanowić czy te błędy powinny zostać rozdzielone czy nie, bo np. MainService jest jakimś serwisem aplikacyjnym, a FirstService warstwą infrastruktury - wtedy pewnie chcemy mówić na różnych poziomach różnymi językami. Np. wyżej będziemy mówić o błędzie Nie znaleziono danego użytkownika, a niżej Brak dokumentu User w bazie.

Tak jak napisałeś łapanie wyjątków jak najniżej (ponieważ zewnętrzne biblioteki je rzucają) jest dobrym podejściem - zwiększa opisowość Twojego API, lepiej się testuje i zmniejsza ilość błędów.

1

Ale warstwa wyżej nie powinna znać wyjątków z warstwy niższej! Co więcej, to warstwa domeny definiuje kontrakt, a w warstwie infrastruktury jest on po prostu zaimplementowany.
Inaczej umująć, CurenncyExchangeRateProvider jest w domenenie, a CurrencyExchangeRestRateProvider w infrastrukturze.

0

Dobra, to może nieco bardziej konkretnie.
Mamy InvoiceUploader, który jest odpowiedzialny za połączenie z jakimś legacy systemem przez libkę napisaną w Javie (pod spodem lecą jakieś zwykłe RESTy) i zapisanie faktury.
Mamy też InvoiceValidator, który odpowiada za sprawdzenie poprawności faktury wedlug jakiś zasad biznesowych.
No i jest to wołane z powiedzmy InvoiceService, który jest wołany z InvoiceController, którego zadanie to po pierwsze:

  • walidacja faktury, co może zwrócić X powodów dla którego faktura nie jest poprawna
  • upload faktury, tutaj błędy tak naprawdę wracaja z zewnętrznej biblioteki, która rzuca wyjątki zarówno typu: 503 Service Unavailable jak i jakieś 400 Bad Request

Jak widać, mamy dwa różne sealed class, jeden opisuje że np. NIP na fakturze nie istnieje w systemie, a drugi że zerwało połączenie, albo użytkownik nie podał jakiś wymaganych danych do działania libki. Tutaj już rodzi się pytanie, skoro to input użytkownika, to może warto go walidować już na wejściu do naszego systemu? Tyle że, po to mamy biblioteke która za to odpowiada, żeby tego nie robić ręcznie, więc prościej wydaje się po prostu zrobić prostę walidacje za pomocą biblioteki i owrappować rezultat w postaci wyjątku w sealed class.

Tyle że, tym co trafi do kontrollera u nas, ma być InvoiceServiceResult, który będzie zmapowany na odpowiedni kod i wiadomosć błędu. Tyle że tak naprawdę, jest to InvoiceUploaderResult || InvoiceValidatorResult.

W każdym razie, widzę chyba ewentualne rozwiązanie. Zakłada ona że serwisy infra posługują się tak jakby kontraktem domeny, no ale chyba nic lepszego nie wymyślę

sealed class InvoiceServiceResult

sealed class InvoiceValidatorResult: InvoiceServiceResult {
//...
}

sealed class InvoiceUploaderResult: InvoiceServiceResult {
//...
}

Zarejestruj się i dołącz do największej społeczności programistów w Polsce.

Otrzymaj wsparcie, dziel się wiedzą i rozwijaj swoje umiejętności z najlepszymi.