Geneza Value Objectu
VO (Value Object) swoją popularność zdobyły przy okazji Domain Driven Design gdzie definiują standard obiektów z których składa się logika biznesowa. Pozwalają na prostą walidację wszystkich wartości wchodzących do naszego systemu, dzięki czemu ułatwia rozwijanie aplikacji przy zmieniających się wymagań, oraz zabezpiecza bazę danych przed niechcianymi wartościami.
Jak stworzyć Value Object?
Jednak koncept Value Objectu nie jest często znany w środowisku które nie miało styczności z Domain Driven Design, a według mnie VO jest jednym z prostszych sposobów na ulepszenie swojej architektury. Posłużę się prostym przykładem w „pseudo” kodzie.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
<?php class SomeClass { /** @var SomeConnection */ private $connection; public function __construct(SomeConnection $connection) { $this->connection = $connection; } public function fetchSomeThingsFromDb(string $someVariable, int $someInteger) { //… } } |
Z powyższego pseudo kodu za dużo się nie możemy dowiedzieć jak dokładnie ma wyglądać parametr wejściowy i nie ma żadnej walidacji, o ile nie walidowaliśmy go w innym miejscu. Problem pojawia się jednak gdy mamy kilka wykorzystań danej metody, i nie uda nam się przypilnować, żeby każde wartości były zwalidowane. Kolejnym problem jest zmienność wymagań biznesowych, oraz kilka walidatorów w różnych miejscach. Kiedy przyjdzie nam zmienić wymaganie np. nazwy użytkownika która musi składać się z minimalnie 5 znaków, a wcześniej było 4 to musimy przejść przez wszystkie wykorzystania i zaaktualizować wszystkie miejsca gdzie sprawdzana była długość. W dobrym przypadku wiemy dokładnie gdzie musimy zmienić wszystkie wartości, jednak jeśli pracuje sporo developerów, łatwo o miejsce o którym nikt nie wie, i np pozostało bez walidacji, bądź co gorsza ze starą walidacją.
Przykład jest prosty, ale ma na celu zobrazować problem jaki rozwiązujemy użyciem Value Objectów. Zmodyfikujemy trochę powyższy kod o zamienienie typowania prostego na konkretną klasę.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
<?php class SomeClass { /** @var SomeConnection */ private $connection; public function __construct(SomeConnection $connection) { $this->connection = $connection; } public function fetchSomeThingsFromDb(SomeStringVO $someVariable, SomeStringVO $someInteger) { //… } } |
Do tego dodamy prostą implementację powyższych klas.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
<?php class SomeStringVO { /** @var string */ private $string; public function __construct(string $string) { if ($this->isValid($string)) { $this->string = $string; } else { throw new Exception(’String is too short’); } } private function isValid(string $string): bool { return (bool) strlen($string) > 5; } public function getValue(): string { return $this->string; } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
<?php class SomeIntegerVO { /** @var int */ private $integer; public function __construct(int $integer) { if ($this->isValid($integer)) { $this->integer = $integer; } else { throw new Exception(’Integer can be below zero’); } } private function isValid(int $integer): bool { return (bool) $integer >= 0; } public function getValue(): int { return $this->integer; } } |
Dzięki zastosowaniu VO otrzymujemy wysoką szanse na to, że parametry które będą wchodziły do bazy danych zostaną zwalidowane i zweryfikowane ze standardami obowiązującymi w założeniach biznesowych. Do tego walidację będziemy posiadać w jednym miejscu dla wszystkich zastosowań, przez co koszt utrzymania VO będzie bardzo niski.
Co w sytuacji kiedy chcę sprawdzić dwie wartości?
Często zdarzają się sytuację, że jeden Value Objecty składają się z kilku mniejszych Value Objectów, żeby móc dokładnie zwalidować daną wartość. W takich przykładach tworzymy np. dwa mniejsze VO które poźniej obudowujemy kolejnym większym Value Objectem. Posłużę się przykładem z kwotami pieniędzy.
W pierwszym kroku tworzymy Value Object dla waluty.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
<?php class CurrencyVO { /** @var string */ private $currency; const ALLOWED_CURRENCIES = [’PLN’, ’EUR’, ’GBP’]; public function __construct(string $currency) { if ($this->isValid($currency)) { $this->currency = $currency; } else { throw new InvalidCurrencyException(’Currency is not allowed’); } } private function isValid(int $currency): bool { return in_array($currency, self::ALLOWED_CURRENCIES); } public function getValue(): int { return $this->currency; } } |
Następnie tworzymy Value Object dla całej kwoty z walutą, którego nazwiemy MoneyVO. W tym przypadku nie tworzymy osobnego Value Objecta dla samej kwoty, bo nie ma to za bardzo sensu.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
<?php class MoneyVO { /** @var CurrencyVO */ private $currency; /** @var int */ private $amount; public function __construct(int $amount, CurrencyVO $currency) { $this->guardNegativeAmount($amount); $this->amount = $amount; $this->currency = $currency; } private function guardNegativeAmount(int $amount) { if ($amount < 0) { throw new InvalidMoneyException(’Amount must be greater than zero’); } } public function isEqual(int $amount, CurrencyVO $currencyVO) { $this->guardNegativeAmount($amount); if ($amount === $this->amount && $currencyVO->getValue() === $this->currency->getValue()) { return true; } return false; } public function getAmount(): int { return $this->amount; } public function getCurrencyVO(): CurrencyVO { return $this->currency; } } |
W powyższym przykładzie otrzymujemy funkcjonalność która sprawdza i waliduje czy w naszym systemie obsługujemy daną walutę, sprawdza poprawność kwoty czy jest wyższa od zera, oraz umożliwia łatwe porównanie czy dana kwota jest równa innej. Dzięki takiemu Value Objectowi otrzymujemy jedno miejsce w którym wszystkie krytyczne dane biznesowe są walidowane, oraz zmiany na przyszłość.
Główne zasady jakich warto przestrzegać przy projektowaniu Value Objectów
- Value Object jest niezmienialny, raz stworzony obiekt do samego końca istnienia musi przechowywać tę samą początkową wartość
- Value Object nie służy do sprawdzania czy rekord jest unikalny w bazie danych
- Zawsze powinniśmy stosować jak najprostszą walidację bez wchodzenia w szczegóły, często możemy używać wyrażeń regularnych
- Możemy używać pomocniczych metod, jak w powyższym przykładzie do porównywania wartości (isEqual())
- Powinniśmy utrzymywać jak najmniejsze ilość jednorazowo walidowanych wartości w VO, lepiej nie doprowadzać do momentu gdy mamy do utrzymania po kilkanaście pól w VO 😛
Panie, ale tu jest za dużo dodatkowego kodu do utrzymywania, nie mam na to czasu
Value Objecty nie są idealnym rozwiązaniem dla każdego projektu, kiedy mamy prostego CRUDa np. najprostszą wersję listy ToDo, to wtedy VO mogą okazać się lekkim przekombinowaniem, jednak mam nadzieję, że jeśli czytasz ten post to znaczy, że pracujesz z bardziej zaawansowanymi aplikacjami. Właśnie w średnich i dużych aplikacjach VO sprawdzają się najlepiej, nie są ogromnie dużym kodem który trzeba później utrzymywać w stosunku do całej aplikacji, ułatwiają sporo rzeczy np. przy testowaniu, dokładnie wiemy w jakim formacie musimy podać parametry bez dalszego wchodzenia w „specjalne” walidatory, uodparnia architekturę na niekontrolowane zmiany i wsadzanie do bazy danych dziwnych i niepoprawnych wyników, co w sumie często ratuje bazę danych i aplikację przed użytkownikami.
Podsumowanie
Osobiście od dłuższego czasu staram się używać jak największej ilości Value Objectów, trochę sobie nie wyobrażam życia bez tej małej klasy z bardzo ograniczoną odpowiedzialnością. Pozwala zaoszczędzać sporo czasu i nerwów w perspektywie długoterminowej. Jednak jak każde podejście nie jest idealne dla wszystkich, przez co w małych aplikacjach, MVP czy prototypach nie tworzę Value Objectów, bo za bardzo nie ma sensu.