Identyfikowanie obiektów domenowych
Własna tożsamość to główny wyróżnik Encji w DDD. Jest ona wyrażana przez Id obiektu, którym w praktyce najczęściej okazuje się … int z bazy danych. Nie jest to najlepsze rozwiązanie, skoro w DDD chodzi o modelowanie biznesu. Postaram się przedstawić alternatywne podejście i jego implementację, która pozwala w możliwie bezbolesny sposób wprowadzić biznesowe identyfikatory do naszych encji.
Cała koncepcja i zaproponowane rozwiązanie ma zastosowania, jeżeli system tworzony jest z wykorzystaniem DDD i architektury hexagonalnej (lub jej odpowiednika).
Problem
Id encji powinno mieć znaczenie biznesowe
Id obiektów domenowych powinno być czymś po czym biznes rozróżnia obiekty i co jest dla niego znakiem ich tożsamości. A jaka jest zazwyczaj praktyka?
U nas nie ma czegoś takiego, używamy int-ów generowanych przez bazę danych.
Czy na pewno nie ma? Co to znaczy, że rozróżniamy obiekty jedynie po numerku z jakiegoś mechanizmu do persystencji? Albo nie jest to prawdziwy obiekt domenowy, tylko anemiczna encja z CRUDowej części systemu. Albo nikt się nie zastanawiał nad tym, co jest identyfikatorem dla biznesu, bo int-y z bazy i tak były pod ręką.
Id biznesowe i techniczne to dwie różne rzeczy
To co ja mam w bazie używać varchar, albo jeszcze czegoś gorszego jako klucza głównego?
Nie! Baza danych rządzi się swoimi prawami i jeżeli potrzebuje swoich technicznych kluczy, to powinna je mieć. Chodzi tylko o to, żeby technikalia nie wyciekały do domeny. Encja z DDD to pojęcie domenowe i powinno być jak najmniej zależne od kwestii technicznych. Szczególnie dotyczy to zależności, które wymagają dodawania dodatkowych pól, wystawiania publicznie czegoś co nie powinno być widoczne z zewnątrz, lub wprowadzania czysto technicznego dziedziczenia.
A jak ja mam rozróżnić czy obiekt jest nowy, czy był już zapisany? Mam do tego użyć ORM? Przecież to jeszcze gorsze …
A czy persystencja jest pojęciem domenowym? Czy logika biznesowa powinna od tego zależeć? To czy obiekt jest nowy czy nie, jaką ma wersję, etc. jest bardzo istotne, ale nie w logice domenowej. Żaden z ekspertów domenowych nie ma pojęcia co to jest int, transakcja, czy optimistic locking. Jeżeli biznes zaczyna mówić takim językiem, to znaczy, że jest już naprawdę źle.
No dobrze, ale w repozytorium to już muszę wiedzieć, czy to jest nowy obiekt czy nie, jaką ma wersję, itd. I co teraz?
Owszem, na tym etapie wiedza ta jest potrzebna i tu z pomocą przychodzi nam dziedziczenie. W domenie nie powinniśmy wiedzieć o technikaliach, ale nic nie stoi na przeszkodzie, żeby obiekt reprezentujący domenowe Id był czymś więcej, o czym domena w ogóle nie wie. Wie o tym za to repozytorium, które odtwarzało zapisany wcześniej obiekt i dostaje go z powrotem do kolejnego zapisu.
Rozwiązanie
Narzut techniczny powinien być minimalny
Wprowadzenie Id biznesowego i dziedziczącego po nim Id technicznego jest pewnym narzutem. Jak każdy mechanizm wymaga on dodatkowego kodu, którego ilość staramy się przecież redukować. Do tego deweloperzy mają alergię na trywialny kod, powtarzający się co chwila w niemal identycznej postaci. Rozwiązanie musi więc minimalizować ten narzut, tak żeby koszt jego wprowadzenia był akceptowalny.
Każdy element powinien mieć pojedynczą, naturalną odpowiedzialność
Moja propozycja będzie opierać się na następujących elementach:
Entity
– klasa bazowa odpowiedzialna za identyfikację po Id biznesowym
DomainId
– identyfikator biznesowy dla każdego Entity
przechowujący Id biznesowe
TechnicalId
– identyfikator techniczny przechowujący Id techniczne i informację o tym, czy obiekt jest nowy oraz ewentualnie jego wersję.
Co istotne DomainId
nie może być po prostu Id biznesowym. Gdyby tak było dziedziczenie po nim TechnicalId dla konkretnego Entity nie byłoby możliwe. DomainId
przechowuje więc wartość Id biznesowego oraz poprawnie implementuje metody odpowiedzialne za porównywanie obiektów.
Entity przechowuje DomainId i deleguje do niego logikę odpowiedzialną za porównywanie obiektów.
TechnicalId
konkretnej klasy dziedziczy po jej DomainId
, dzięki czemu może być z nim używane zamiennie.
Ilość typów, które trzeba dodać do projektu powinna być minimalna
Implementacja DomainId
dla każdej klasy byłaby uciążliwa należy jej więc uniknąć tak, aby wystarczyło dziedziczenie po Entity
. Żadna dodatkowa linijka kodu nie powinna być potrzebna. Implementacji osobnych TechnicalId
dla każdego Entity
niestety będzie już trudno uniknąć. Ten narzut da się jednak rozsądnie zminimalizować i powinien być on akceptowalny. Można go wyeliminować całkowicie przy zastosowaniu odpowiednich mechanizmów po stronie persystencji, ale o tym napiszę w jednym z kolejnych postów.
Implementacja
Przykładowa implementacja będzie wykonana w C#. Sposób, w jaki zrobione są typy generyczne w tym języku, pozwala osiągnąć zamierzony cel w elegancki i przejrzysty sposób. W innych językach można oczywiście opracować analogiczne implementacje.
Deklaracja typu konkretnego Entity
mogłaby wyglądać tak:
public class Order : Entity<Order, OrderNumber>
DomainId
jest klasą zagnieżdżoną w Entity
. Dzięki generycznemu odwołaniu do samego siebie DomainId
każdego Entity
jest innym typem. Jednocześnie ograniczenie założone na tym argumencie jest na tyle mocne, że błędne użycie jest praktycznie niemożliwe. Sama klasa bazowa jest bardzo prosta i odpowiada jedynie za identyfikację po DomainId
:
A oto przykłady zastosowania:
Order.DomainId domainId = new Order.DomainId(new OrderNumber("ABC/123"));
Order order = Order.Create(domainId);
Order.DomainId domainId = order.Id;
DomainId
można rzutować implicite na Id biznesowe, dzięki czemu nie są potrzebne żadne dodatkowe operatory:
OrderNumber orderNumber = domainId;
Deklaracja TechnicalId
mogłaby wyglądać tak:
public class OrderTechnicalId : Order.DomainId, ITechnicalId<int>
Interfejs ITechnicalId<TValue>
zapewnia dwie informacje istotne przy persystencji: TechnicalValue
oraz IsNew
. Implementacja konkretnych typów jest maksymalnie uproszczona dzięki strukturze TechnicalId<TValue>
i delegowaniu do niej implementacji interfejsu ITechnicalId<TValue>
.
TechnicalId
można przekazywać jako DomainId
podczas tworzenia Entity
:
Order order = new Order(new OrderTechnicalId(new OrderNumber("ABC/123"), 123456));
Ustalenie czy obiekt został utworzony w domenie, czy odtworzony w mechanizmie do persystencji, odbywa się przez sprawdzenie czy jego Id implementuje ITechnicalId
. Jeżeli nowy, niezapisany dotychczas obiekt został powołany poza domeną, to można to sprawdzić za pomocą właściwości IsNew
.
Zapraszam na nasz GitHub, gdzie można znaleźć kompletny kod tego rozwiązania i nie tylko. Do pobrania są również gotowe paczki NuGet.
Zakończenie
Id biznesowe zwiększa deskryptywność modelu i eliminuje błędy
Dodatkową zaletą takiego podejścia jest mniejsza podatność na błędy. Posiadanie osobnych Id dla każdego typu w domenie chroni przed głupimi pomyłkami, gdy ktoś przekazał np. Id klienta zamiast Id produktu. Gdy wszystko jest int-em to o taki błąd nie trudno.
Dzięki użyciu Id biznesowych deskryptywność modelu rośnie, gdyż wszystkie typy same mówią kim są. W domenie bardzo często używamy Id zamiast całego obiektu. Agregaty nie przechowują referencji do innych agregatów tylko ich identyfikatory. Tej komunikacji jest więc całkiem sporo i warto zadbać, aby jej poprawności pilnował kompilator, a kod był jeszcze bardziej czytelny.
Kiedy stosować takie rozwiązanie?
Czy zawsze trzeba używać takich armat? Oczywiście nie! Jest to przydatne tylko wtedy, gdy prowadzi do lepszego modelu. Jeżeli jest to sztuczny narzut, to nie należy tego robić. Trzeba tylko zawsze zadać sobie pytanie, czy faktycznie narzut jest sztuczny, a problem trywialny? Czy nawet, jeżeli obecnie tak jest, to czy będzie tak za miesiąc, czy rok? Jak długo ma żyć system? Ile osób będzie jeszcze przy nim pracować? Dodatkowa praca nad modelem domenowym bardzo szybko się zwraca i zwykle warto ją wykonać.
Co dalej?
Czy to już pełne odseparowanie modelu domenowego od persystencji? Niestety nie. Została jeszcze reszta danych encji, które trzeba jakoś zapisać. O technikach osiągnięcia tego celu i kosztach z tym związanych napiszę następnym razem.
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.