Ostatnio poruszaliśmy temat value object’ów, który wielu z Was zaciekawił. Dzisiaj chciałbym Wam opowiedzieć o kolejnej strukturze z taktycznego Domain Driven Design, a mianowicie entity. Jak pewnie pamiętacie value object’y charakteryzowały się tym, że nie miały tożsamości tzn. były porównywane przez wartość. W przypadku encji jest odwrotnie i to tożsamość odgrywa główną rolę.
Przykład bez użycia encji
Aby zobrazować Wam dlaczego encje są przydatne najpierw stwórzmy przykład bez ich użycia i zidentyfikujmy problemy jakie w nim występują. Wyobraźmy sobię sytuację w której musimy zaimplementować logikę odpowiedzialną za produkt w sklepie e-commerce. Początkowe wymagania są następujące:
- Produkt powinien zawierać nazwę oraz cenę
- Produkt powinien zawierać historię zmian
- Produkt powinien być widoczny dopiero po opublikowaniu
- Produkt nie może mieć zmienionej ceny oraz nazwy po opublikowaniu
Kod mógłby wyglądać następująco:
enum Status { New = 'New', Published = 'Published', } enum HistoricalFacts { NameChanged = 'NameChanged', PriceChanged = 'PriceChanged', StatusChanged = 'StatusChanged', } class Product { public id: string; public name: string; public price: number; public status: Status; public history: HistoricalFacts[]; constructor(id: string, name: string, price: number, status: Status) { this.id = id; this.name = name; this.price = price; this.status = status; this.history = []; } } class ProductService { public async createProduct(name: string, price: number): Promise<void> { const id = generateId(); const product = new Product(id, name, price, Status.New); await saveToDB(product); } public async publish(id: string): Promise<void> { const product = await findOne(id); if (!product) { throw new Error('Product not found'); } if (product.status !== Status.New) { throw new Error('Product is published'); } product.status = Status.Published; product.history.push(HistoricalFacts.StatusChanged); saveToDB(product); } public async unpublish(id: string): Promise<void> { const product = await findOne(id); if (!product) { throw new Error('Product not found'); } if (product.status !== Status.Published) { throw new Error('Product is not published'); } product.status = Status.New; product.history.push(HistoricalFacts.StatusChanged); saveToDB(product); } public async changePrice(id: string, newPrice: number): Promise<void> { const product = await findOne(id); if (!product) { throw new Error('Product not found'); } if (product.status !== Status.New) { throw new Error('Product is published'); } product.price = newPrice; product.history.push(HistoricalFacts.PriceChanged); saveToDB(product); } public async changeName(id: string, newName: string): Promise<void> { const product = await findOne(id); if (!product) { throw new Error('Product not found'); } if (product.status !== Status.New) { throw new Error('Product is published'); } product.name = newName; product.history.push(HistoricalFacts.NameChanged); saveToDB(product); } }
Nasza aplikacja się rozwija, do pracy przychodzą nowi developerzy. Po jakimś czasie przychodzi do nas biznes i mówi, żebyśmy dodali możliwość zrabatowania ceny o konkretny procent oraz kwotę. Jest to dosyć proste zadanie, więc oddajemy je nowemu developerowi. Jako, że nasz serwis jest już stosunkowo duży to nowy developer zgodnie z zasadami dobrego programowania postanowił stworzyć nowy service DiscountService
. Oczywiście nie zagłębiał się w szczegóły ProductService
gdyż nie widział takiej potrzeby.
class DiscountService { public async makePercentageDiscount(id: string, discount: number): Promise<void> { const product = await findOne(id); if (!product) { throw new Error('Product not found'); } if (discount > 99) { throw new Error('Discount cannot be grather than 99 percent'); } product.price = product.price - (product.price * discount / 100); saveToDB(product); } public async makeDiscount(id: string, discount: number): Promise<void> { const product = await findOne(id); if (!product) { throw new Error('Product not found'); } product.price = product.price - discount; saveToDB(product); } }
Wymagania zostały spełnione i na pierwszy rzut oka wszystko wygląda dobrze, ale po bliższym przyjrzeniu okazuje się, że nasz nowy developer zapomniał o kilku ważnych regułach:
- Zapomniał o dodaniu do historii zdarzenia mówiącego o tym, że cena została zmieniona
- Nie sprawdził czy produkt nie jest przypadkiem opublikowany (możemy zmieniać ceny tylko nieopublikowanych produktów)
Ktoś może powiedzieć, że wystarczyło sprawdzić ProductService
i wszystko byłoby jasne, ale należy pamiętać, że to tylko przykład i prawdziwy kod mógłby być bardziej skomplikowany oraz mógłby zawierać większą ilość reguł i metod. Z naszej struktury danych jaką jest Product
mogło by również korzystać kilka serwisów.
Anemiczny model danych
Product
który widzimy powyżej jest tzw. anemicznym modelem danych. To znaczy, że nie posiada on żadnych zachowań oraz reguł biznesowych, a jedynie publiczne pola, które dowolnie możemy modyfikować. Jest to problem gdyż posługując się samym obiektem Product
nie jesteśmy w stanie stwierdzić co on robi, możemy jedynie zobaczyć jak wygląda. Wszystkie reguły biznesowe znajdują się w naszym serwisie i żeby dowiedzieć się coś wiecej na temat naszego obiektu (przykładowo które pola zmieniają się razem lub jakie reguły nimi sterują) musimy przeanalizować każdą metodę oraz każdy serwis, który z niego korzysta. Dopiero wtedy będziemy mieli pełny obraz.
Anemiczny model danych powstaje, ponieważ o klasach często myślimy jak o pojemnikach na dane nie biorąc w ogóle pod uwagę ich zachowań. To przyzwyczajenie często wynosimy ze szkoły, gdzie uczymy się, że klasa odzwierciedla obiekt z prawdziwego życia i jest zbiorem pól. Bazując na takim założeniu programiści nie rzadko zaczynają tworzenie aplikacji od zaprojektowania struktur w bazie danych, co nie do końca jest najlepszym rozwiązaniem. Wiecej na ten temat mówi Sławek Sobótka.
Jakie problemy to powoduje?
Po pierwsze nie jesteśmy w stanie sprawdzić reguł oraz zachowań klasy Product
, ponieważ jest to tylko struktura danych. Nasze reguły oraz niezmienniki są rozproszone po wszystkich miejscach, które jej używają. Implementując lub modyfikując jakąś funkcjonalność możemy je pominać, tak jak nasz nowy developer. Nawet jeśli zdecydowalibyśmy się na sprawdzenie wszystkich serwisów i metod to sam proces sprawdzania i upewniania się, że zmieniamy odpowiednie pola mógłby zająć wieki.
Analogią może być tutaj człowiek. W normalnej sytuacji chcąc coś zjeść wkładamy jedzenie do ust, nasz orgamizm sprawdza smak, następnie nasącza je śliną i transportuje do żołądka i jelit w celu strawienia. Czyli nasz organizm jest „klasą”, która posiada jakieś zachowania oraz chroni reguły biznesowe. Natomiast jeśli potraktowali byśmy nasz organizm jak anemiczny model to najpierw rozcięlibyśmy brzuch, wyjęli żołądek, następnie go rozciecli, włożyli do niego jedzenie i spowrotem zszyli. Chyba nikt z Nas nie czerpałby wtedy przyjemności z jedzenia.
Po drugie obecna implementacja lekko nakłania nas do duplikowania logiki biznesowej lub co gorsza wyciągania jej do utilsów. No bo przecież jeśli w kilku miejscach zmieniając cenę najpierw musimy sprawdzić czy produkt ma odpowiedni status to warto by to było wyciągnąć do osobnej metody. Taka metoda, może na poczatku powstać tylko w serwisie ProductService
no ale przecież, z czasem dojdą nam kolejne wymagania i kolejne serwisy gdzie również będziemy jej potrzebowali. Żeby nie robić zależności pomiędzy serwisami to prawdopodobnie wyciągniemy ją jeszcze wyżej i trafi ona do folderu shared
gdzie powinien się znajdować tylko kod narzędziowy. Po takim zabiegu nasza logika jeszcze bardziej się rozmyje.
Po trzecie nasza logika jest bardzo ciężka do zrozumienia. Przykład w artykule jest bardzo prosty ale w prawdziwych systemach nie jest już tak kolorowo. Szczególnie jeśli zmieniamy kilka obiektów jednocześnie, za pomocą wielu zagnieżdżonych metod. Czytając logikę domenową wymieszaną z logiką aplikacyjną lub co gorsza infrastrukturalną bardzo łatwo można stracić kontekst i się pogubić. Zamiast skupić się na naszej domenie, skupiamy się na wszystkim do okoła czyli na przykład zapytaniach do bazy danych, metodach generujących id, strzałach do zewnętrznych serwisów i tym podobnych. Takie podejście powoduje mase błędów, szczególnie jeśli nie mamy odpowiednich testów.
Co więcej jako iż nasza logika wyglada w ten sposób, że po prostu ustawiamy wartości pól na klasie Product
to osobie czytającej kodzik bardzo ciężko jest go rozszyfrować. Jest zmuszona do odkodowywania tego co autor miał na myśli. Szczególnie jeśli logika biznesowa jest bardziej skomplikowana i zawiera jakieś obliczenia. Często musi zagłębiać się w zbyt szczegółowe detale implementacyjne w celu zrozumienia całego kontekstu nad którym pracujemy.
Po czwarte logika domenowa jest wymieszana z logiką aplikacyjną przez co bardzo ciężko jest ją przetestować. Nie możemy napisać prostych i szybkich unit testów, ponieważ musimy bawić się w mockowanie bazy danych, zewnętrznych zapytań, innych modułów itp. Sam setup takich testów jest ogromny i zajmuje mase czasu. Wpływa to również znacząco na ich czytelność.
Część osób może słusznie zdecydować się na testy integracyjne lub e2e, ale tutaj pojawia się kolejny problem. Są one o wiele wolniejsze (przez co deweloperzy rzadziej je odpalają) i wymagają większego nakładu pracy niż testy jednostkowe. Potrzebujemy również do nich specjalnej infrastruktury, która również kosztuje. Biznes może stwierdzić, że nie opłaca się nam pisać testów, ponieważ zajmują więcej czasu niż sama funkcjonalność i jako że mamy ich dużo, to każde odpalenie kosztuje kupe pieniędzy. W efekcie czego nasze oprogramowanie traci na jakości.
Będziemy musieli również napisać więcej testów, ponieważ nasza logika wycieka do różnych serwisów i każdy z nich będzie musiał mieć osobne testy sprawdzające te same reguły.
W przypadku testów jednostkowych jesteśmy zmuszeni do mockowania niektórych rzeczy, przez co wyciągamy naszą implementację do kodu testów co skutkuje jej zabetonowaniem. Przykładowo zmieniając wewnętrzną logikę serwisu bez zmiany jego zachowania, jesteśmy zmuszeni równiez do zmian w testach, ponieważ naszymi mockami sięgamy do detali implementacyjnych.
Całość skutkuje tym, że deweloperzy zniechęcają się do pisania testów, ponieważ uznają je za zbyt skomplikowane i czasochłonne.
Po piąte serwisy bezpośrednio używają pól naszej struktury danych, co znacznie utrudnia ich ewelntualną zmianę. Przykładowo jeśli zdecydujemy się, że status od teraz nie będzie enumem tylko będzie miał wartość numeryczną lub będzie określany na podstawie kilku pól, to będziemy musieli zmienić implementację w każdym miejscu, które używa klasy Product
.
Na szczęści z pomocą przychodzi nam…
Encja
Encja jest to klasa, która podobnie jak value object nie jest jedynie strukturą danych, ale zawiera w sobie zachowania oraz chroni reguły biznesowe. Udostępnia również odpowiednie metody, ktore zmieniają jej stan oraz enkapsulują logikę dbając przy tym o nasze niezmienniki.
class Product { private id: string; private name: string; private price: number; private status: Status; private history: HistoricalFacts[]; constructor(id: string, name: string, price: number, status: Status) { this.id = id; this.name = name; this.price = price; this.status = status; this.history = []; } public doSomething(): void { ... } }
Unikalny identyfikator (id)
Tym co najbardziej odróżnia encję od value object’u jest unikalny identyfikator. Jak pewnie pamiętacie value object’y porównywaliśmy za pomocą ich wartości. Przykładowo dwie monety 5zł były równoważne z jednym banknotem 10zł. W przypadku encji jest inaczej. Analogią może być tutaj człowiek. Pomimo, że część z nas ma te same imiona i nazwiska to jednak jesteśmy różnymi osobami, które można zidentyfikować za pomocą numeru pesel lub numeru dowodu osobistego. Więc jeden Michał Kuchno nie jest równy drugiemu Michałowi Kuchno i są to dwie różne osoby.
class User { private id: string; private firstName: string; private lastName: string; constructor(id: string, firstName: string, lastName: string) { this.id = id; this.firstName = firstName; this.lastName = lastName; } public isEqual(user: User): boolean { return this.id === user.id; } } const firstUser = new User('1', 'Michał', 'Kuchno'); const secondUser = new User('2', 'Michał', 'Kuchno'); firstUser.isEqual(secondUser) // false
Skąd zatem wziąć identyfikator? Odpowiedź jest bardzo prosta:
Identyfikator biznesowy
Tworząc niektóre systemy możemy użyć identyfikatorów, które już istnieją i jednoznacznie odróżniają poszczególne byty z punktu widzenia biznesowego. Czasami jest to również wymóg prawny przy tworzeniu aplikacji. Przykładem może być:
- numer ISBN książki
- numer pesel
- numer dowodu osobistego
- numer seryjny banknotu
- numer VIN pojazdu
Używając takich identyfikatorów możemy łatwo powiązać encje z realnymi obiektami. Nie musimy się również martwić o ich unikalność, ponieważ dba o nią ktoś inny. Należy być jednak bardzo uważnym i z rozwagą używać tego typu identyfikatorów, ponieważ niektóre z nich nie koniecznie muszą być unikalne pomimo, że na takie wyglądają.
Identyfikator z bazy danych
Bardzo często w naszych systemach używamy bazy danych, która może za nas wygenerować identyfikator i zadbać o jego unikalność. Przykładem może być tutaj uuid
lub auto inkrementowany identyfikator numeryczny. Jest to wygodne rozwiązanie, ponieważ nie musimy się martwić o samodzielne wygenerowanie, zapewnienie unikalności i przypisanie identyfikatora, natomiast powoduje to jeden bardzo dotkliwy problem szczególnie w przypadku id numerycznego.
Tworząc nową encję, nie jesteśmy w stanie nadać jej identyfikatora, przed zapisaniem jej do bazy danych czyli od razu po jej stworzeniu. Oczywiście możemy to obejść tworząc pusty rekord lub posługując się jakąś metodą, która wyciągnie i za lockuje nam kolejne id, ale wprowadza to niepotrzebną komplikację, mase problemów, a co gorsza jest bardzo nieintuicyjne. Warto się zatem dobrze zastanowić zanim zdecydujemy się użyć tej metody.
W przypadku id numerycznego będziemy mieli również problem przy rozbicu jednej bazy danych na kilka mniejszych na przykład ze względów wydajnościowych lub przy imporcie danych z jednego systemu do drugiego, ponieważ id numeryczne prawdopodobnie się skonfiktują.
Identyfikator wygenerowany z poziomu kodu
Powyższy problem rozwiązuje nam wygenerowanie identyfikatora z poziomu kodu. Jest to również bardzo wygodna opcja natomiast musimy pamiętać o jednej ważnej rzeczy, a mianowicie o tym żeby zapewnić unikalność. Nie polecam używać własnych algorytmów do generowania losowego id, ponieważ może się okazać, że będziemy mieli duplikaty. Radziłbym skorzystać z gotowych rozwiązań takich jak uuid, nanoid lub ObjectId w przypadku MongoDB. Są one dobrze przetestowane i minimalizują ryzyko wystąpienia duplikatów praktycznie do zera. Nawet jeśli takowy wystąpi to odpowiednie ustawienie bazy danych powinno rozwiązać sprawę (unique key).
Cykl życia encji
Encja posiada również cykl życia, czyli mówiąc w prost zmienia się w czasie. Oznacza to, że encja jest mutowalna i możemy modyfikować jej pola bez konieczności tworzenia nowej instancji klasy, tak jak to było w przypadku value object’u. Zmiana wartości encji, nie zmienia jej tożsamości. Żeby Wam to zobrazować, ponownie posłużmy się analogią do człowieka. Pomimo, że się starzejemy, zmieniamy fryzurę, chudniemy lub tyjemy to nadal jesteśmy tą samą osobą.
Implementacja z wykorzystaniem encji
Wiemy już czym jest encja i co ją charakteryzuje. Teraz aby lepiej zrozumieć temat pokażę Ci jak zaimplementować encję do naszego przykładu z początku artykułu.
Po pierwsze jako iż encja ma enkapsulować w sobie logikę oraz chronić niezmienniki zmieńmy wszystkie pola publiczne na prywatne, aby uniemożliwić ich modyfikację z zewnątrz.
enum Status { New = 'New', Published = 'Published', } enum HistoricalFacts { NameChanged = 'NameChanged', PriceChanged = 'PriceChanged', StatusChanged = 'StatusChanged', } class Product { private id: string; private name: string; private price: number; private status: Status; private history: HistoricalFacts[]; constructor(id: string, name: string, price: number, status: Status) { this.id = id; this.name = name; this.price = price; this.status = status; this.history = []; } }
Następnie dodajmy do naszej klasy metody, za pomocą, których będziemy modyfikować nasze pola.
interface ProductDTO { id: string; name: string; price: number; status: Status; history: HistoricalFacts[]; } class Product { private id: string; private name: string; private price: number; private status: Status; private history: HistoricalFacts[]; constructor(id: string, name: string, price: number, status: Status) { this.id = id; this.name = name; this.price = price; this.status = status; this.history = []; } public publish(): void { if (this.status !== Status.New) { throw new Error('Product is published'); } this.status = Status.Published; this.history.push(HistoricalFacts.StatusChanged); } public unpublish(): void { if (this.status !== Status.Published) { throw new Error('Product is not published'); } this.status = Status.New; this.history.push(HistoricalFacts.StatusChanged); } public changePrice(newPrice: number): void { if (this.status !== Status.New) { throw new Error('Product is published'); } this.price = newPrice; this.history.push(HistoricalFacts.PriceChanged); } public changeName(newName: string): void { if (this.status !== Status.New) { throw new Error('Product is published'); } this.name = newName; this.history.push(HistoricalFacts.NameChanged); } public makeDiscount(discount: number): void { this.changePrice(this.price - discount); } public toDTO(): ProductDTO { return { id: this.id, name: this.name, price: this.price, status: this.status, history: [...this.history], } } }
Zwróćcie uwagę na to, że nie są to metody typu update
lub set
lecz posiadają one nazwy, które mają znaczenie z punktu widzenia biznesowego. Znacząco ułatwi to komunikację między ludźmi technicznymi, a nietechnicznymi oraz zrozumienie samego kodu. Nasz Product
nie posiada również żadnych zewnętrznych zależności, a więc napisanie do niego testów będzie banalnie proste. Całą logikę mamy również zamkniętą w klasie, której ona dotyczy, przez co od razu widzimy co robi produkt i jakie reguły posiada.
Do naszego produktu dodałem również metodę toDTO
, która umożliwia nam wyciągnięcie pól, które chcemy udostępnić na zewnątrz, jednak są to pola przeznaczone tylko i wyłącznie do odczytu.
Nasze serwisy będą teraz wyglądać następująco:
class ProductService { public async createProduct(name: string, price: number): Promise<void> { const id = generateId(); const product = new Product(id, name, price, Status.New); await saveToDB(product); } public async publish(id: string): Promise<void> { const product = await findOne(id); if (!product) { throw new Error('Product not found'); } product.publish(); saveToDB(product); } public async unpublish(id: string): Promise<void> { const product = await findOne(id); if (!product) { throw new Error('Product not found'); } product.unpublish(); saveToDB(product); } public async changePrice(id: string, newPrice: number): Promise<void> { const product = await findOne(id); if (!product) { throw new Error('Product not found'); } product.changePrice(newPrice); saveToDB(product); } public async changeName(id: string, newName: string): Promise<void> { const product = await findOne(id); if (!product) { throw new Error('Product not found'); } product.changeName(newName); saveToDB(product); } } class DiscountService { public async makePercentageDiscount(id: string, discount: number): Promise<void> { const product = await findOne(id); if (!product) { throw new Error('Product not found'); } // Tę logikę również można by z enkapsulować if (discount > 99) { throw new Error('Discount cannot be grather than 99 percent'); } const productDTO = product.toDTO(); // Tę logikę również można by z enkapsulować product.makeDiscount(productDTO.price * discount / 100); saveToDB(product); } public async makeDiscount(id: string, discount: number): Promise<void> { const product = await findOne(id); if (!product) { throw new Error('Product not found'); } product.makeDiscount(discount); saveToDB(product); } }
Jak widzimy są one znacznie prostsze, czytelniejsze, nie posiadają logiki biznesowej i odpowiadają tylko za wykonanie operacji na produkcie.
Jak sobie radzić z asynchronicznością?
Temat asynchroniczności poruszyłem już w jedym z moich poprzednich artykułów na temat value object’ów. Obsługa asynchroniczności w przypadku encji jest analogiczna.
Dlaczego warto używać encji?
Chroni niezmienniki i reguły biznesowe
Mówiąc wprost encja chroni nasze pola i uniemożliwia ich modyfikację z zewnątrz. Jeśli chcemy coś zmienić to zawsze musimy posłużyć się metodami, które udostępnia, a co za tym idzie spełnić wszystkie reguły biznesowe, które się w nich znajdują. Dzięki temu minimalizujemy ryzyko, że ktoś wprowadzi nasz byt w niepoprawny stan tak jak nasi nowi developerzy.
Enkapsuluje logikę biznesową
Jak już wczesniej wspomniałem nasze metody znajdujące się na encji enkapsulują logikę biznesową. Dzięki temu nie jest ona rozwalona po całym systemie, a znajduje się w jednym konkretnym miejscu do którego należy. Znacząco poprawia to czytelność oraz utrzymywalność kodu, a co za tym idzie przyspiesza onboarding nowych developerów, ponieważ wprowadzając jakieś zmiany od razu wiemy gdzie zajrzeć. Kod biznesowy nie jest zaśmiecony kodem infrastrukturalnym i odwrotnie przez co ilość wiedzy w danym kontekscie jest zdecydowanie mniejsza. Minimalizuje to również ryzyko duplikacji logiki biznesowej lub wyciągnięcia jej do utilsów. Dodatkowo dochodzą nam tutaj wszystkie zalety wynikające z enkapsulacji.
Pomaga przełożyć słownictwo biznesowe na kod i ułatwia komunikację
Nazwa encji oraz jej zachowania (metody) odzwierciedlają słownictwo biznesowe. Nie posiadamy tam setterów, ani metod typu update
. Dzięki temu jesteśmy w stanie rozmawiać z ludźmi biznesu tym samym językiem zrozumiałym dla obu stron. Ludzie biznesu zdecydowanie lepiej nas zrozumieją jeśli powiemy „Opublikowałem produkt”, niż „w ProductService
wywołałem metodę publish
i ustawiłem pole status
na Published
„. Akurat ten przykład jest banalny, ale uwierzcie, że w prawdziwych systemach nie jest juz tak kolorowo, szczególnie jeśli zmieniamy kilkanaście pól jednocześnie.
W drugą stronę będzie to działać podobnie, jeśli biznes powie „potrzebuję zmienić reguły odpowiadające za publikację produktu” to developerzy nie będą się zastanawiać jaki serwis zmienić, albo gdzie szukać logiki, ponieważ słownictwo biznesowe jest przełożone do kodu praktycznie 1 do 1.
Ułatwia testowanie
Nasza encja posiada w sobie jedynie logikę biznesową co umożliwia przetestowanie jej za pomocą testów jednostkowych. W takich testach nie będziemy musieli niczego mockować, stubować ani fake’ować, ponieważ nie mamy zewnętrznych zależności. Znacząco wpłynie to na czas, który musimy poświęcić na ich napisanie. Testy będą też o wiele bardziej zrozumiałe zarówno dla ludzi technicznych, jak i ludzi biznesu zważywszy na to, że nasz kod odzwierciedla słownictwo biznesowe.
Czy zatem zawsze warto tworzyć encje?
Podobnie jak w przypadku value object’ów, w większości przypadków tak. Jednak jeśli tworzymy aplikację typu CRUD to nie przyniosą nam one zbyt wielu korzyści. Zawsze należy być pragmatycznym i dobierać rozwiązania w zależności od kontekstu w którym się znajdujemy.
Z encjami wiąże się również kilka innych ciekawych tematów i problemów takich jak: zapisywanie encji do bazy danych, kompozycja encji i value object’ów, obsługa wielu stanów, bardzo duże encje, współdzielenie encji itp. Ale o tym jak radzić sobie z takimi problemami opowiem Wam w kolejnym artykule.
Żródła:
https://www.amazon.com/Domain-Driven-Design-Tackling-Complexity-Software/dp/0321125215
https://www.amazon.com/Implementing-Domain-Driven-Design-Vaughn-Vernon/dp/0321834577
https://droganowoczesnegoarchitekta.pl/
https://www.amazon.pl/Hands-Domain-Driven-Design-NET-Core/dp/1788834097