Refactor encji domenowej

3

W jednym z prywatnych projektów powstał mi taki mały god object: https://github.com/nobody00111/RefactorNeeded/blob/main/RefactorNeeded/Core/Offers/Entities/Offer.cs

W skrócie o co chodzi:

Jest sobie oferta. Ma jakieś tam statusy (można ją wysłać klientowi, odrzucić, anulować etc.). Oferta zawiera produkty. Ceny tych produktów pobierane są z cennika oferty, ale można też ustawić cenę ręcznie. Do każdej pozycji można dodać rabat (procentowy lub kwotowy).

Każdy produkt ma coś takiego jak koszty wytworzenia. Jeśli cena jakiegoś produktu na ofercie jest mniejsza niż koszty wytworzenia powiększone o jakiś procent, oferta wymaga akceptacji (nie można jej wysłać klientowi). Użytkownik może wysłać żądanie akceptacji ceny. Żądanie to może zostać odrzucone lub zaakceptowane. Jeśli zostanie zaakceptowane, to oferta może zostać wysłana klientowi.

Do każdej pozycji na ofercie można dodać coś takiego jak dopłata. Liczba sztuk dopłaty może zmieniać się wraz z liczbą sztuk produktu, do którego została dodana, ale może też zostać ustawiona ręcznie - wtedy zmiana liczby sztuk produktu nie ma wpływu na liczbę sztuk dopłaty. Ceny dopłat są doliczane do cen zwykłych produktów.

Do oferty można dodać sugerowane produkty (ich ceny nie są uwzględniane w cenie łącznej). Produkt sugerowany można następnie dodać do pozycji oferty, jeśli klient się zgodzi.

Chciałbym zrobić tu jakiś refactor. Widzę 2 opcje:

  1. Rozbić klasę Offer na kilka mniejszych. Wydaje mi się jednak, że ta logika, która jest obecnie, powinna w niej pozostać. W tym stanie oferta strzeże pewnych reguł biznesowych. Np. blokowanie wysłania oferty, jeśli ceny produktów są zbyt niskie, czy automatyczna zmiana ilości dopłat, jeśli zmieni się ilość produktu.
  2. Użycie jakiś wzorców, które pozwolą zachować logikę biznesową oferty, ale poprawią czytelność (500 linii to trochę dużo).

Co byście zrobili?

1

IMO pominąłeś istotny krok, czyli w ogóle zaprojektowanie domen/modułów. Odpowiedz sobie na pytanie, jakie domeny masz w tym projekcie i w każdej z nich możesz mieć inny „Offer”.

Wpp. idąc dalej będziesz tam miał obsługę koszyka, listingu, zamówienia, dostawy, zwrotu, polecenia, … widzisz co się dzieje :)

Powinieneś to rozbić, ale tak, żeby móc zrealizować operacje biznesowe, które tam masz. Poczytaj o projektowaniu agregatów i modelu do zapisu.

0

@Charles_Ray: Właśnie problem polega na tym, że ten cały agregat Offer należy do jednego kontekstu, powiedzmy Sales, i nie mam pomysłu, jak go rozbić. Dodawanie produktów, sugerowanie produktów, sprawdzanie cen przed wysłaniem oferty... to wszystko wydaje się należeć do jednego kontekstu. Jeśli klient zaakceptuje ofertę, to pewnie oferta przekształci się w Order w kontekście Ordering. Potem trafi do jakiegoś Warehouse, Invoicing itd. Ale na razie jesteśmy na etapie Sales.

1

Na pewno dodawanie produktów może w pełni funkcjonować bez rekomendacji ;) musisz to lepiej przemyśleć - rozbij na jak najmniejsze, a potem scalaj w razie potrzeby

0

Poprawcie mnie. Moje rozumienie jest takie: jeśli wymagamy, żeby jakaś reguła była ZAWSZE spełniona (1), to musimy jej obsługę umieścić w jednym agregacie, który zapisujemy w całości transakcyjnie Jeśli chwilowy brak spójności nie stanowi problemu (2), to wtedy daną logikę możemy rozbić na kilka agregatów i te agregaty mogą się komunikować asynchronicznie. W moim przypadku widzę to tak:

a) Dodanie produktu z za niską ceny wymaga zablokowania możliwości wysłania oferty. To imo przypadek (1), bo nie chcemy, żeby klient dostał ofertę ze zbyt niskimi cenami produktów.
b) Dodanie produktu rekomendowanego do normalnych pozycji na ofercie (operacja dwuetapowa: usunięcie produktu z rekomendowanych i dodanie go do normalnych produktów na ofercie). No to faktycznie wygląda na (2). Powiedzmy, że chcemy wygenerować dokument z ofertą i wysłać klientowi. Jeśli w momencie generowania pliku produkt został dodany do normalnych pozycji, ale jeszcze nie zdążył zostać usunięty z rekomendowanych, to wtedy pojawi się na ofercie 2 razy: w sekcji z normalnymi pozycjami i w sekcji z rekomendowanymi. No ale to mała szansa, że tak się stanie, a nawet jeśli, to nie będzie to duży problem.

7

To będzie trochę mechaniczny reverse-engineering Twojego kodu, ale może skorzystasz ;) Możesz spróbować zaaplikować ten sposób myślenia do pozostałych partii encji i zobaczymy, co wyjdzie. Disclaimer: nie znam Twojego projektu, nie wiem jaki biznes modelowałeś - to co napisałem może nie mieć w Twoim przypadku sensu.

Zaczynamy. Przykładowo, masz taką metodkę:
public Either<Success, DomainError> RequestDiscountApproval(string message)
co sugeruje, że masz jakiś feature polegający na zniżkach. Spróbujmy zamknąć to w osobnym bounded context. Stawiamy sobie za cel stan, w którym Offer nic nie wie o zniżkach.

W tym celu zbieramy wszystkie commandy związane z Discountingiem. Query na tym etapie mnie w ogóle nie interesują, ponieważ pesymistycznie można je ograć osobnym read modelem - skupiamy się na refactoringu modelu do zapisu. Przypominam, że model do zapisu to model skupiony na transakcyjnej ochronie niezmienników.

Commandy z domeny Discounting wraz z odpowiadającymi niezmiennikami:

  • RequestDiscountApproval(string message) -> DiscountStatus == DiscountStatus.ThresholdExceeded
  • ApproveDiscount() -> DiscountStatus == DiscountStatus.SentForApproval
  • RejectDiscount(string reason) -> DiscountStatus == DiscountStatus.SentForApproval)
  • SetDiscount(ProductId productId, Discount discount, CustomerMaxDiscount customerMaxDiscount) -> !customerMaxDiscount.IsExceeded(item.UnitPrice, item.CalculateUnitPriceAfterDiscount(discount))

Kolejny krok - jakich danych potrzebujesz, żeby sprawdzić te niezmienniki. Wychodzi na to, że DiscountStatus oraz jakieś info per customer. Pierwsze jest trywialne - dajesz pole w klasie, drugie również trywialne, bo przychodzi jako parametr metody ;D

Kolejna sprawa - te metody czytają, a nawet modyfikują jakieś pola z Offer. Będziesz musiał poinformować tę encję o nałożonej zniżce i tutaj masz do wyboru 3 opcje:

  1. Asynchronicznie (czytaj dalej jak to zrobić).
  2. Synchronicznie - wysyłasz do oferty command ApplyDiscount.
  3. Zapisujesz zniżkę w Discounting i nie informujesz o zniżce, ale wówczas wszyscy klienci będą musieli wiedzieć, aby pobierając ceny oferty dodatkowo sprawdzić zniżki - bez sensu.

A zatem lądujemy z nową encją:

public class OfferDiscount : Entity
{
       public OfferId Id { get; }
       public DiscountStatus DiscountStatus { get; private set; }

       RequestDiscountApproval(string message) { ... }
       ApproveDiscount() { ... }
       RejectDiscount(string reason) { ... }
       SetDiscount(ProductId productId, Discount discount, CustomerMaxDiscount customerMaxDiscount) { ... }
}

Odpowiedzialność tej encji ogranicza się do automatu stanu - zamyka logikę sterującą, kiedy można nałożyć zniżkę. Benefit jest taki, że w przyszłości mogą być różne rodzaje zniżek, różnie uwarunkowanych, może proces ich zatwierdzania będzie bardziej złożony - masz to zamknięte w tym miejscu - super sprawa, możesz oddać to do osobnego zespołu :)

Dla kompletu można teraz dodać pozostałe pola: DiscountApprovalRequestMessage i DiscountRejectionReason - zapewne tylko na potrzeby odczytu, ponieważ nie występowały w warunkach. Takie rzeczy dorzucamy na sam koniec - tak jak pisałem wcześniej, najwyżej je sobie doczytasz, kiedy będziesz chciał renderować jakiś widok.

Musimy jeszcze zadbać o komunikację w drugą stronę - zrobimy to asynchronicznie by default (jak mawiał T.Nurkiewicz). Przykładowo, masz taką metodkę wołaną z innych metod publicznych:

private RefreshDiscountStatus()

Ona spokojnie może być wołana eventem, ponieważ nie blokuje flow innych commandów. Zamiast wołać ją bezpośrednio np. z ChangeProductPrice() - rzuć event ProductPriceChanged i zasubskrybuj się na niego w Discountingu. Jeżeli to jednak byłoby częścią jakiegoś warunku/niezmiennika w innym bounded context - zawsze możesz odpytać ją synchronicznie (sync when necessary, jak mawiał T.Nurkiewicz). EDIT: po dokładniejszej analizie myślę, że wywołanie tej metody jest zbędne, natomiast mechanika pozostaje taka sama.

Doszliśmy do momentu, kiedy mamy już 2 BC: Core (jakoś trzeba to nazwać) oraz Discounting. Widzę, że kolejnym kandydatem rzucającym się w oko jest Notifications.

To co wychodzi mi na event stormingach to zwykle to, że system można podzielić na wiele BC tak długo, jak nie mamy do czynienia z operacjami typu compare-and-swap (CAS). Wtedy jest to bardziej tricky i trzeba modelować wokół tego. Na szczęście jest na to wzorzec i nazywa się chyba Inventory (przedstawia go S.Sobótka w swoich wystąpieniach o DDD).

Mam nadzieję, że to ćwiczenie było dla Ciebie pomocne. Spróbuj tak zrobić z pozostałymi commandami - pogrupuj, poszukaj domen, zrób nowe agregaty. Iteruj i zobacz dokąd dojdziesz ;) DDD jest fajne. Wesołych Świąt!

0

@Charles_Ray: Dzięki! Mam kilka pytań :)

  1. Obecnie przy OfferItem przechowuję cenę, która została zaakceptowana (metoda ApproveDiscount). Mam wymaganie, że jeśli cena zostanie zaakceptowana, a następnie przed wysłaniem oferty ktoś tę cenę zmniejszy jeszcze bardziej, to cena musi zostać zaakceptowana ponownie. W OfferDiscount powinienem więc mieć jakąs uproszczoną wersję _offerItems, w której bym mógł przechowywać zaakceptowane ceny?

  2. Mam regułę mówiąca, że nie można wysłać oferty, jeśli ceny są za niskie. Obecnie tej reguły strzeże Offer. Po modyfikacji encja Offer powinna zawierać DiscountStatus aktualizowany eventami z OfferDiscount? O ile rozumiem, ponieważ jest to niezmiennik, to potrzebuję synchronicznej komunikacji między Offer i OfferDiscount (transakcja?). Wydaje mi się, że Offer i OfferDiscount są bardzo mocno powiązane ze sobą (zmiana stanu jednej encji często powoduje zmianę stanu drugiej). Czy agregaty nie powinny być w miarę niezależne od siebie?

2

Ad. 1. Patrząc na to biznesowo, na ścieżce zakupowej jest moment, w którym klient podejmuje decyzję zakupową i akceptuje warunki - po tym czasie cena nie może się zmienić - albo powinien zaakceptować nowe warunki (słaby UX), albo sprzedajesz mu po cenie, którą akceptował. Poza tym, kto zmniejsza te ceny? Sprzedający? A może jakiś program subskrypcyjny, zniżki na zestawy itd?

Ad. 2. Możesz to zamodelować w taki sposób, że będziesz miał komponent mówiący, czy ofertę można wysłać i tam będziesz wpinać różne checki - pewnie będzie ich więcej, robi się z tego takie „one to many”. Pamiętaj, że podzielenie encji nie oznacza od razu, że wpadasz w asynchroniczność - możesz po prostu odpytać inną encję (z zastrzeżeniem, że nie jest to atomowe, bo ta druga encja może się zmienić po obliczeniu warunku).

Jeżeli wyjdzie Ci, że encje są jednak silnie ze sobą powiązane i musza wiedzieć o sobie nawzajem, to pewnie - scalaj :) Brandolini mówił, żeby wyjść od jak najmniejszych agregatów i scalać w miarę potrzeby.

0

@Charles_Ray:

  1. Ogólnie jest to dość wewnętrzny system - oferty tworzy i wysyła ktoś z wewnątrz (np. przestawiciel handlowy). Ceny są akceptowane przez osoby z odpowiednimi uprawnieniami - jeśli ktoś chce umieścić na ofercie jakiś produkt ze zbyt niską ceną (czy to na skutek przyznania zniżki, czy wpisania ceny ręcznie), potrzebuje akceptacji przełożonego, żeby móc ofertę wysłać klientowi. Po wysłaniu oferty klientowi nie można już zmienić cen. Jeśli klient zaakceptuje ofertę, oferta przekształca się w zamówienie.

  2. Pisząc o komponencie, masz na myśli coś takiego?

class OfferSending
{
       private readonly Offer _offer;
       private readonly OfferDiscount _discount;

       public OfferSending (Offer offer, OfferDiscount discount) { ... }

       public Either<Success, DomainError> Send()
       {
           // check
           _offer.Send();
       }
}
2
  1. No to masz generalnie przynajmniej 2 osobne byty - OfferDraft (można zmieniać ceny) i po akceptacji AcceptedOffer albo Order (nie można zmieniać cen). Każda z nich może mieć inny zbiór dopuszczalnych operacji. Coś podobnego zamodelowałeś polem Status. Najlepiej byłoby wyjść od ścieżek użytkownika i zobaczyć, jakie operacje są kiedy wykonywane. Bez takiego rozciągnięcia w czasie masz jedną „pulpę”. A przecież proces zakupowy to proces.

  2. Zwykle podczas zatwierdzenia zamówienia chcesz wykonać ostateczne checki, np. sprawdzić stany magazynowe. W zależności jak bardzo jest to złożone możesz chcieć to wynieść do osobnej klasy/modułu/mikroserwisu/systemu/… Warto zacząć od czegoś prostego, natomiast widać, że w tym miejscu będą splątane różne domeny - dostępność, ceny, zniżki, jakieś ograniczenia wiekowe itd.

Więcej o modelowaniu możesz poczytać np. tutaj (nawet ta sama domena) https://blog.redelastic.com/corporate-arts-crafts-modelling-reactive-systems-with-event-storming-73c6236f5dd7

1 użytkowników online, w tym zalogowanych: 0, gości: 1