W ostatnim artykule na temat value object opowiedziałem Wam czym są value object’y, jak je zaimplementować oraz dlaczego w większości przypadków są lepsze od bezpośredniego używania wartości prymitywnych. Jeśli go jeszcze nie widzieliście to przed dalszym czytaniem zachęcam Was do zapoznania się z nim. Ten artykuł jest tylko jego rozszerzeniem bazującym na Waszych sugestiach oraz pytaniach, które pojawiły się pod ostatnim postem odnośnie value object’ów na profilu drycode na facebook’u do którego Was serdecznie zapraszam.
Kompozycja (zagnieżdżanie) value object’ów
Pierwszym tematem, który chciałbym poruszyć jest możliwość zagnieżdżania value object’ów. We wcześniejszym artykule pokazałem Wam jedynie bardzo proste „jednopoziomowe” value object’y, jednak nic nie ogranicza nas przed tym aby nieco bardziej je skomplikować. Oczywiście nie zrozumcie mnie, źle nie mam na myśli celowej komplikacji, a jedynie przedstawienie złożoności biznesowej za pomocą kodu.
Aby zaprezentować Wam co dokładnie mam na myśli użyję nieco uproszczonego value object’u Money
z poprzedniego artykułu. Zawiera on logikę odpowiedzialną za pieniądze, a dokładniej sprawdza czy podana waluta jest poprawna oraz zapobiega odejmowaniu i dodawaniu pieniędzy, które różnią się walutą.
class Money { private readonly value: number; private readonly currency: string; constructor(value: number, currency: string) { if (!this.checkIsCurrencyValid(currency)) { throw new Error('Invalid currency'); } this.currency = currency; this.value = value; } public subtract(money: Money): Money { if (!this.checkIsTheSameCurrency(money)) { throw new Error('Invalid currency'); } const newValue = this.value - money.value; return new Money(newValue, this.currency); } public add(money: Money): Money { if (!this.checkIsTheSameCurrency(money)) { throw new Error('Invalid currency'); } const newValue = this.value + money.value; return new Money(newValue, this.currency); } private checkIsTheSameCurrency(money: Money): boolean { return this.currency === money.currency; } private checkIsCurrencyValid(currency: string): boolean { //.. } }
Wyobraźmy sobie teraz, że pracujemy nad systemem e-commerce, który sprzedaje jakieś produkty. Bazując na poprzednim artykule oczywiście zdecydowaliśmy się na zaimplementowanie powyższego value object’u, ale teraz musimy się jeszcze zająć ceną produktów. Biznes mówi nam, że cena musi być wyższa od 0 oraz, że można dodać do niej rabat, ale tylko wtedy kiedy rabat jest wyrażony w takiej samej walucie jak cena. To znaczy, żeby na przykład nie można było z rabatować ceny o 10zł w momencie kiedy jest ona wyrażona w dolarach. Moglibyśmy to zrobić w taki sposób:
class Price { private readonly value: number; private readonly currency: string; constructor(value: number, currency: string) { if (!this.checkIsCurrencyValid(currency)) { throw new Error('Invalid currency'); } if (value <= 0) { throw new Error('Price cannot be less or equal zero'); } this.currency = currency; this.value = value; } public makeDiscount(price: Price): Price { if (!this.checkIsTheSameCurrency(discount)) { throw new Error('Invalid currency'); } const newValue = this.value - discount.value; return new Price(newValue, this.currency); } private checkIsCurrencyValid(currency: string): boolean { //.. } private checkIsTheSameCurrency(price: Price): boolean { return this.currency === price.currency; } }
Na pierwszy rzut oka wszystko wydaje się być w porządku. Kod jest czytelny, reguły biznesowe się zgadzają. Jednak gdy się bliżej przyjrzymy to możemy zauważyć, że tak naprawdę nasza cena ma jedynie jedną dodatkową regułę względem Money
, a mianowicie, że nie może być niższa bądź równa 0. Pozostała logika jest taka sama.
Co więcej nasza metoda makeDi
scount wydaje się być nieco sztuczna, a konkretnie jej argument, który jest typu Price
. Przecież nie mówimy „z rabatuj cenę 100zł o cenę 10zł” tylko „z rabatuj cenę o 10zł”. Klient naszego kodu widząc tylko syntax naszej metody, z podpowiedzią typu mógłby się zacząć zastanawiać, czy to znaczy, że obniżamy cenę o 10zł, czy może ustawiamy nową cenę na 10zł. Moglibyśmy wprowadzić nowy byt Discount
ale NA TEN MOMENT nie widzimy większego sensu, ponieważ nie różni się on niczym od Money
.
Więc co w tym przypadku należy zrobić? Zamiast duplikować logikę zawartą w klasie Money
możemy użyć już gotowego value object’u. Dzięki temu pozbędziemy się zbędnego kodu dotyczącego sprawdzania, poprawności waluty w klasie Price
, który aktualnie zaciemnia nam ważną logikę biznesową. Co więcej jeśli kiedyś zdecydujemy się dodać nowe reguły do Money
na przykład zaokrąglanie miejsc po przecinku, to zrobimy to tylko w tej klasie bez konieczności modyfikowania klasy Price
.
Nasz nowy value object Price
korzystający z Money
może wyglądać następująco:
class Price { private readonly value: Money; constructor(value: Money) { if (value <= 0) { throw new Error('Price cannot be less or equal zero'); } this.value = value; } public makeDiscount(money: Money): Price { const newValue = this.value.subtract(discount); return new Price(newValue); } }
Jak widzimy klasa jest o wiele mniejsza i zdecydowanie bardziej czytelna. Sama metoda makeDiscount
jest również bardziej zrozumiała, ponieważ jej argumnet nie jest aż tak mylący.
Dlaczego nie dziedziczenie?
Tutaj zasada jest taka sama jak i bez stosowania value object’ów dziedziczenie stosujemy tylko i wyłącznie w momencie kiedy nasz podtyp korzysta z wszystkich funkcji nad typu. Prościej mówiąc kiedy mamy pewność, że jedynie rozszerzamy klasę po której dziedziczymy i nie modyfikujemy jej zachowań. Jest to oczywiście bardzo ogólne stwierdzenie i jeśli chcecie wiedzieć więcej na ten temat to zachęcam Was do poczytania artykułów na temat composition over inharitence.
Generalnie zauważcie, że jeśli nasza klasa Price
dziedziczyłaby po klasie Money
to faktycznie zawierałaby w sobie logikę odpowiedzialną za sprawdzanie waluty:
class Price extends Money { constructor(value: number, currency: string) { super(value, currency); if (value <= 0) { throw new Error('Price cannot be less or equal zero'); } } public makeDiscount(price: Price): Price { const newValue = this.value - discount.value; return new Price(newValue, this.currency); } }
Natomiast posiadałaby również metody add
oraz substract
, zwracające obiekt Money
. Jest to bardzo mylące i nieintuicyjne zachowanie klasy Price
.
const price = new Price(1, 'a'); price.subtract(); // ??? price.add(); // ???
Więc trzeba by je było nadpisać i na przykład rzucać słynny error Not Implemented
.
class Price extends Money { constructor(value: number, currency: string) { super(value, currency); if (value <= 0) { throw new Error('Price cannot be less or equal zero'); } } public makeDiscount(price: Price): Price { const newValue = this.value - discount.value; return new Price(newValue, this.currency); } public subtract(): Money { throw new Error('Not implemented'); } public add(): Money { throw new Error('Not implemented'); } } const money = new Money(1, 'a'); const price = new Price(1, 'a'); price.subtract(money); // ??? price.add(money); // ???
Oczywiście metody nadal są widoczne, ale zamiast zwracać dziwną klasę Money
to rzucają Error
. Problem nasili się jeszcze bardziej, kiedy do Price
dojdzie logika, która jest sprzeczna/wzajemnie się wykluczająca z logiką znajdującą się w klasie Money
.

Powyższy przykład TO NIE JEST REKOMENDACJA PROGRAMISTYCZNA i za każdym razem kiedy tak robicie ginie mały, biedny, słodki kotek lub jak kto woli pomiot szatana.

Słownictwo i mnogość reprezentacji
Jak wszyscy wiemy odpowiednie nazewnictwo w programowaniu jest kluczowe i jest jedną z cięższych rzeczy do zrobienia, jeśli chcemy zrobić to dobrze. Jeszcze większy problem sprawia wydobycie odpowiedniego słownictwa od ludzi biznesu. Czasami biznes posługuje się synonimami lub uproszczeniami i nie mówi wprost, że dana rzecz nie jest tym czym myślimy, że jest. Dla nich nazewnictwo jest oczywiste i przyjmują za pewnik, że my również je rozumiemy.
Aby Wam to zobrazować wróćmy do przykładu z metodą makeDiscount
.
public makeDiscount(money: Money): Price { const newValue = this.value.subtract(discount); return new Price(newValue); }
Zaimplementowaliśmy ją w ten sposób no bo przecież biznes powiedział, że „powinniśmy dodać funkcję, która umożliwi z rabatowanie ceny o na przykład 10zł” więc wydało się to dosyć jasne i oczywiste. Po jakimś czasie biznes stwierdził, że rabaty są fajne ale powinniśmy je ograniczyć do max 50 zł/dolarów/euro. Oczywiście dodajemy tą funkcjonalność.
public makeDiscount(money: Money): Price { // Pamiętajmy, że musimy również zmienić pole value na public lub stworzyć getter if (money.value > 50) { throw new Error('Discount is invalid'); } const newValue = this.value.subtract(discount); return new Price(newValue); }
Ta funkcjonalność również wyszła fajnie, więc biznes rzucił kolejny pomysł, aby rabat można było również wyrazić w procentach…
Jak widzimy wraz z rozwojem oprogramowania nasza klasa Price
zdobywa coraz więcej i więcej dodatkowej logiki za którą tak naprawdę nie powinna odpowiadać. Dzieje się tak, ponieważ nie wychwyciliśmy, że Discount
to tak naprawdę nie jest Money
pomimo tego, że na początku wydawała się być tym samym i pieniądze naturalnie przychodziły nam na myśl kiedy mówiliśmy rabat
, a ludzie biznesu nie wyprowadzali nas z błędu, ponieważ przyjęli za oczywistość, że to wiemy.
Gdybyśmy na początku zadali odpowiednie pytania na przykład:
- Jak może być wyrażony rabat?
- Czy rabatujemy zawsze o konkretną kwotę?
- Czy rabat może zwiększyć cenę”? (Już samo pytanie brzmi absurdalnie, a przecież w obecnej implementacji mamy możliwość zwiększenia ceny za pomocą rabatu)
To okazałoby się, że Discount
to wcale nie jest Money
i powinien zostać potraktowany jako osobny byt, który być może podobnie jak Price
zawierałby w sobie Money
. Oczywiście ktoś może zapytać „A po co mamy się męczyć i wypytywać o to już na samym początku? Po jakimś, czasie i tak to sami zauważymy”. Odpowiedź brzmi i tak i nie.
Założę się, że każdy z Was miał taki moment w życiu kiedy musiał coś na szybko dodać do przerośniętej metody obiecując sobie „To już ostatni raz! Muszę to zrobić bo czas mnie goni! Następnym razem zrobię refactor!”.

Kolejną kwestią jest również sam poziom skomplikowania danej logiki. Na potrzeby artykułu reguły i sposób wydzielenia jest dosyć prosty, ale przy bardziej skomplikowanej logice kiedy nie mamy jednego value object’u tylko dziesiątki powiązanych ze sobą klas już nie jest tak łatwo odwrócić tego procesu. Zdecydowanie łatwiej jest wykryć i zapobiec ewentualnej katastrofie i potrzebie refactoru już na samym początku dobrze poznając język i domenę biznesową, a przynajmniej próbując.
Wiadomo, że od razu nie wychwycicie wszystkiego bo prawdopodobnie nie jesteście ekspertami w danej domenie i będziecie ją poznawać przez cały okres pracy nad projektem. Proces wytwarzania oprogramowania to proces ewolucyjny więc trzeba być czujnym i szybko reagować na pojawiające się nieścisłości. Jeśli widzimym, że byt, który zidentyfikowaliśmy na samym początku nie pasuje do naszych założeń to należy go zmienić. Nie bójmy się zadawać pytań ludziom biznesu.
Jednym z problemów, który często się powtarza w naszych systemach jest to, że uznajemy, że język, którym posługują się ludzie, w całej organizacji jest taki sam i poszczególne słowa znaczą to samo we wszystkich kontekstach. Jest to jeden z większych błędów, który popełniamy. Z reguły poszczególne słowa znaczą TO SAMO tylko w obrębie jednego kontekstu. Weźmy na przykład słowo „zamek”. Zupełnie co innego oznacza w architekturze (zamek jako budowla), krawiectwie (zamek do spodni), a jeszcze co innego w stolarstwie (zamek do drzwi).
Dokładnie tak, samo jest w naszych systemach. Pomimo, że na pierwszy rzut oka może się wydawać, że słowo „produkt” znaczy dokładnie to samo w całej organizacji, to po bliższym zapoznaniu się z biznesem może okazać się, że: słowo produkt w dziale magazynu oznacza coś co ma wielkość, wagę i logikę związaną z magazynem, w dziale sprzedaży coś co ma opis, zdjęcie i cenę oraz logikę związaną ze sprzedażą, a w dziale księgowości coś co ma nazwę, koszt wytworzenia, marżę, podatek oraz logikę związaną z fakturowaniem. Jeśli nie zauważymy tych różnic, to zaprojektujemy Product
w naszym systemie jako ogromną klasę z mnóstwem logiki biznesowej no bo przecież produkt to produkt, więc nie ma sensu tego rozdzielać.
Często jeśli próbujemy stworzyć value object’y to wpadamy w powyższą pułapkę i wszystko pchamy do współdzielonego modułu, który często zawiera ogromne ilości dużych klas. Jeśli coś już trafi do takiego modułu to z reguły traci swojego właściciela i każdy coś do takich klas dodaje bez sprawdzania, czy aby na pewno nie niesie to za sobą jakiś konsekwencji w innych modułach w których dana klasa jest wykorzystywana. Dlaczego to jest złe podejście opisałem w tym artykule.
Jednym z przykładów takiego value object’u z którym się spotkałem był kraj. Była to klasa, którą wzięto za coś oczywistego co powinno trafić do shared modules
no bo przecież kraj to kraj i wszędzie znaczy to samo. Po jakimś czasie okazało się, że jednak nie do końca. W jednym kontekście kraj faktycznie miał jakąś nazwę i swój kod, w innym kraj miał jakieś regulacje prawne, w jeszcze innym kraj zawierał poziom ryzyka, a w kolejnym informacje na temat podatków obowiązujących w danym kraju. Taka rozbieżność nie została na początku zauważona, przez co klasa Country
była ogroma. A jako, że korzystało z niej wiele modułów oraz wiele zespołów to bardzo ciężko było się wycofać z tej decyzji.
Więc zanim uznamy coś za byt, który jest używany we wszystkich kontekstach to warto się zastanowić, czy aby na pewno posiada jakieś cechy wspólne prócz samej nazwy. Pamiętajcie, również, żeby nie zwracać uwagi tylko na samą strukturę obiektu na przykład uznając, że produkt jest jednym i tym samym ponieważ i produkt w dziale księgowości i sprzedaży zawiera w sobie pole o nazwie name
. Najważniejsze są reguły biznesowe i zachowania jakie udostępnia.
Czy zatem każdy kontekst powinien posiadać swoje value object’y?
Oczywiście, że nie. Chciałem tylko zaznaczyć, że przed podjęciem decyzji na temat współdzielenia danego value object’u należy dobrze przeanalizować sytuację i zadać odpowiednie pytania w celu wydobycia odpowiedniego słownictwa i jego znaczeń w danych kontekstach.
Natomiast bardzo często zdarzają się sytuacje w których potrzebujemy współdzielić dany value object na przykład Money
lub Email
bo w każdym kontekście znaczy dokładnie to samo. Każdy przypadek należy rozpatrywać indywidualnie, bo to że w jednym systemie słowo Email
znaczy wszędzie to samo nie oznacza, że w innym również.
Podsumowując
Zastanówmy się czy aby na pewno dane słowo znaczy to samo we wszystkich kontekstach oraz czy aby na pewno słowo, którym się posługujemy jest poprawne i nie jest tylko skrótem myślowym/uproszczeniem dla prawdziwego bardziej skomplikowanego znaczenia tak jak w przypadku rabatu. Oczywiście powyższe stwierdzenia nie tyczą się tylko value object’u. Są one agnostyczne względem struktur i powinniśmy zwracać na nie uwagę nie tylko w momencie w którym korzystamy z value object’ów, ale zawsze kiedy tworzymy jakieś oprogramowanie.
Przydatne rzeczy podczas pracy z value object’ami
Biblioteki
Tak jak wspomniałem w ostatnim artykule value object’y tworzymy po to aby z enkapsulować w nich naszą logikę biznesową. Bardzo często elementami tej logiki jest odpowiednia walidacja na przykład: czy podana wartość jest typu String
, czy podana wartość jest poprawnym numerem telefonu itp. W większości przypadków nie ma sensu tracić czasu na pisanie tej logiki samemu i warto skorzystać z gotowych rozwiązań takich jak:
- https://github.com/typestack/class-validator w połączeniu z https://github.com/typestack/class-transformer
- https://github.com/hapijs/joi
Typescript
Bardzo pomocne jest również użycie TypeScript, który zapewnia nam typy bardzo ułatwiające pracę z value object’ami i nie tylko.
Typowanie nominalne
Kolejną rzeczą o której już wspomniałem w poprzednim artykule jest zapewnienie, a w zasadzie zasymulowanie typowania nominalnego dla WSZYSTKICH value object’ów. Na pierwszy rzut oka może się to wydawać zbędne szczególnie jeśli każdy value object ma inną strukturę, ale uwierzcie, że w raz z biegiem czasu to bardzo zaprocentuje. Wraz z rozwojem oprogramowania bardzo często będziecie tworzyć „Strukturalne duplikaty”, które przez pomyłkę można przekazać w nieodpowiednie miejsce.