value object czyli jak tworzyć aplikację z mniejszą ilością błędów

Value object w Javie polega na grupowaniu wspólnych właściwości w małe obiekty. Przesyłane są razem z jednej metody do kolejnej, z modułu do modułu. W taki sposób atrybuty trafiają w odpowiednie miejsce wspólnie i tylko razem tworzą całość.

Przesyłanie współrzędnych 2D jest dużo wygodniejsze przesyłając je w jednym obiekcie jako X i Y. Okres dat również składa się z dwóch wartości – dat od i do. Gdy przesyłamy wartość pieniądza używamy obiektu, który trzyma informację o walucie i wartości. W każdym z tych przykładów zawsze potrzebujemy informacji o obu właściwościach.

Jeśli do tej pory w podobny sposób modelowałeś architekturę to bardzo prawdopodobne, że używałeś konceptu Value Object.

Co to jest Value Object i czym się charakteryzuje?

Jak stworzyć Value Object?

Jakie są jego wady i zalety?

Czy powinieneś go stosować w swoim kodzie?

Na te wszystkie pytania odpowiem poniżej. Do tłumaczenia wykorzystam fragmenty kodu, abyś mógł łatwiej zrozumieć Value Object od strony praktycznej.

Co to jest Value Object?

Value Object jest z reguły małym obiektem, który nie posiada – jakby to ładnie powiedzieć – tożsamości. Może będzie prościej zacząć od innej strony.

Równość dwóch value objectów określany jest na podstawie danych jakie one zawierają. Tylko dane tutaj wchodzą w grę. Nie bierzemy pod uwagę żadnych identyfikatorów. Nie ma mowy, więc o unikalności. Obiekty reprezentują tylko to co posiadają.

Jako ciekawostka dodam, że inaczej to wygląda może w znanych Ci encjach. W encji to id określa jasno tożsamość obiektu i dzięki temu wiadomo czy mamy przed sobą dwa identyczne obiekty czy też nie.

Skoro wiemy, że Value Object nie ma żadnego identyfikatora to nie musimy się całkowicie martwić o ich unikalność. Możemy je tworzyć i porzucać jak tylko nam się podoba. Bez żadnych zmartwień czy też wyrzutów sumienia. 

Na ten moment może wystarczy tej teorii, bo od tej strony może to wyglądać zbyt skomplikowanie. Najprostszym przykładem Value Object, z którym możesz się spotkać w wielu aplikacjach jest okres dat. Wygląda on mniej więcej tak.

import java.time.LocalDate;
import java.util.Objects;

public class Period {
    private final LocalDate from;
    private final LocalDate to;

    public Period(LocalDate from, LocalDate to) {
        if (from.isAfter(to)) {
            throw new IllegalArgumentException("Date from cannot be after date to");
        }
        this.from = from;
        this.to = to;
    }

    public LocalDate getFrom() {
        return from;
    }

    public LocalDate getTo() {
        return to;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Period period = (Period) o;
        return Objects.equals(from, period.from) && Objects.equals(to, period.to);
    }

    @Override
    public int hashCode() {
        return Objects.hash(from, to);
    }
}

Powyższy kod nie powinien zaskoczyć osoby, która poznała podstawy programowania obiektowego w Javie. Na pierwszy rzut oka nie dzieje się tutaj nic ciekawego, jednak muszę poruszyć dwie kwestie.

Stworzenie Value Object oznacza, że jest on w poprawnym stanie. Wszystkie dane jakie powinny być – są i do tego są odpowiednie. Tak jak w powyższym przykładzie – skoro obiekt definiuje okres to z góry możemy uniemożliwić stworzenie okresu, gdzie data “do” przypada na dzień wcześniejszy niż data “od”.

Dzięki takiemu prostemu zabiegu korzystając z obiektu zawsze mamy pewność, że jest poprawny. W przypadku, gdy próbujemy podać niepoprawne dane, utworzenie obiektu nie doszłoby do skutku i potencjalny błąd w aplikacji zostałby wyłapany dużo wcześniej.

Wokoło terminu Value Object krąży jeszcze jedno istotne zagadnienie. Jest to niemutowalność (ang. immutable). Dlaczego o tych dwóch zagadnieniach zazwyczaj słyszy się w parze?

Ze względu na charakterystykę i wykorzystanie value objectów zaleca się, aby były one niemutowalne. Immutable objects charakteryzuje niezmienność stanu przez cały cykl jego życia. Na czym to polega?

W każdym momencie, gdy chcemy wykonać jakąkolwiek zmianę na obiekcie w zamian otrzymujemy nowy obiekt. Nigdy zmiana nie oddziałowuje na zmianę stanu bieżącego obiektu. Wykonujemy zmianę, tworzony jest nowy obiekt, a stary jest porzucany na pociechę Garbage Collectora.

Nie chcę wchodzić w szczegóły niemutowalności obiektów. Jeśli chcesz dowiedzieć się więcej na ten temat to tutaj trochę o już tym napisałem.

Dlaczego niemutowalność jest tak istotna?

Skoro Value Object nie posiada żadnej tożsamości i dodatkowo jest niemutowalny to bez problemu może być współdzielony przez wiele części systemu. 

  1. Nie ma tożsamości – boom, nie musimy martwić się o synchronizację jego stanu w całym systemie. Wyobraź sobie obiekt typu książka (Book) przechowywany w bazie danych. Każda aktualizacja stanu takiej książki wiąże się z pewnymi wymogami. Nie można tak o, byle gdzie sobie jej modyfikować.
  2. Jest niemutowalny – nie ma cienia szansy, że ten sam obiekt przekazany do dwóch modułów zostanie w jakikolwiek sposób zmodyfikowany. Praca jednego modułu nie wpłynie wtedy na działanie drugiego. Nawet jeśli jeden z modułów go zmodyfikuje to od tej pory będzie już używał nowej instancji. Wyobraź sobie, że dwa moduły wykorzystują value object do prezentacji wartości pieniądza (waluta + wartość). Nie ma możliwości, aby jedna część systemu po prostu ot tak zmodyfikowała wartość, a druga pracowała na nowej wartości. 

Jak stworzyć Value Object?

Po wstępie teoretyczno-praktycznym nie powinieneś mieć już zbyt dużych problemów ze stworzeniem Value Object w Javie. Jedynym wyzwaniem może być zapewnienie niemutowalności obiektu. Szczególnie w sytuacji, gdy obiekt przechowuje w sobie kolekcje. Ale to już temat na inny artykuł.

W poprzednim akapicie przedstawiłem Ci implementację okresu dat. Teraz przedstawię Ci przykład reprezentancji pieniądza, o której już wspominałem. Będzie to przykład, w którym będę mógł zawrzeć wszystkie powyższe cechy value objectu. Do dzieła.

Na początek stworzę klasę, która będzie przechowywać wartość i walutę pieniądza. Walutę zdefiniuje jako enum.

import java.math.BigDecimal;

public class Money {
    private final BigDecimal value;
    private final Currency currency;

    public Money(BigDecimal value, Currency currency) {
        this.value = value;
        this.currency = currency;
    }

    public BigDecimal getValue() {
        return value;
    }

    public Currency getCurrency() {
        return currency;
    }

    public static enum Currency {
        PLN, EURO, DOLLAR
    }
}

Zacząłem od pól, konstruktora i getterów. Jako następny krok zadbajmy, aby powstały obiekt typu Money zawsze był w odpowiednim stanie. Do określenia tego zastosuje kryteria, że wartość musi być większa bądź równa zero oraz należy podać walutę.

public Money(BigDecimal value, Currency currency) {
     if (value.compareTo(BigDecimal.ZERO) < 0) {
         throw new IllegalArgumentException("Value cannot be lower than 0");
     }
     if (currency == null) {
         throw new IllegalArgumentException("Currency cannot be null");
     }
     
     this.value = value;
     this.currency = currency;
 }

Obiekt jest już tworzony w odpowiedni sposób. Kolejnym krokiem jest zapewnienie równości dwóch value objectów. Czyli ich porównanie musi odbywać się na podstawie wartości ich pól. W takim przypadku całkiem nieźle radzą sobie equals i hashCode wygenerowane przez IntelliJ Idea.

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    Money money = (Money) o;
    return Objects.equals(value, money.value) && currency == money.currency;
}

@Override
public int hashCode() {
    return Objects.hash(value, currency);
}

Nasz Value Object wygląda już całkiem nieźle. Jednak nie chcemy go używać jedynie jako worek na dane. Okazuje się, że będziemy potrzebować mieć możliwość sumowania pieniędzy.

I to jest ostatni i najtrudniejszy etap. Jak zaimplementować sumowanie obiektów typu Money tak, aby nadal były niemutowalne? Musimy posłużyć się zdaniem z akapitu powyżej.

W każdym momencie, gdy chcemy wykonać jakąkolwiek zmianę na obiekcie w zamian otrzymujemy nowy obiekt.

Więc też tak zróbmy.

public Money add(Money money) {
    if (this.currency != money.currency) {
        throw new IllegalArgumentException("Cannot add if currencies are different");
    }

    BigDecimal newValue = this.value.add(money.value);
    return new Money(newValue, this.currency);
}

Już tłumaczę co tutaj się dzieje.

  1. Weryfikacja – sprawdzamy czy możemy w ogóle dodać do siebie te obiekty. Mega przydatne. Taka prosta konstrukcja może zabezpieczyć Cię przed wieloma nieoczekiwanymi błędami w aplikacji. Nie ma szans, że ktoś przez przypadek doda euro do dolarów. Nigdy się tak nie stanie, ponieważ to obiekt Money tego pilnuje. Unikamy potencjalnego błędu.
  2. Wyliczanie – wyliczamy nowy stan. W naszyzm przypadku dodajemy wartości BigDecimal.
  3. Zwrotka – tak jak napisałem powyżej. Każda modyfikacja powoduje utworzenie nowego obiektu. Dzięki temu, że w linii numer 7 nie modyfikujemy aktualnego obiektu (this), to obiekt typu Money jest na pewno niemutowalny.

I twój pierwszy Value Object gotowy. Voilà. 

 

import java.math.BigDecimal;
import java.util.Objects;

public class Money {
    private final BigDecimal value;
    private final Currency currency;

    public Money(BigDecimal value, Currency currency) {
        if (value.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Value cannot be lower than 0");
        }
        if (currency == null) {
            throw new IllegalArgumentException("Currency cannot be null");
        }

        this.value = value;
        this.currency = currency;
    }

    public Money add(Money money) {
        if (this.currency != money.currency) {
            throw new IllegalArgumentException("Cannot add if currencies are different");
        }

        BigDecimal newValue = this.value.add(money.value);
        return new Money(newValue, this.currency);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Money money = (Money) o;
        return Objects.equals(value, money.value) && currency == money.currency;
    }

    @Override
    public int hashCode() {
        return Objects.hash(value, currency);
    }

    public BigDecimal getValue() {
        return value;
    }

    public Currency getCurrency() {
        return currency;
    }

    public static enum Currency {
        PLN, EURO, DOLLAR
    }
}

Jakie są zalety i wady value object?

Pokrótce przyjrzyjmy się jakie są plusy i minusy stosowania value object w aplikacji.

Wady

  1. Ilość obiektów – wraz z rozwijaniem projektu ich ilość może być całkiem spora i może być ciężko się w nich odnaleźć. Na pewno trzeba uważać i tworzyć value objecty tylko w tych miejscach, gdzie widzimy, że enkapsulacja właściwości przynosi realne korzyści. Nie ma co tworzyć na siłę, jeśli w max kilku miejscach potrzebujemy przesłać jakąś wartość to nieoznacza, że trzeba natychmiast tworzyć dedykowany value object  zamiast przesłać je np. jako typ prymitywny.
  2. Wydajność (?) – value objecty mają to do siebie, że jest tworzone sporo ich instancji. W jednym momencie może ich żyć wiele. Z drugiej strony są na tyle małe, że są szybko sprzątane. Zostawiam znak zapytania bo w przypadku niektórych systemów może to być jakaś bolączka. Tak przynajmniej sobie wyobrażam.

Zalety

  1. Reprezentacja – zgrupowanie podobnych/wspólnych właściwości, które razem tworzą całość. Taki obiekt istnieje tylko w poprawnym staniem. Klient obiektu nie musi obawiać się poprawność danych – nie musi wykonywać dodatkowych sprawdzeń zanim coś wykona. Korzysta z obiektu typu Money? To wie, że nie znajdzie tam wartości ujemnych.
  2. Reużywalność – brak tożsamości sprawia, że mogą być wielokrotnie używane. Można je łatwo stworzyć i w każdym momencie porzucić. Bez wyrzutów sumienia. Naprawdę.
  3. Enkapsulacja – może nie wybrzmiała odpowiednio w poprzednich akapitach. Value object ukrywa reprezentację danych, które posiada. W każdym momencie może się ona zmienić i nie powinna ona drastycznie wpłynąć na resztę systemu. Dobrym przykładem value objectu jest użycie do reprezentancji id np. książki. Obiekt typu BookId może przechowywać id jako long, ale z czasem może się zmienić na string. Taka zmiana nie powinna wpływać na cały system, ponieważ wszędzie powinien być używany BookId.
  4. Niemutowalność (?) – nie jest to bezpośrednio zaleta value object (dlatego też znak zapytania). Jednak będąc przy value object stosowanie niemutowalności jest zawsze rekomendowane. Naprawdę musisz uwierzyć mi na słowo. Potrafi to zniwelować wiele potencjalnych błędów w aplikacji, a nie kosztuje Cię to wiele.

Czy powinieneś stosować Value Object w swoim kodzie?

Patrząc na zestawienie wad i zalet z pewnością jasne by było, że powinieneś stosować.  Jaka jest na to moja ostateczna odpowiedź?

Oczywiście, że powinieneś jeśli tylko widzisz taką możliwość i wprowadzenie go da realne korzyści. Wpychanie go wszędzie na siłe na pewno nie będzie miało sensu. Zwiększy to tylko skomplikowanie projektu, a nic dobrego ze sobą nie przyniesie.

Wiem jednak, że w wielu miejscach możesz go zastosować i mieć z tego korzyści. Jednym z takich miejsc, z których już wspominałem to reprezentowanie id encji w systemie. Naprawdę nie ma sensu przepychać wszędzie typów prymitywnych np. String lub Long.

Wyobraź sobie, gdy nagle zmieni się typ takiego identyfikatora. Albo zmieni się sposób identyfikowania encji np. teraz to będzie para dwóch id. Refaktor teoretycznie prosty, ale może okazać się ogromny.

Identyfikatory są najprostszym przykładem jaki jestem w stanie Ci podać na tacy bez przeczytania choćby linii kodu twojej aplikacji. Jednak pewnie w systemie znajdzie się o wiele miejsc, gdzie można zastosować value objecty. Tych musisz już poszukać sam. Jednak pamiętaj tylko jedno – niektóre z value objectów są już dostarczane przez biblioteki. Idealnym przykładem jest klasa Money z popularnej biblioteki Joda Money.

Ciekawostka

Jako ciekawostka powiem, że tworzenie value objectów jest dużo prostsze z biblioteką Lombok. Biblioteka ta generuje za nas sporo zbędnego boilerplate kodu takiego jak konstruktory, gettery, setter itp. 

Jedną z funkcjonalności jaką dostarcza Lombok jest robienie z klasy value object. Zapewnia to przez:

  1. zrobienie wszystkich pól jako prywatne i finalne;
  2. wygenerowanie getterów;
  3. niewygenerowanie setterów;
  4. klasa oznaczana jest jako final – czyli nie można po niej dziedziczyć;
  5. generuje metody equals i hashCode;
  6. konstruktor dla wszystkich pól;

I to wszystko możemy mieć dodając jedynie adnotację @Value nad klasą.

Podsumowanie

To wszystko co chciałem Ci przedstawić na temat value objectów w Javie. Mam wielką nadzieję, że materiał był dla Ciebie zrozumiały i wiele z niego przyswoiłeś.

Jestem ciekaw czy przed zapoznaniem się z artykułem znałeś koncept Value Object i może stosowałeś go już podczas tworzenia aplikacji?

Czy jednak jest to Twój pierwszy raz kiedy o nim słyszysz i planujesz dopiero go zastosować? Czy wiesz już w jakim przypadku?

Czekam na Wasze odpowiedzi w komentarzach poniżej.

Kamil Klimek

Od 2016 jestem programistą Java. Przez pierwsze 4 lata pracowałem jako Full Stack Java Developer. Później postanowiłem postawić nacisk na Javę, żeby jeszcze lepiej ją poznać.

Subscribe
Powiadom o
guest
0 komentarzy
Inline Feedbacks
View all comments
0
Would love your thoughts, please comment.x