Value Object (Obiekt Wartości) to koncept rozpowszechniony wraz z DDD (Domain Driven Design), ale tego rodzaju byty pojawiały się w kodzie jeszcze zanim to było modne. Na pewno gościły u programistów wierzących w podejście „wszystko jest obiektem”. 3xD spowodowało jednak, że zostały bardzo dobrze określone i ustrukturyzowane.
Value Object to zazwyczaj niewielki obiekt, który reprezentuje zmierzoną, wyliczoną bądź opisaną wartość. Co ważne, taki obiekt nie posiada unikalnego identyfikatora, a jego równość definiuje się na podstawie wartości atrybutów. Obiekty wartości z pewnością wzbogacają warstwę modelu.
Cechy obiektów wartości
Niezmienność
W skrócie, brak setterów i innych modyfikatorów. Obiekt przy tworzeniu dostaje wymagane wartości, których w trakcie cyklu jego istnienia nie da się zmodyfikować. Skoro stan obiektu jest niezmienny – klasa jest łatwa w testowaniu oraz nie generuje skutków ubocznych, dzięki czemu jest mniej podatna na błędy.
Value Object nie powinien zawierać referencji do innego mutowalnego obiektu. Prowadzić to może do nieoczekiwanych rezultatów, gdyż zmiana stanu w innym obiekcie spowoduje zmianę w VO. W praktyce klasy takie ograniczają się do typów prymitywnych i ewentualnie referencji do innego obiektu wartości.
Brak tożsamości
final class Calorie
{
private const RANGE_SIZE = 15;
private int $quantity;
public function __construct(int $quantity)
{
$this->quantity = $quantity;
}
public function getQuantity(): int
{
return $this->quantity;
}
public function isInRange(self $calorie): bool
{
return $calorie->getQuantity() >= $this->calculateMin()
and $calorie->getQuantity() <= $this->calculateMax();
}
private function calculateMax(): int
{
return $this->quantity + self::RANGE_SIZE;
}
private function calculateMin(): int
{
return $this->quantity - self::RANGE_SIZE;
}
}
Powyżej przykład prostego obiektu wartości, który jak widać nie posiada identyfikatora. Jak zatem sprawdzić równość dwóch takich instancji? Zacznę od omówienia operatora identyczności, który niestety w tym przypadku nie zdaje egzaminu.
$calorie1 = new Calorie(50);
$calorie2 = new Calorie(50);
var_dump($calorie1 === $calorie2);
W kontekście obiektów porównanie rygorystyczne będzie prawdą tylko wtedy, gdy obie zmienne posiadają referencję do tego samego obiektu. Wynik powyższego kodu to false, a poniższego true.
$calorie1 = new Calorie(50);
$calorie2 = $calorie1;
var_dump($calorie1 === $calorie2);
Co z luźniejszym porównaniem? Okazuje się, że wyjątkowo jest pomocne, ale nie jest idealnym rozwiązaniem. Poniższe dwa obiekty są sobie równe, jako że operator ==
sprawdza wartości atrybutów oraz typ klasy.
$calorie1 = new Calorie(50);
$calorie2 = new Calorie(50);
var_dump($calorie1 == $calorie2);
Czy są jakieś zagrożenia wynikające z owego porównania? Oczywiście. Po pierwsze, w celu lepszej kontroli zawsze zaleca się używanie ===
. Inny programista mógłby więc nieświadomie poprawić i tym samym doprowadzić do błędu. Po drugie, przy takim porównywaniu trzeba uważać na typ w kontekście dziedziczenia. Chociaż to bardziej w formie ciekawostki, bo VO to zazwyczaj klasa finalna. Po trzecie samo IDE mi się świeci, że to zła praktyka – a bardzo tego nie lubię.
Zostaje zatem, preferowane przeze mnie, rozwiązanie numer 3. Dorzucenie metody equals()
, której zaletą jest to, że nieraz równość dwóch bytów wynika z samej dziedziny i nie musi oznaczać tożsamych wartości atrybutów klasy. Za to wadą jest, że przy rozbudowanie klasy o nowe pole trzeba pamiętać o obsłużeniu go w funkcji.
public function equals(self $calorie): bool
{
return $calorie->getQuantity() === $this->getQuantity();
}
Ostatecznie można pokusić się o połączenie dwóch rozwiązań i zaimplementować metodę equals()
jak w poniższym fragmencie kodu. Mnie natomiast się to nie zdarza, ale widziałem takie rozwiązania. Zawsze to lepsze, niż powielanie ==
w kodzie klienckim.
public function equals(self $calorie): bool
{
return $calorie == $this;
}
Wymienialność
Zamienialność jest ściśle powiązana z niemutowalnością. Samego stanu obiektu nie wolno modyfikować, ale przecież naturalne jest, że wartość w kontekście encji czy agregatu może ulec zmianie. Przecież to nie stała. W związku z tym każda operacja jego przypisania powinna opierać się o stworzenie nowej instancji.
Zostając przy przykładzie z góry. Encja posiłek zawiera w sobie informacje o kaloryczności właśnie w postaci referencji do Value Objectu. W momencie dodania nowego składnika, kaloryczność powinna wzrosnąć. Zamiast tworzyć metody, które pozwalają na jego zmianę trzeba zagwarantować możliwość łatwiej podmiany. Finalnie zostanie utworzony nowy obiekt, który jako liczbę kalorii w konstruktorze przyjmie wartość z wcześniejszego obiektu plus liczba kalorii nowego składnika.
Zapis i odczyt obiektów wartości
Skoro Value Object jest częścią domeny, a konkretniej nawet częścią encji to oczywiście potrzebny będzie zapis jego stanu do bazy danych. I w drugą stronę jego odczyt. Pokażę dwie opcje na przykładzie Doctrine ORM. Możliwych rozwiązań jest dużo więcej, ale w pracy z bazą relacyjną moim zdaniem te są najrozsądniejsze. Można bawić się w serializowane obiektów w kolumnach typu json i tym podobne mechanizmy, ale zazwyczaj więcej z tym problemów, niż pożytku.
Pierwsze z rozwiązań to samodzielna obsługa spłaszczania obiektu do typów prymitywnych przy zapisie oraz tworzenie instancji z typów prymitywnych przy odczycie.
final class Calorie
{
private int $quantity;
public function __construct(int $quantity)
{
$this->quantity = $quantity;
}
public function getQuantity(): int
{
return $this->quantity;
}
}
/**
* @ORM\Entity()
* @ORM\Table(name="meal")
*/
class Meal
{
/**
* @ORM\Id
* @ORM\Column(name="uuid", type="uuid", unique=true)
*/
private UuidInterface $uuid;
/** @ORM\Column(type="integer") */
private int $calorieQuantity;
public function __construct(Calorie $calorie)
{
$this->uuid = Uuid::uuid4();
$this->calorieQuantity = $calorie->getQuantity();
}
public function getCalorie(): Calorie
{
return new Calorie($this->calorieQuantity);
}
}
Od takiego rozwiązania wyszedłem przy stosowaniu tego rodzaju obiektów w kodzie. Od wersji 2.5 w bibliotece Doctrine istnieje obsługa obiektów, które nie są encją, ale jej częścią. W praktyce sprawdza się to bardzo dobrze. Musicie sami zdecydować, która opcja bardziej Wam pasuje.
Warto dodać, że poniższe podejście ma jeden plus. Wykorzystuje serializację/deserializację przy odtwarzaniu obiektu. Jest to o tyle zasadne, że w kodzie powyżej obiekt za każdym razem przy wyciąganiu z bazy danych jest tworzony przez konstruktor. Gdyby w konstruktorze uruchamiane były dodatkowo na przykład jakieś zdarzenia to zostaną wywołane za każdym razem, co nie jest oczekiwanym zachowaniem. Tak czy inaczej oba rozwiązania skutecznie realizują cel, czyli persystencję Value Object.
/** @ORM\Embeddable() */
final class Calorie
{
/** @ORM\Column(type="integer") */
private int $quantity;
public function __construct(int $quantity)
{
$this->quantity = $quantity;
}
public function getQuantity(): int
{
return $this->quantity;
}
}
/**
* @ORM\Entity()
* @ORM\Table(name="meal")
*/
class Meal
{
/**
* @ORM\Id
* @ORM\Column(name="uuid", type="uuid", unique=true)
*/
private UuidInterface $uuid;
/** @ORM\Embedded(class="Calorie") */
private Calorie $calorie;
public function __construct(Calorie $calorie)
{
$this->uuid = Uuid::uuid4();
$this->calorie = $calorie;
}
public function getCalorie(): Calorie
{
return $this->calorie;
}
}
Wszystko fajnie, tylko po co…
No dobra, ciekawa i dobrze opisana konstrukcja, ale po co w ogóle takie obiekty tworzyć? To dobre miejsce na logikę domenową. Nie wszystko co dziedzinowe wymaga posiadania własnego identyfikatora, ale może być częścią encji. Tak dokładnie – Value Object zawsze przynależy do Encji. Daje więc separację i enkapsulację. Mniej typów prymitywnych w domenie, co też ma swoje korzyści.
W DDD istnieje nawet zasada mówiąca, że w budowaniu bogatego modelu dziedzinowego należy więcej korzystać z obiektów wartości, aniżeli z encji. Naturalnie tam gdzie ma to sens, a nie wszędzie gdzie się da. Ich cechy sprawiają, że w wielu miejscach okażą się po prostu trafniejsze niż typy proste czy encje oparte o identyfikator.
Czy value object zawsze musi przynależeć do encji? Wydaje mi się, że można z nich korzystać w całkowitym oderwaniu od encji jak najbardziej
Wtedy nie nazywałbym go raczej Value Objectem, ale faktycznie może wyglądać identycznie. Tyle że DTO też mogą wyglądać tak samo, ale nie maja logiki i mają zupełnie inne przeznaczenie. Obiekty Wartości rozpatruje się w kontekście encji/agregatu.
Mają wspólne i różne cechy. A to już wiedząc można użyć w dowolnym miejscu. To nic nie ma związku z Encjami. Zdarza się że zdarzy się, że miejscami są podobne, ale jak pogadamy o kontekście, będzie to miało znaczenie ogromne.
Uważam, że autor nie czerpał wiedzy z wielu źródeł, bo wiedza zawarta jest jakoś poprawna, ale jednak jest płytka moim zdaniem. VO nie ma nic z doctrinem, czy encjami do czynienia. O persystencji – wadach i zaletach – można rozpisać osobny artykuł.
Moja rada, polecam zrozumieć definicje: „whose equality isn’t based on identity” MFowler i na tym się skupić.
Dzięki za wkład z edukacje 🙂 Szerzenie wiedzą zawsze na plus 😉
Jasne, zawsze można stworzyć nawet osobną książkę na niektóre tematy. Mimo wszystko swoje wpisy traktuje tak, żeby były praktyczne i od razu można było wdrażać. Pełnego spektrum zagadnienia prawie nigdy nie da się opracować. 🙂
fajny artykuł, zastanawia mnie jedna rzecz przy jakiej ilości obiektów Meal z powtarzającymi się wartościami Calorie będzie trzeba rozważyć zrobienie z Calorie encji i relacji Meal->Calorie
Moim zdaniem nigdy. Właśnie VO pasują w takich miejscach, a to że się powtarzają to nie problem. Większy by był gdybyś przypisał tę samą encję Calorie do kilku Meal (bo wartość się powtarza). I wyobraź sobie że zmieniasz dla jednego posiłku, bo składniki się w nim zmieniły, a zmieni się dla wszystkich.
Chodziło mi raczej o sytuację, gdzie to Embeddable będzie miało postać np
https://gist.github.com/mysiar/266fc3f9b7123f9ce476c405a70f4c32
kiedy wiadomo, ze taka wartość w przypadku encji będzie tylko jedna bo w założeniu nie trzeba jej powtarzać
Jak dla mnie obie sytuacje się nie różnią. Wyciągnąć do encji zawsze możesz jeśli masz taki przypadek, plus mała migracja danych wchodzi w grę. Wydaje mi się jednak, że nie często się to zdarzy. Z założenia VO nie bazują na ID.
Ja z kolei miałem taki problem z porównywaniem VO za pomocą podwójnego znaku równości. Jedno z pól mojego VO było integerem który mógł być nullem. Wartość zero a null miały całkowicie różne znaczenie biznesowe. W przypadku gdy porównaniu były poddawany właśnie VO z null’em i VO z zerem, if’ka w klasie klienta uznawała, że VO’sy mają takie same xD Doprowadziło to do błędu na produkcji, ticketa naprawczego i czasu na szukanie przyczyny problemu. Wniosek taki, że tak jak pisałeś lepiej nie polegać na == a właśnie customowym equals. Pozdro!
Kto jeszcze w tych czasach używa == zamiast ===?
Ktoś na pewno. 😉
Świetny wpis, dzięki za info. Temat ciekawy i świetnie pozwalający rozwijać wyobraźnie.