Value Object w PHP – obiekt, który naprawdę wnosi wartość

świecąca żarówka

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.

Programista PHP i właściciel marki Koddlo. Pasjonat czystego kodu i dobrych praktyk programowania obiektowego. Prywatnie fan dobrego humoru i podcastów.