Walidacja w architekturze wielowarstwowej post
2017-03-09
Ten wpis jest jednym z kilku rozszerzeń jakie planuje napisać do CQRS w praktyce, wprowadzenie - PHP. Kilka osób zapytało w jaki sposób najlepiej radzić sobie z walidacją, skoro architektura poruszona w poprzednim tekście składał się z warstw to w której warstwie powinno się umieścić walidację? Pierwsze co nasuwa się na myśl to interfejs użytkownika, w końcu ta warstwa jest najbliżej użytkownika. Jednak ciągle wspominałem o tym jak to model domenowy powinien być kuloodporny, jak to obiekty powinny same dbać o poprawność swojego stanu. No więc może lepiej umieścić walidację w modelu domeny? Tak naprawdę obydwa te miejsca są odpowiednie, jednak walidacja w nich umieszczona ma zupełnie inne przeznaczenie.
Walidacja na poziomie modelu domeny
Celem tej walidacji jest zapewnienie poprawnego stanu systemu, za wszelką cenę. Nawet kosztem wywalenie użytkownikowi na ekran nieprzechwyconego wyjątku. Tutaj nie ma miejsca na sentymenty. Poniżej przykład walidacji w modelu domeny (to nie jest kod nadający się na produkcję, służy on jedynie jako wizualizacja koncepcji).
<?php
declare (strict_types = 1);
namespace Example\Domain;
use Example\Domain\Exception\InvalidArgumentException;
final class Money
{
private $validCurrencies = [
'PLN', 'EUR', 'USD'
];
private $amount;
private $currency;
public function __construct(int $amount, string $currency)
{
if ($amount <= 0) {
throw new InvalidArgumentException("Money amount needs to be greater than zero.");
}
if (!in_array(mb_strtoupper($currency), $this->validCurrencies)) {
throw new InvalidArgumentException(sprintf("Illegal currency \"%s\".", $currency));
}
$this->amount = $amount;
$this->currency = $currency;
}
}
Walidacja w warstwie interfejsu użytkownika
Ta walidacja jest jedynie mechanizmem wczesnego ostrzegania dla użytkownika. Jest to jedynie sposób ładnej i czytelnej komunikacji. To taka ochrona użytkownika przed nim samym. Jeszcze zanim intencja (jeżeli czytałeś poprzedni wpis wiesz, że komendy nazywam intencjami) zostanie przesłana do systemu w warstwie UI przeprowadza się wstępną walidację danych. To taki szybki feedback dla użytkownika, ładna i czytelna informacja o tym co zrobił źle.
<?php
declare (strict_types = 1);
namespace Example\UserInterface\Http\Symfony\AppBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\GreaterThan;
use Symfony\Component\Validator\Constraints\NotBlank;
final class PaymentType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('price', MoneyType::class, [
'divisor' => 100,
'currency' => 'PLN',
'constraints' => [
new NotBlank(),
new GreaterThan([
'value' => 0
])
]
]);
}
}
Wyjaśnienie
Załóżmy, że pracujemy nad systemem w którym dokonywane są jakiekolwiek płatności. Jedną z komend dostępnych w tym systemie będzie na pewno komenda "Zapłać". Intencja płatności zapewne będzie zawierać w sobie informację na temat kwoty, waluty i być może celu płatności, coś typu:
PayForService(int $amount, string $currency)
Nietrudno wyobrazić sobie, że system nie powinien dopuszczać możliwości płacenia w bananach albo przy użyciu ujemnej kwoty. Nie trudno też przewidzieć, że ktoś postrzegający siebie za niezwykle sprytna osobę na pewno spróbuje takiego manewru. Zacznijmy więc od dołu.
W modelu domeny prawdopodobnie dobrym rozwiązaniem będzie utworzenie Value Objectu
o nazwie Money
składającego
się z kwoty i waluty. Ten Value Object
zadba o to aby waluta była przekazywana w postaci poprawnego kodu ISO 4217.
Zadba też aby kwota nie była wartością ujemną lub zerową. Jeżeli zostanie zbudowany w jakikolwiek inny sposób poleci
wyjątek typu InvalidArgumentException
. Czy jednak taka walidacja z punktu widzenia systemu jako całości wystarczy?
Cóż, teoretycznie tak. Można by przecież przechwytywać ten wyjątek i w warstwie wyżej jakoś na niego reagować.
Nie jest to jednak najlepszy i najwygodniejszy sposób na prezentowanie użytkownikom informacji na temat tego co skopali.
W tym momencie powinniśmy zwrócić się w stronę interfejsu użytkownika. W zależności od rodzaju interfejsu warto przemyśleć rodzaj walidacji, jeżeli przyjmiemy że interfejsem jest protokół http prawdopodobnie najłatwiej będzie ustawić odpowiednią walidację w formularzu, czy to w czasie jego obsługi na poziomie PHP, czy nawet jeszcze wcześniej. Wcześniej czyli na poziomie JavaScriptu.
"No dobra, ale to jest duplikacja kodu!! To nie jest DRY!"
- Tak, masz rację. To nie jest DRY, to jest jakaś tam duplikacja, pytanie brzmi, co z tego?
Zakładając, że interfejs użytkownika zbudowany będzie w oparciu o jakiś framework (a przeważnie właśnie tak jest) to jak ciężkie może być dołożenie nowego walidatora? Prawdopodobnie sprowadzi się to do ustawienia odpowiedniej opcji, może jakiegoś fajnego i czytelnego komunikatu dla użytkownika, 5 minut roboty? Pewnie nawet nie. Tak więc argument "trzeba pisać 2 razy więcej kodu" jest delikatnie mówiąc "inwalidą". Poza tym spójrz jeszcze raz na przykłady kodu z początku tego tekstu. Czy widzisz tam gdzieś duplikację? Czy jest tam jakikolwiek kawałem kodu, który dałoby się wydzielić jako wspólny byt? Duplikacja częściej niż w kodzie siedzi w głowie programisty i zamiast refaktoryzować po fakcie, naprawiamy nieistniejące problemy.
"Walidacja w JavaScriptcie jest niebezpieczna! Nie można na niej polegać, ktoś może wyłączyć JS!"
- Tak może, tylko co z tego? Przecież nie polegamy na JS'ie, polegamy na modelu domeny.
Mamy XXI wiek, Elon Musk kopie tunele żeby rozładować korki uliczne i planuje wysłanie człowieka na Marsa. Śmiało można założyć że większość ludzi korzysta z internetu za pośrednictwem przeglądarek, które posiadają wbudowaną i domyślnie włączoną obsługę JavaScriptu. Jeżeli ktoś decyduje się wyłączyć JavaScript robi to świadomie, nie spotkałem się jeszcze z kimś kto nieświadomie korzystał z przeglądarki bez JS'a. Jeżeli jednak trafi się nam rodzynek to zamiast ładnego komunikatu o błędzie zobaczy po prostu nieprzechwycony wyjątek, który i tak będzie odpowiednio zamaskowany pod postacią strony błędu czy generycznego komunikatu w stylu "ops, coś poszło nie tak". Skoro ktoś celowo wyłącza sobie JavaScript musi liczyć się z pewnymi niedogodnościami.
Właśnie dzięki temu, że model domeny sam dba o to aby stan systemu zawsze był poprawny nie musimy przejmować się czy coś nieprawidłowego trafi niżej czy nie. Jeżeli trafi to w najgorszym wypadku poleci wyjątek. Odpowiednio przygotowany i monitorowany system da nam o tym znać i patcha będziemy w stanie przygotować w przeciągu 15, może 20 minut. Użytkownik takiego systemu zobaczy natomiast generyczny komunikat o tym, że coś poszło nie tak i właśnie trwają pracę mające na celu przywrócenie sprawności funkcjonowania systemu. Co mimo wszystko jest lepsze niż przykładowo opłacenie zamówienia ujemną kwotą.
Podsumowanie
Ludzie często próbują za wszelką cenę eliminować duplikację, podążać za regułą DRY. Kończą później z ValueObjectami w domenie zależnymi od walidatorów z wybranego frameworka, tworzą walidatory w domenie, które później wykorzystują w interfejsie użytkownika co prędzej czy później i tak kończy się przenikaniem interfejsu do modelu domeny. Jeszcze gorzej dzieje się kiedy dochodzi nowy interfejs, przykładowo API RESTowe i nagle okazuje się, że walidator który teoretycznie sprawdzał się w dwóch innych miejscach tutaj już się nie sprawdzi.
Walidacja ma poziomie warstwy UI jest przeznaczona dla użytkownika i ma na celu ułatwić i uprzyjemnić mu korzystanie z aplikacji. Walidacja na poziomie modelu domeny to nasz mur obronnych, zasieki nie do przejścia, coś co odróżnia solidny system od miernego. Potencjalna duplikacja nie jest czymś czym warto się przejmować, jeżeli spojrzymy na to z perspektywy systemu posiadającego wiele interfejsów użytkownika może okazać się, że każdy z nich wymaga innego podejścia i coś co pierwotnie wydawało się duplikacją, teraz jest uzasadnioną separacją. Poza tym duplikacja zawsze będzie lepsza od złej abstrakcji.