W czym pomoże nam Value Object?

Value Object, jak sama nazwa wskazuje, jest obiektem przechowującym jakąś wartość. Ta koncepcja pomaga nam nadać znaczenie danej liczbie bądź literałowi czy też ich grupie. Zastanów się czym jest dla Ciebie cyfra 3. Jest to miesiąc marzec? Ilość produktów? A może wiek pacjenta? Ciężko się domyślić na pierwszy rzut oka. Musimy znać szerszy kontekst, aby zrozumieć czym jest ta wartość. Właśnie z tego powodu warto przyjrzeć się Value Objects.

Problem na tapet

Załóżmy, że programujemy aplikację dietetyczną i zaprojektowaliśmy w niej klasę Person posiadającą pole weight typu double. Aktualnie naszymi użytkownikami są ludzie, którzy posługują się jednostką miary jaką są kilogramy. Wszystko działa świetnie, ponieważ umownie wiemy, że jak coś jest wagą to na pewno są to kilogramy.

public class Person {

    //... other fields
    private double weigh;

    //... constructors
    
    public void addWeigh(double weigh) {
        this.weigh += weigh;
    }

    public String printCurrentWeigh() {
        return String.format("%.1f kg", weigh);
    }
    
    //... other methods
}

Wraz z rosnącą popularnością aplikacji nasz biznes dochodzi do wniosku, że musimy wejść z nią na rynek brytyjski. Z pobieżnych analiz wynika, że wystarczy teraz obsługiwać kolejną jednostkę miary jaką są funty i wszystko powinno działać jak należy. No dobrze, zweryfikujemy co to oznacza dla naszej aplikacji. Musimy teraz widzieć czy do metody addWeigh przychodzi wartość wagi w kilogramach czy może w funtach, aby móc dokonać niezbędnej konwersji. Dodatkowo metoda printCurrentWeigh również musi dopisywać odpowiednią jednostkę miary. Kod znacznie zaczyna się komplikować i nie wygląda przyjaźnie.

public class Person {

    enum WeighUnit {
        KG(1), LB(2.20459);

        final double rateToKg;

        WeighUnit(final double rateToKg) {
            this.rateToKg = rateToKg;
        }
    }

    //... other fields
    private double weigh;
    private WeighUnit weighUnit;

    //... constructors

    public void addWeigh(double weigh, WeighUnit weighUnit) {
        this.weigh += (this.weighUnit.rateToKg / weighUnit.rateToKg) * weigh;
    }

    public String printCurrentWeigh() {
        return String.format("%.1f %s", weigh, weighUnit.name().toLowerCase());
    }

    //... other methods
}

Zarzuciliśmy klasę Person wieloma szczegółami implementacyjnymi dotyczącymi przeliczania wagi czy też jej poprawnego wyświetlania. Dodatkowo zwiększa nam się ilość pól w tej klasie, które tak naprawdę nie są interesujące z jej punktu widzenia. Co możemy z tym zrobić? Zastosować Value Object!

Użycie Value Object

Zanim przejdziemy do implementacji Value Object przedstawmy sobie czym się one w ogóle cechują.

  • są niezmienne (oznacza to, że ich pola również) – zapraszam do obejrzenia lekcji kursu podstaw Javy na ten temat
  • nie posiadają tożsamości (brak identyfikatora)
  • każda zmiana wartości kończy się powstaniem nowego obiektu

Projektowana przez nas klasa musi spełnić powyżej przedstawione wymagania. Na pewno nie może ona zmienić wartości swoich pól po jej stworzeniu. Oznacza to, że muszą być one finalne. Nie może również posiadać identyfikatora przez co równość obiektów jest determinowana przez wartości pól klasy. Dodatkowo jeżeli zajdzie konieczność zmiany stanu klasy należy utworzyć jej nowy obiekt (jest to cecha obiektów niemutowalnych).

Bogatsi w tą wiedzę możemy przejść do implementacji Value Object o nazwie Weigh.

final class Weigh {

    private static final int ROUND_SCALE = 1;

    private final BigDecimal value;
    private final WeighUnit weighUnit;

    Weigh(final double value, final WeighUnit weighUnit) {
        if (value < 0) {
            throw new IllegalArgumentException("Weigh value cannot be negative");
        }
        if (weighUnit == null) {
            throw new IllegalArgumentException("Weigh unit cannot be null");
        }

        this.value = new BigDecimal(value).setScale(ROUND_SCALE, RoundingMode.HALF_UP);
        this.weighUnit = weighUnit;
    }

    Weigh add(Weigh weigh) {
        double newValue = value.doubleValue() + (this.weighUnit.rateToKg / weigh.weighUnit.rateToKg) * weigh.value.doubleValue();
        return new Weigh(newValue, weighUnit);
    }

    String print() {
        return String.format("%s %s", value, weighUnit.name().toLowerCase());
    }
}

enum WeighUnit {
    KG(1), LBS(2.20459);

    final double rateToKg;

    WeighUnit(final double rateToKg) {
        this.rateToKg = rateToKg;
    }
}

Całą logikę odpowiedzialną za przeliczanie wagi przenieśliśmy do nowej klasy Weight. Ma ona tylko jedną odpowiedzialność polegającą na trzymaniu danych o wadze. Warto zwrócić uwagę na to co dzieje się w konstruktorze. Mamy tutaj od razu sprawdzenie czy przekazane wartości są poprawne. Bronimy się przed tym, aby obiekty typu Weight nie znajdowały się w nieprawidłowym stanie. Wykorzystujemy do tego zasadę fail fast polegającą na wczesnym zatrzymaniu działania programu, gdy trafiają do niego błędne dane.

W metodzie add zwracamy nowy obiekt. Nie pozwalamy, aby użytkownik tej klasy zmieniał jego stan w niepożądany sposób. Wyręczamy go z tego dając mu metodę, która sama utworzy odpowiedni obiekt po wykonaniu odpowiedniej logiki. Nawet zobacz, że dzięki utworzeniu walidacji w konstruktorze nigdy nie utworzymy nieprawidłowego obiektu. W przypadku, gdy w metodzie add wartość wagi będzie ujemna to wyrzucimy wyjątek tworząc nowy obiekt.

Nie udostępniamy żadnych setterów i getterów. Cała logika jest zamknięta w jednym miejscu i tylko udostępniamy dwie metody: jedna do zmiany wartości wagi, a druga do wydrukowania aktualnej wagi w postaci literału.

Użycie Value Object

public class Person {

    //... other fields
    private Weigh weigh;

    //... constructors

    public void addWeigh(Weigh weigh) {
        this.weigh = this.weigh.add(weigh);
    }

    public String printCurrentWeigh() {
        return weigh.print();
    }

    //... other methods
}

Teraz klasa Person nie musi się martwić przeliczaniem wagi. Otrzymała zenkapsulowaną klasę Weight jako pole. Metody addWeigh oraz printCurrentWeigh wyglądają naprawdę prosto. Wywołują odpowiednie metody Value Object i nie przejmują się jeśli zajdzie potrzeba zmiany implementacji obsługi wagi.

Podsumowanie

Jak każde rozwiązanie, Value Object również ma swoje zastosowanie. Nie jest ono w ogóle potrzebne w przypadku bardzo prostej aplikacji CRUD. W tym przypadku byłby to po prostu overengineering. Jednak jeżeli mamy do czynienia z bardziej skomplikowanym oprogramowaniem to Value Object jest jak najbardziej pożądanym rozwiązaniem. Nie jest to duża ilość kodu do utrzymywania w porównaniu z całą aplikacją, a daje nam sporo korzyści jak np. lepszą walidację prowadzącą do mniejszej ilości błędów, łatwiejsze testowanie, precyzyjniejsze otypowanie parametrów. Właśnie z tego powodu wywodzi się on z koncepcji Domain Driven Design.

Sam stałem się wielkim fanem Value Objects. Staram się je stosować, gdzie jest to tylko możliwe. Nasz kod co prawda jest nafaszerowany ogromną ilością klas, ale czy nie o to chodzi w programowaniu obiektowym? Zgadzam się, że nie zawsze jest to najlepsze rozwiązanie w przypadku małych aplikacji czy prototypów, gdzie chcemy jak najszybciej pokazać wartość bez niepotrzebnego zagłębiania się w implementację.

Źródła:

Podziel się tym z innymi!

Może Ci się również spodoba

4 komentarze

  1. aa pisze:

    Nie widzę biznesowego sensu w metodzie addWeight w aplikacji dietetycznej. Dzisiaj ważę 70kg a za dwa tygodnie wpisuje swoją wagę 75kg i mi wychodzi, że ważę 145kg?

    • Dzięki aa za cenną uwagę! Moim zamysłem było napisanie przykładowego kodu, który do aktualnej wagi dodawałby lub odejmował kilogramy. Jednak faktycznie użytkownik podaje w aplikacjach dietetycznych swoją aktualną wagę, a nie przelicza ile schudł albo przytył. Więc zgadzam się z Tobą w stu procentach! 🙂 Ten aspekt miał drugorzędne znaczenie w artykule, ale faktycznie nie powinno być takiej nieścisłości. Z tego powodu dziękuję Ci za ten komentarz!

  2. Bartek Walter pisze:

    Ciekawy artykuł, oparty o niebanalny przykład – dzięki!
    Dodałbym może zdanie uzasadnienia dlaczego zmiana wartości wymaga stworzenia nowej instancji – bardzo ładnie widać to przy testowaniu takiej klasy.

    • Cześć Bartek! Dzięki za taki komentarz, jest mi niezmiernie miło 🙂
      Co do uzasadnienia tworzenia nowej instancji Value Object przy zmianie jego wartości to ładnie opisał to Martin Fowler pod tym linkiem: https://martinfowler.com/bliki/ValueObject.html. Chodzi o problem „Aliasing Bug”, który powoduje zmianę stanu obiektu, gdy jest on przypisany do dwóch referencji. Najbezpieczniej jest wtedy tworzyć jego kopię, aby do tego nie dochodziło 😉
      Pozdrawiam Cię serdecznie i życzę samych sukcesów!