Jak zaimplementować Value Object z DDD w C#
Value Object to bardzo użyteczny Building Block z DDD. Jego użycie znacznie zwiększa ekspresywność kodu i redukuje ilość defensywnej logiki. Jak składnia C# wpiera implementację Value Objectów? Których konstrukcji językowych używać, żeby maksymalnie zwiększyć czytelność? Na szczęście C# ma w tym obszarze sporo do zaproponowania, więc kod może być naprawdę elegancki.
DDD-starter-dotnet
Ten post jest częścią projektu DDD-starter-dotnet. Zachęcamy do obserwowania:
- Kodu: https://github.com/itlibrium/DDD-starter-dotnet
- Bloga: https://itlibrium.com/blog?tag=DDD-starter
- Twittera: ITLIBRIUM, Marcin, Szymon
Co modelować przez Value Objecty?
Primitive obsession
Najczęściej jako przykłady Value Objectów podaje się Money
, identyfikatory Agregatów, czy inne tego typu obiekty przechowujące jedną lub dwie wartości. Dzięki temu można uniknąć nadmiernego wykorzystywania typów prostych, które same z siebie nie niosą żadnej informacji biznesowej. Kod oparty na typach prostych może mieć poprawną syntaktykę, ale brakuje mu semantyki. W efekcie bardzo ciężko jest go zrozumieć, gdy nie zna się kontekstu biznesowego i okoliczności, w których powstawał. Często nawet znając ten kontekst nie jest to proste zadanie.
Typy proste mogą też przyjmować wartości, które w konkretnym kontekście mogą nie mieć sensu biznesowego np. DateTime.MinValue
lub int < 0
. Rolą Value Objectów jest walidacja wartości i reprezentowanie tylko takich, które mają sens w modelowanej domenie.
Kod czyta się znacznie częściej niż się go modyfikuje, warto więc zadbać o jego czytelność. Tworzenie wielu Value Objectów, tylko po to żeby int
, string
, czy bool
nabrały znaczenia biznesowego może wydawać się dużym narzutem. W rzeczywistości samo dodawanie typów nie jest jednak w zasadzie zauważalne. To co jest zauważalne to zastanowienie potrzebne do właściwego wyznaczenie granic poszczególnych Value Objectów i nadania im dobrych nazw. Tego narzutu nie warto jednak unikać, jeżeli kod ma być Modelem biznesu, a nie tylko zbiorem instrukcji dla procesora.
Nie tylko Money
Value Objecty mają jednak znacznie więcej do zaoferowania niż tylko nadawanie znaczenia typom prostym. Jest wiele koncepcji biznesowych, które pasują do tego wzorca. Jako przykład weźmy ofertę składającą się z wycenionych produktów. Czy oferta jest Agregatem a wycenione produkty Encjami? Być może tak, jeżeli biznes chce wrócić do tej konkretnej oferty i tej konkretnej wyceny produktu, bo np. została ona zaakceptowana przez klienta i jest od tej pory zobowiązaniem firmy. Wcale nie musi jednak tak być.
Ludzie z IT mają tendencję do traktowania wszystkich koncepcji biznesowych jako porcji danych, które trzeba zachować pod jakimś Id, żeby później móc je pobrać i wykorzystać do kolejnych operacji. To nie zawsze jest dobra droga. Jeżeli wycenione produkty i składająca się z nich oferta są czymś, co trzeba móc obliczyć, porównać, wyświetlić klientowi, etc. to może Value Object jest wszystkim czego potrzebujemy? Może w ogóle nie potrzebujemy zapisywać takiego obiektu do bazy danych? Warto odpowiedzieć sobie na takie pytania i wybrać taki model i taką implementację, która najlepiej oddaje biznes i jest przy tym najlżejsza.
Czym powinien charakteryzować się dobry Value Object?
Przede wszystkim immutable
Jedną z podstawowych cech Value Objectów jest ich niezmienność. Niestety C# nie ma takiej koncepcji jak immutability. Musimy sobie radzić inaczej i sami zapewnić niezmienność naszych obiektów.
Ważne jest to, żeby niezmienny był cały graf obiektów, a nie tylko jego korzeń. Jeżeli Value Object zawiera tylko typy proste, to nie ma problemu. Jeżeli zawiera jednak inne obiekty, to one również muszą być immutable. Dotyczy to szczególnie kolekcji, o czym czasami się zapomina. Modyfikacja składu kolekcji to też modyfikacja. Używanie interfejsów IReadOnly...<T>
nie jest 100% obroną. Kolekcja jest wtedy wystawiana jako jedynie do odczytu, ale sama w sobie jest jak najbardziej modyfikowalna. Można ją zmienić z wnętrza obiektu, gdzie najprawdopodobniej została zadeklarowana jako modyfikowalna. Można ją też zawsze rzutować, a następnie modyfikować bez ograniczeń. W .net istnieje jednak cała gama kolekcji Immutable...<T>
, które są faktycznie niezmienne, warto rozważyć ich użycie.
Ten sam, czy taki sam?
Tożsamość to bardzo ważny aspekt każdego obiektu przechowującego stan. W przypadku Value Objectów nie interesuje nas jakiś konkretny egzemplarz np. zamówienie o numerze ABC123. Interesuje nas tylko, żeby dany obiekt miał konkretne cechy. Tak jak np. z pieniędzmi. Każdy banknot 100 zł jest dokładnie taki sam i można ich używać całkowicie wymiennie. Właśnie tego typu koncepcje należy modelować przez Value Objecty.
Skoro Value Object nie ma globalnej identyfikacji, to nie potrzebujemy w nim żadnego pola w stylu Id
. Ma to też drugą ważną konsekwencję. Albo obiekt ten będzie żył bardzo krótko (w czasie obsługi jednego żądania), albo będzie częścią czegoś co żyje dłużej czyli Agregatu.
class czy struct ?
Pod kątem niezmienności najbliższą konstrukcją języka C# jest readonly struct
. Używanie struktur w kodzie biznesowym budzi jednak wiele kontrowersji.
Zaletą jest wymuszenie na poziomie kompilacji, żaby wszystkie pola / właściwości były readonly
. Trzeba jednak pamiętać, że nie jest to równoznaczne z byciem immutable. Inną zaletą jest lepsza wydajność, gdyż dla struktur nie trzeba alokować pamięci, a Garbage Collector nie musie po nich sprzątać. Dla struktur mamy też szerszą gamę potencjalnych optymalizacji. Czasami może to mieć znaczenie, jeżeli piszemy kod wymagający bardzo niskich opóźnień.
Wady są zasadniczo dwie, ale nie są one poważne i istnieją proste metody uniknięcia problemów.
Po pierwsze porównywanie struktur jest delikatnie mówiąc "dziwne". Trzeba pamiętać o nadpisaniu metod Equals
i GetHashCode
oraz implementacji interfejsu IEquatable<T>
. Z biznesowego punktu widzenia jest to zresztą całkowicie naturalne. To kiedy dwa Value Objecty są takie same, to element domeny, a nie technikalia.
Po drugie nie można pozbyć się domyślnego bezparametrowego konstruktora, przez co technicznie zawsze możliwe jest stworzenie struktury, której stan jest najprawdopodobniej bezsensowny biznesowo. Tego problemu nie da się wyeliminować. Można natomiast napisać automatyczny test na poziomie architektury, który zagwarantuje, że tego typu konstruktory nie są nigdzie użyte.
A jak wyglądają wady w przypadku użycia klas zamiast struktur? Tutaj problemem jest zdecydowanie możliwość przypisania im wartości null
. Wartość taka zazwyczaj nie ma żadnego sensu biznesowego, powoduje za to wiele błędów. Konieczne jest wprowadzanie defensywnego kodu we wszystkich miejscach, gdzie może zostać przekazany null
, co niestety zmniejsza jego czytelność. Problem ten zostanie wyeliminowany w C# 8. Struktury są jednak od niego wolne od zawsze.
Jak zwiększać ekspresywność?
Factory methods
Prostą metodą zwiększającą ekspresywność kodu jest użycie metod fabrykujących. Ich nazwy zawierają intencje / cel / okoliczności tworzenia danego obiektu. W przypadku użycia konstruktorów informacja ta ginie, za to pojawia się konieczność pamiętania, który konstruktor należy używać w jakiej sytuacji, albo jaką wartość trzeba przekazać, gdy któryś z argumentów jest w danym kontekście nieznany / nieistotny. Składnia metod fabrykujących jest też bardziej przejrzysta od składni konstruktorów.
Porównajmy:
var price = new Money(0, Currency.PLN);
var price = Money.Zero(Currency.PLN);
Albo:
var offer = new Offer();
var offer = new Offer(quotes)
var offer = new Offer(products)
var offer = Offer.Empty;
var offer = Offer.FromQuotes(quotes)
var offer = Offer.WithBasePrices(products)
Zachowania biznesowe
Value Objecty to nie tylko opakowania na dane nadające im znaczenie. Biznes opiera się na działaniu, a ono jest reprezentowane w Modelu przez zachowania obiektów. Po to w ogóle wyodrębniamy różne typy obiektów, żeby modelować powiązane ze sobą zestawy zachowań. Nawet w przypadku tak prostego Value Objectu jak Money
mamy kilka zachowań takich jak działania arytmetyczne i porównywanie wartości. Jeżeli kwoty mogą być wyrażone w różnych walutach, to zadanie nieco się już komplikuje i zysk z zamodelowania go wprost jest naprawdę istotny.
Porównajmy:
if (currency1 != currency2)
throw new DomainException();
var sum = price1 + price2;
// Jak zwrócić walutę dla sumy ?
var sum = price1.Add(price2);
Użycie Value Objectów znacznie redukuje ilość defensywnego kodu, gdyż każdy obiekt odpowiada za to, żeby jego stan był poprawny w każdym momencie, a zachowania przebiegały zgodnie z regułami. Nie trzeba się zajmować wszystkimi tymi kwestiami w każdym miejscu użycia danego obiektu.
Zachowania te mogą być o wiele bardziej skomplikowane. W przypadku oferty może to być porównanie dwóch ofert i zwrócenie różnicy.
var diff = offer.Diff(newOffer);
Jeżeli wszystkie zachowania są zaimplementowane przez obiekty mające znaczenie biznesowe, to eliminujemy jedno trudne i kluczowe pytanie przed jakim na co dzień stają deweloperzy. Czy to jest już gdzieś zaimplementowane? Jeżeli odpowiednie zachowanie jest już w obiekcie, który reprezentuje daną koncepcję biznesową to tak. Jeżeli go tam nie ma, to nie musimy już przeszukiwać wszystkich Serwisów, Managerów, Utilsów, etc. licząc, że może gdzieś to jednak jest. Value Objecty tworzą naturalne miejsca dla bardzo wielu "drobnych" zachowań biznesowych, które często w wielu "prawie takich samych" kopiach rozpleniają się po wszystkich zakątkach projektu.
Operators overloads
Składnię dla niektórych zachowań można jeszcze bardziej uprościć z wykorzystaniem operatorów. Porównajmy:
return price.LowerOrEqualTo(newPrice);
return price <= newPrice;
Lub poprzedni przykład z dodawaniem pieniędzy można uprościć do postaci:
var sum = price1 + price2;
A jak te trywialne operacje wyglądałyby bez użycia Value Objectów? Naprawdę warto napisać te kilkanaście - kilkadziesiąt linijek kodu więcej.
ToString()
Skoro każdy obiekt to odwzorowanie czegoś ze świata biznesu, to powinien się on móc sensownie przedstawić. Właśnie czymś takim jest metoda ToString()
. Domyślna implementacja zwracająca nazwę typu nie jest zwykle najlepszym możliwym rozwiązaniem.
W przypadku Value Objectów oprócz nazwy typu (która powinna mieć precyzyjne i jednoznaczne biznesowe znaczenie) warto zwrócić również co najmniej niektóre wartości. Czasami najsensowniej jest zwrócić wszystkie jak w przypadku Money
. Czasami jednak byłoby to niepraktyczne jak w przypadku Offer
, które przechowuje kolekcję wszystkich wycenionych produktów. W takim wypadku wystarczy zestaw danych, który jest odpowiednio mały i niesie jak najwięcej informacji o kontekście biznesowym.
Przykładowo:
"123.45 PLN"
"Offer for Client: 1234, calculated on: 2019-05-28 12:34:56"
Zamiast:
"MyCompany.Sales.Commons.Money"
"MyCompany.Sales.Offers.Offer"
Nie ma wtedy potrzeby konstruowania zrozumiałej informacji w momencie tworzenia logów, czy rzucania wyjątków. Ułatwia to również debugowanie, gdyż użyteczna informacja o obiekcie jest dostępna od razu bez wchodzenia w jego właściwości.
Podsumowanie
Do czego mogą się przydać Value Objecty?
- Do nadawania typom prostym znaczenia biznesowego
- Do modelowania zachowań biznesowych ściśle związanych z daną wartością
- Do redukcji defensywnego kodu
- Do modelowania danych, które żyją krótko i nie wymagają persystencji
Jakie konstrukcje z C# można wykorzystać do implementacji Value Objectów?
- readonly struct do uniemożliwienia zmiany wartości pól oraz poprawienia wydajności
- IEquatable
do poprawnego i wydajnego porównywania struct-ów - Collections.Immutable do zapewnienia niezmienności kolekcji
- static factory methods do tworzenia obiektów w bardziej przejrzysty sposób niż przez konstruktor
- operators overloads do uproszczenia składni zachowań takich jak działania arytmetyczne czy porównania
- ToString() do streszczania najważniejszych informacji o obiekcie w czytelnej formie
Przykłady implementacji Value Objectów możecie już teraz znaleźć na: https://github.com/itlibrium/DDD-starter-dotnet. W szczególności:
- Porównanie struct vs. class:
- operators overloads:
- Zachowania biznesowe
- Agregowanie innych Value Objectów:
Implementacja kolejnych Building Blocków z DDD już wkrótce.
Marcin Markowski
Architekt, trener, zwolennik podejścia Software Craftsmanship i ścisłej współpracy z biznesem. Specjalizuje się w modelowaniu opartym o Domain Driven Design i projektowaniu architektury systemów.
Zaczynał od consultingu biznesowego, później przeszedł do IT. Pracował zarówno nad systemami „enterprise”, jak i tworzył od podstaw rozwiązania dla małych firm. Próbował wejść w świat startupów z własnym produktem. Ostatecznie został jednak w IT, gdzie działa jako konsultant i trener.