Gdzie walidować, a gdzie nie?
Cześć!
W zeszłym tygodniu pisałem o tym, żę lepiej mówić, a nie pytać. Opowiedziałem trochę o tym jak dzięki tej zasadzie możemy zbudować lepsze w użytkowaniu i bardziej wydajny kod. Dzisiaj chciałbym podrążyć okolice tego tematu i opowiedzieć trochę o moim podejściu do walidacji.
Zacznijmy od banału, który każdy niby wie. Nigdy nie powinniśmy ufać danym wejściowym z “zewnętrznego świata”. Zarówno request do Web API, zdarzenie z kolejki, ale i (uwaga, uwaga!) baza danych są danymi spoza “naszego świata”. Zabrzmi to nieco paranoicznie, ale nigdy nie powinniśmy ufać temu co zewnętrzny świat nam ma do zaproponowania.
No dobrze, to kiedy ktoś może nam wysłać złe dane? Może się tak stać gdy:
- walidacji na frontendzie nie było lub była brakowało warunków. Nie możemy zakładać, że inny programista lub my sami wszystko dopniemy i ujednolicimy. Im więcej jest elementów w łańcuchu, tym większa szansa, że coś zostanie przeoczone. Może też się tak zdarzyć, że API zmieniło swoją logikę biznesową, a frontend o tym jeszcze nie wie.
- walidacja na froncie nie jest w stanie zweryfikować warunków - np. czy produkt jest jeszcze dostępny, czy nie ma już takiego użytkownika z tym samym email. Tak, odpytanie backendu o te warunki nie wystarczy. Dlaczego? Odsyłam do poprzedniego maila.
- mamy API jako produkt. Wtedy każdy może sobie sam strzelić requestem dowolnego typu. Może nam wysłać pusty JSON, może XML zamiast JSONa.
null
gdy pole jest wymagane, pola może w ogóle brakowac, albo mieć totalnie inny typ (string zamiast numeru, tablica zamiast pojedynczej wartości, itd. itp.) - ktoś może celowo próbować nam zaszkodzić. Strzelać niepoprawnymi requestami próbując coś zepsuć, albo robić tzw. “data scrapping”. Czyli strzelać do API próbując znaleźć dziury i poprzez analizę odpowiedzi wyciągnąć dane.
No ogólnie może się wszystko wydarzyć. Podobnie może być z bazą danych. Wydaje nam się, że przecież my za to odpowiadamy. Ale jak przekażemy młodszych nas do tego, żeby od razu strukturę bazy zrobili docelową? Co jak kiedyś pole było nienullowalne, a teraz już jest? Albo co gorsza jak kilka zespołów orze schemat tej samej bazy? Albo jak każdy klient naszego systemu ma swoją bazę i jest ona niby prawie taka sama, ale jednak może być różna?
Tak jak to mówił Fox Mulder - “Trust No One”.
Skoro wiemy, że nikomu nie możemy ufać, to jak żyć? Komuś musimy ufać przecież. Możemy spróbować zaufać własnemu kodu. Ale ostrożnie z tym. Kontrola podstawą zaufania.
Ja podchodzę następująco:
-
Klasy requestów API robię jako zwykłe obiekty z podstawowymi typami, które oczekuję, że dostanę. Zakładam w nich, że wszystko może być nullem, wszystko może się nie zgadzać. Ogólnie mają one służyć do tego, żeby zdeserializować request i nic nie walidować. To co jest zadaniem takiej klasy to po prostu zmapowanie danych z requestu do klasy w kodzie. Taką klasę zwykle trzymam w projekcie API. To jest po prostu kontrakt. Przykład: https://github.com/oskardudycz/EventSourcing.NetCore/blob/main/Workshops/PracticalEventSourcing/Carts/Carts.Api/Requests/Carts/AddProductRequest.cs
public class AddProductRequest { public Guid CartId { get; set; } public ProductItemRequest ProductItem { get; set; } }
-
Taką klasę mapuję do właściwego kontraktu - tzw. domenowego. Ten kontrakt pochodzi już z “mojego” modułu. W sensie ja, lub mój zespół za niego odpowiada. Tutaj pojawia się właśnie element zaufania. Stosuję wzorzec Smart Constructor (o którym pisałem w Newsletter - do poczytania: https://oskar-dudycz.netlify.app/pl/smart_constructor/). Czyli faktorkę, która ma na celu zmapować mi ten obiekt requestu już do poprawnego obiektu mojej klasy. Przez poprawny mam na myśli podstawowe założenia - czyli, że jak coś ma być nienullowalne to nim nie jest, następują tutaj też podstawowe walidacje (np. czy data zakończenia nie jest mniejsza niż data zakończenia). Ważne, żeby nie robić tutaj wielkiej walidacji logiki domenowej tylko raczej taką semantyczną.
Tutaj też zaczyna dziać się magia, jeśli użyjemy C# Nullable Reference Types lub piszemy np. w TypeScript to od tego momentu kompilator będzie nas już chronił. Pomoże nam wykryć nam niezgodności nie tylko w rzutowaniu ale i też podstawowym podejściu.
Warto tutaj rozważyć użycie Value Objectów, które mówią wprost co jest co oraz robić ten obiekt niezmienialnym. Dzięki temu wiemy, że od momentu utworzenia kontraktu nikt już nie zmienił jego zawartości i możemy mu zaufać. Możemy tym samym zaoszczędzić dużo na konieczności robieniu ifów i unit testów. Bo mamy pewność, że taki obiekt po prostu nie może być niepoprawnie utworzony. To jest te miejsce gdzie powinniśmy sobie sami zaufać. Oczywiście kontrolując te zaufanie testami tego kontraktu.
public class AddProduct: ICommand { public Guid CartId { get; } public ProductItem ProductItem { get; } private AddProduct(Guid cartId, ProductItem productItem) { CartId = cartId; ProductItem = productItem; } public static AddProduct Create(Guid cartId, ProductItem productItem) { Guard.Against.Default(cartId, nameof(cartId)); Guard.Against.Null(productItem, nameof(productItem)); return new AddProduct(cartId, productItem); } }
-
Właściwą walidację domenową powinniśmy zrobić w logice biznesowej. Dlatego tak lubię CQRS i Agregaty. Dzięki CQRS wiemy, że komenda będzie wykonana w ciągu w danym handlerze. Agregat daje nam jedno miejsce odpowiedzialne za logikę biznesową. Jeżeli mamy zmienić regułę to nie musimy rozbieganymi oczami patrzeć po całym kodzie. Czyli np. warto w komendzie walidować czy jest data zakończenia późniejsza od daty rozpoczęcia, ale już sprawdzać czy daty są większe niż data dzisiejsza proponowałbym w logice biznesowej.
Dlaczego? Przyjmijmy, że robimy system zarządzania zgłoszeniami błędów. Początkowo możemy założyć, że daty będą zgłaszane tylko przez nasz system i te daty będą nas obowiązywały. Ale potem może się okazac, że powinniśmy również wspierać sytuację gdy ktoś zadzwonił telefonicznie, albo wysłał maila. Możliwe, że ktoś od razu zgłoszenia nie wprowadził, a obowiązuje nas SLA od momentu pierwszego kontaktu. Wtedy np. będziemy musieli dać możliwość “antydatowania”. Analogicznie warto walidować format numeru faktury w komendzie, ale nie to czy już istnieje faktura o takim numerze - to powinno być robione w logice biznesowej.
Przykład:
public class Cart: Aggregate { public Guid ClientId { get; private set; } public CartStatus Status { get; private set; } public IList<PricedProductItem> ProductItems { get; private set; } public decimal TotalPrice => ProductItems.Sum(pi => pi.TotalPrice); // (...) public void AddProduct( IProductPriceCalculator productPriceCalculator, ProductItem productItem) { Guard.Against.Null(productPriceCalculator, nameof(productPriceCalculator)); Guard.Against.Null(productItem, nameof(productItem)); if(Status != CartStatus.Pending) throw new InvalidOperationException($"Adding product for the cart in '{Status}' status is not allowed."); var pricedProductItem = productPriceCalculator.Calculate(productItem).Single(); var @event = ProductAdded.Create(Id, pricedProductItem); Enqueue(@event); Apply(@event); } }
Może się to na początku wydac nieco nadmiarowe, ale dzięki temu:
- zwiększamy bezpieczeństwo naszego kodu,
- uniezależniamy zmiany w kodzie domenowym (komenda, agregat) od zmian w API,
- jesteśmy w stanie po kolei odcinać scenariusze brzegowe: deserializacja, walidacja semantyczna typów, walidacja biznesowa. Z racji, że każda z tych czynności jest coraz bardziej czasochłonna to zmniejszamy obciążenie naszego kodu,
- łatwiej wiemy co gdzie i jak zmienić dzięki czemu zwiększamy utrzymywalność,
- zmniejszamy liczbę potrzebnych testów.
Niepostrzeżenie wprowadziłem w tym mailu podstawy:
- Architektury heksagonalnej:
- https://en.wikipedia.org/wiki/Hexagonal_architecture_(software),
- https://web.archive.org/web/20180822100852/http://alistair.cockburn.us/Hexagonal+architecture,
- https://medium.com/swlh/implementing-a-hexagonal-architecture-bcfbe0d63622
- Type Driven Development:
- https://leanpub.com/ddd_first_15_years, Eseje: “Scott Wlaschin - Domain Modeling with Algebraic Data Types”, “Mathias Verraes - Emergent Contexts through Refinement”
Poza tymi linkami chciałem się podzielić jeszcze dwoma swoimi:
- Moim Repo z cotygodniową porcją linków o programowaniu, dzisiaj było pierwsze zestawienie - https://github.com/oskardudycz/ArchitectureWeekly
- Wpisu na moim blogu o tym dlaczego konto bankowe nie jest najleszym przykładem Event Sourcing - https://event-driven.io/pl/bank_account_event_sourcing/
Na koniec zagadka. Dlaczego w zdarzeniach waliduję pola w statycznej faktorce, a nie w konstrukturze? Przykład: https://github.com/oskardudycz/EventSourcing.NetCore/blob/main/Workshops/PracticalEventSourcing/Carts/Carts/Carts/Events/ProductAdded.cs
public class ProductAdded: IEvent
{
public Guid CartId { get; }
public PricedProductItem ProductItem { get; }
private ProductAdded(Guid cartId, PricedProductItem productItem)
{
CartId = cartId;
ProductItem = productItem;
}
public static ProductAdded Create(Guid cartId, PricedProductItem productItem)
{
Guard.Against.Default(cartId, nameof(cartId));
Guard.Against.Null(productItem, nameof(productItem));
return new ProductAdded(cartId, productItem);
}
}
Czekam na Twoją odpowiedź i pozdrawiam! Oskar