28 Września, 2020

917 słów 5 min.

Reprezentacja wartości (ValueObject)

Reprezentacja wartości (ValueObject)

Domain Driven Design to popularny już od kilku lat temat w programowaniu. Wiedzy dotyczącej DDD jest bardzo dużo i zastosowanie tego podejścia w pełni jest bardzo trudne dla początkującego programisty. Istnieją jednak pewne obszary w tej metodyce, które możemy wyodrębnić i zastosować w naszej codziennej pracy, w projektach niewykorzystujących DDD. Jednym z takich obszarów są building block’i. W tym artykule przedstawię jeden z nich, a mianowicie ValueObject.

Gdzie leży problem?

Czy 6zł to tyle samo co ocena 6 w dzienniku? No nie. A jeśli tak jest, to po pierwsze to bardzo tanio, a po drugie, to niezgodne z prawem ;). A jak takie wartości zapiszemy w kodzie, jeśli chcemy coś zaprogramować? No zapewne w obu przypadkach najlepszym typem danych do zapisu tych wartości będzie integer lub float. Ale czy napewno?
Dla uproszczenia przyjmijmy, że jest to integer:

$ocena = 6;
$cena = 6;

W tej sytuacji, wpadamy jednak w ten niepożądany stan:

$ocena === $cena; // true

Czyli, z naszego kodu wynika, że ocena 6 to to samo co cena 6. Wiemy jednak dobrze, że tak nie jest, więc wypadałoby coś z tym zrobić.

Ale co? Może PHP ma za mało typów? Może powinniśmy dodać RFC o dodaniu typu money albo grade? A może mamy możliwość dodania takiego typu bez ingerencji w interpreter?

No tak się składa, że w sumie chyba możemy to zrobić przy pomocy klas. Klasy po części zachowują się jak typy, możemy tworzyć na ich podstawie obiekty, czyli niejako ich wartości, możemy ich oczekiwać w parametrze metody czy funkcji, możemy je zwracać w wyniku funkcji.

Wychodzi na to, że mamy w zasadzie wszystko, co potrzebne żeby utworzyć nowy typ. Spróbujmy więc to zrobić. Stwórzymy sobie klasę pieniądz z parametrem wartość:

class Money
{
    public int $value;
}

$money = new Money;
$money->value = 6;

No i mamy nowy typ. Jednak coś mi nie pasuje w budowaniu tej wartości. Musimy najpierw stworzyć obiekt, a dopiero potem możemy nadać jego wartość. W dodatku, jeśli spróbujemy użyć tej wartości przed przypisaniem, dostaniemy wyjątek. Można by użyć konstruktora, w końcu do tego został on stworzony:

class Money
{
    public int $value;

    public function __construct(int $value)
    {
        $this->value = $value;
    }
}

$money = new Money(6);

Super, wygląda to już nieco lepiej. Może jednak użyć tutaj wzorca metody fabrykującej? Jeśli zmieni się sposób tworzenia obiektu, to nie będziemy musieli poprawiać każdego użycia. Jeśli nasz system np. nie może doprowadzić do sytuacji, w której kwota będzie ujemna, możemy wprowadzić walidację podczas tworzenia (polecam rozważyć użycie biblioteki webmozart/assert). Wtedy też pozbędziemy się słówka new i cała konstrukcja będzie bardziej zwarta.
Spróbujmy:

class Money
{
    public int $value;

    public function __construct(int $value)
    {
        $this->value = $value;
    }

    public static function of(int $value): Money
    {
        if ($value < 0) {
            throw new InvalidArgumentException("Value of ".__CLASS__." must be greater than zero");
        }
        return new Money(6);
    }
}

$money = Money::of(6);

No i teraz wygląda to perfekcyjnie. Mamy nowy obiekt ceny stworzony na podstawie wartości int(6). Da się jednak zrobić to jeszcze lepiej.

Nasz obiekt reprezentuje pieniądz. Fizycznie posiadając jakiś np. banknot, nie możemy zmienić sobie jego wartości. Nie możemy na banknocie 10zł dopisać zera i mieć 100zł. Nasza klasa / typ jednak na to pozwala. Dodatkowo, jeśli przekażemy nasz pieniądz do wnętrza jakiejś metody, może ona go zmienić w niepożądany sposób. Zatem dobrym pomysłem będzie zastosowanie niemutowalności.
Spróbujmy więc:

class Money
{
    private int $value;

    private function __construct(int $value)
    {
        $this->value = $value;
    }

    public static function of(int $value): Money
    {
        // walidacja...
        return new Money(6);
    }

    public function value(): int
    {
        return $this->value;
    }

    public function sum(Money $money): Money
    {
        return Money::of($this->value() + $money->value());
    }

    public function equals(Money $money): bool
    {
        return $this->value() === $money->value();
    }
}

$money6 = Money::of(6);
$money4 = Money::of(4);

$money10 = $money6->sum($money4);
$money10->equals(Money::of(10)); // true

No i mamy ładną klasę, dzięki której mamy do dyspozycji całkowicie nowy typ do przechowywania wartości pieniędzy. Ten przykład jest rzecz jasna mocno uproszczony. Nie uwzględnia on, chociażby waluty, czy wartości zmiennoprzecinkowych, ale nie o to tutaj chodzi.

Wróćmy teraz do pierwszego przykładu. W tej chwili możemy zadeklarować sobie klasę Money oraz np. Grade. Kiedy teraz spróbujemy je porównać, dostaniemy wyjątek:

$money = Money::of(6);
$grade = Grade::of(6);
$money->equals($grade); // Uncaught TypeError: Argument 1 passed to Money::equals() must be an instance of Money, instance of Grade given

Jak widać, w powyższym wyjątku mamy jasną informację co się wydarzyło w naszym kodzie, dzięki czemu ułatwiamy sobie debugowanie, a nawet jesteśmy w stanie się uchronić przed błędem.

ValueObject

Klasy, które stworzyliśmy w powyższym przykładzie, to tzw. ValueObject’y. Z definicji ValueObject’em może być każda wartość, która nie posiada swojego identyfikatora, lub jest on nieistotny. Np. banknot 10zł teoretycznie ma swój numer seryjny, który jest jego identyfikatorem, ale nie ma to dla nas znaczenia, bo każde 10zł jest warte dokładnie tyle samo.

Powyższe przykłady przedstawiają wartości liczbowe, jednak nie tylko takie mogą być reprezentowane przez ValueObject’y. Może to być np. email, adres korespondencyjny, imie, data, współrzędne geograficzne itd.

Ważnym aspektem ValueObject’ów jest też enkapsulacja logiki. Niektóre z zachowań dotyczących stricte danego typu możemy zapisać właśnie w postaci metod ValueObject’u, a dzięki temu ukryć logikę za nimi stojącą. To dodatkowo pomaga nam respektować zasadę DRY.

Podstawowe cechy ValueObject’u: - Jest niemutowalny - Nie posiada identyfikatora - Definiuje zachowania związane ze specyficzną wartością

Żeby jeszcze lepiej zrozumieć co może być ValueObject’em, warto przejrzeć sobie biblioteki implementujące podstawowe VO, np. ytake/valueobjects. Można rzecz jasna użyć tego typu biblioteki w swoim projekcie. Jednak w przypadku niektórych VO, polecam użyć bardziej specyficznych bibliotek, jak np. moneyphp/money, która dodatkowo obsługuję wymianę walut, parsowanie czy formatowanie.