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.

Categories: DDD

Leave a Reply

Your email address will not be published. Required fields are marked *