środa, 1 czerwca 2011

Sposoby enkapsulowania złożonej logiki biznesowej

W ostatnim poście poruszyłem pewne podstawowe zagadnienia z zakresu projektowania obiektowego, ale jak słusznie zauważyli komentatorzy przykład jest na tyle trywialny, że narzucające się proste rozwiązania są w zupełności wystarczające.

Podejdziemy do problemu raz jeszcze, tym razem w bardziej realistycznym kontekście aplikacji biznesowej.

Mamy zatem ten nieszczęsny OrderItem - nieszczęsny, ponieważ przykład pochodzi z książki, którą ostatnio recenzowałem. Klasyczny model zamówienia: Order-OrderItem-Product


W książkowym przykładzie OrderItem jest obiektem domenowym (rich model) i posiada oprócz stanu również zachowanie - odpowiedzialności biznesowe. Autor książki nawiązuje do Domain Driven Design, dlatego będę się posługiwał Building Blockami modelowania DDD.

Nasz OrderItem zawiera metodę getShippingCost(), która nie jest getterem a metodą biznesową - oblicza koszt dostawy danej pozycji na zamówieniu.

Sugerowana w książce rozbudowa modelu to dodanie nowych klas dziedziczących po OrderItem, które zmieniają sposób liczenia kosztu. Problemy z tym podejściem opisałem w poprzednim poście, w skrócie: w takim obiekcie biznesowym mamy wiele odpowiedzialności, więc prowadzanie dziedziczenia aby zmienić jedną z nich szybko doprowadzi do eksplozji kombinatorycznej bytów.

<dygresja>
Zacznijmy od tego, czy w ogólne taka metoda powinna należeć do tej klasy? W rzeczywistym systemie obliczenie kosztów dostawy zależy zapewne od wielu czynników - nie tylko od zamawianego produktu, ale również od tego kim jest klient, od miejsca dostawy, od reszty zamówienia itd.
Być może cała wiedza znajduje się w klasie OrderItem... Być może wyżej - w Order... Być może nie... wówczas należy wynieść cały problem do osobnego SerwisuBiznesowego (BuildingBlock modelowania DDD).

Nawet jeżeli dziś cała wiedza znajduje się w OrderItem czy nawet w Order, ale w przyszłości zmienią się wymagania i będziemy musieli implementować dziwne akrobacje aby w OrderItem/Order zdobyć potrzebne do obliczeń obiekty.

Generalnie serwis rokuje na lepszy design modelu (serwis biznesowy jest częścią modelu w DDD). W DDD nie chodzi o to aby całą logikę rozsmarować po encjach/agregatach/vo. Nic podobnego. Prosta reguła "kciuka" dla początkujących może być taka: encje/agregaty/vo posiadają metody używane wielokrotnie. Specyficzne metody używane jeden raz znajdują się w specyficznych serwisach biznesowych

Jednak te rozważania nie mają wpływu na dalszą część posta. Abstrahując od tego, którym miejscu umieścimy odpowiedzialność obliczeń wciąż możemy stosować techniki opisane poniżej.

W dalszej części, dla uproszczenia, zakładamy, że większość potrzebnej wiedzy znajduje się w OrderItem.
</dygresja>


W realnej aplikacji obliczenie kosztu dostawy:
- zależy od wielu czynników (rodzaj produktu, klient i jego rabaty, miejsce docelowe, składowe zamówienia, historia zamówień itd)
- jest złożone (załóżmy kilkanaście-kilkaset linijek kodu)

Ta więc rozważania z poprzedniego posta (i komentarzy) gdzie proponowaliśmy switche albo Mapy strtegii się nie aplikują.

Przy standardowym podejściu kod będzie wyglądał mniej więcej tak:

class OrderItem{
  Money getShippingCost(){
    if if if if if
      //17 liniek obliczen
    if if if if
      //50 liniek obliczen z ifami
  }
}
... i wcale nie spodziewajmy się "eleganckiego" switcha, który załatwia sprawę. Mamy tutaj do czynienia z potworkiem rzędu setek linijek, brak ustalenia jednego poziomu abstrakcji, nieustanne skoki mentalne po różnych poziomach oraz potencjał na to, że biznes będzie się komplikował i z czasem wyhodujemy ośmiotysięcznika.

Generalnie przy standardowym podejściu kodzik będzie przeplatał w sobie 3 rodzaje odpowiedzialności:
1. zdecydowanie który scenariusz/algorytm obliczania kosztu zastosować
2. sam algorytm
3. ew. pozyskanie dostępu do danych potrzebnych do obliczeń

Czyli w jednym miejscu mamy 3 rodzaje zależności, o których pisze Misko Hevery.
- colaboration
- construction
- call


Aby uniknąć tego poziomu smutnej złożoności, proponowane w poprzednim poście rozwiązanie (czwarte) sugeruje aby:
1. odseparować samą logikę obliczenia kosztu od reszty kodu.
Wprowadzamy interfejs Strategii, np

interface ShippingCostCalculator{
  Money calculateCost(...);
}

2. kawałki kodu stanowiące każdy z algorytmów pakujemy do osobnej klasy implementującej ten interfejs, np ForeginShippingCostCalculator, LargeItemShippingCostCalculator

2.a co jeżeli wysyłamy duży przedmiot za granicę? tworzymy trzecią klasę ForeginLargeItemShippingCostCalculator? Nie! Z pomocą przychodzi Decorator Design Pattern.

ShippingCostCalculator calculator = new ForeginShippingCostCalculator(new LargrItemShippingCostCalculator());
}

3. kawałki logiki wybierającej, którą konkretną politykę stworzyć chowamy w Fabryce.
I nie jest jeden switch ale kłębowisko ifów

Możemy fabrykować cały agregat:
//fabrykowanie całego agregatu

  class OrderItemFactory{
  OrderItem createOrderItem(...){
    ShippingCostCalculator calculator = //100 ifów;
    return new OrderItem(calculator);
  }
}

albo jedynie strategię:

//fabrykowanie jedynie strategii

  class ShippingCostCalculatorFactory{
  ShippingCostCalculator createShippingCostCalculator(...)
    ShippingCostCalculator calculator = //100 ifów;
    return calculator;
  }
}

Wybór podejścia - co fabrykować: agregat czy strategię zależy do tego w którym momencie mamy wiedzą potrzebną do wyboru kalkulatora. Przed czy dopiero po powołaniu do życia OrderItema.

Generalnie lepiej jest posługiwać się Agregatem i ukrywać szczegóły typu istnienie strategii, ale nie zawsze jest to możliwe.

Tutaj jako agregat traktujemy OrderItem a nie Order, bo tak to wygląda w rzeczywistych systemach, np. klasy ERP - widziałem.

3a. co jeżeli aby wybrać konkretny kalkulator potrzeba znać np aktualnie zalogowanego użytkownika? Lub ogólnie: jakiś kontekst, którego brakuje w OrderItem?
Taka Fabryka może być komponentem zarządzanym (EJB, Speing Bean, itd) i możemy do niej wstrzykiwać potrzebne informacje.

class ShippingCostCalculatorFactory{
  @Inject
  LoggedUser loggedUser;  //logged user to obiekt apliakcyjny (niebiznesowy), trzymany w sesji

  ShippingCostCalculator createShippingCostCalculator(...)
    //if (loggedUser.role == ADMIN)
    ShippingCostCalculator calculator = //100 ifów ;
    return calculator;
  }
}

3b. co jeżeli któraś konkretna strategia obliczania kosztu potrzebuje informacji o zewnętrznym kontekście (np zalogowanym userze)? Chodzi o przypadek gdy metoda z interfejsu strategii nie ma w parametrze tego co potrzebuje implementacja. Fabryka może przekazać kontekst do konkretnej strategii przez jej konstruktor.

class ShippingCostCalculatorFactory{
  @Inject
  LoggedUser loggedUser;  //logged user to obiekt apliakcyjny (niebiznesowy), trzymany w sesji

  ShippingCostCalculator createShippingCostCalculator(...)  
    ShippingCostCalculator calculator = //100 ifów ;
    calculator = new XyzCalculator(loggedUser.getUserId());
  
    return calculator;
  }
}


Podsumowując:
Rozdzielamy silny coupling (współpracy, tworzenia, wywołania) do osobnych "pudełek".
Strategie: tylko obliczenia;
Fabryki: ecydowanie o typie strategii oraz ewentualnie składanie obiektów (przekazywanie szerszego kontekstu do strategii lub dekorowanie strategii)

Dzięki rozdzieleniu możemy niezależnie testować jednostkowo Agregaty i Strategie (Polityki w nomenklaturze DDD). A to za prawą fabryk, w których mieszkają operatory new. Agregaty są od nich wolne, więc mamy możliwość mockowania/stubowania strategii na czas testów.



//===============================================

Tak jak pisałem w poprzednim poście: wprowadzenie Strategii zaczyna się nam opłacać dopiero przy złożonych modelach biznesowych. Pokazałem tutaj dodatkowo złożenie Strategii i Dekoratora wraz z idiomem Fabryki oraz wystrzykiwaniem zależności. Dopiero w synergii technik tkwi prawdziwa siła.

Ważne jest to aby w ogóle modelować złożoność. Jeżeli nie modelujemy złożonego problemu a jedynie "zamiatamy go pod dywan" w postaci ukrytych gdzieś głęboko ifów to model szybko upada na twarz;)

Podstawowa zasada Domain Driven Desigm mówi: make explicit what is implict. Czyli wydobywajmy ważne koncepcje biznesowe na powierzchnię. Nie ukrywajmy ich w ifach w linijkacj od 5000 do 6000.

A czy jest coś ważniejszego w modelu biznesowym niż różne sposoby liczenia pieniędzy?;)

Na koniec jedna uwaga: zawsze gdy pokazuję na warsztatach tego typu przykłady część uczestników jest wręcz urzeczona "pudełkowaniem" złożonej logiki w małe, eleganckie pudełeczka. Inni z kolei preferują jedno pudło (np procedurę) ponieważ łatwiej im ogarnąć jedno pudło niż wiele małych, nawet kosztem tego, że w tym jednym pudle jest, powiedzmy delikatnie - bałaganik.

Wszyscy różnimy się mocno pod względem oceniania tego co jest proste. Więcej na ten temat: O programiście-pisarzu i programiście-konstruktorze.

8 komentarzy:

Jarek Żeliński pisze...

Kluczem w moich oczach jest to:

"Ważne jest to aby w ogóle modelować złożoność a nie postawiać ją setkom ifów."

i tak właśnie robię w złożonych projektach: dobry agregat i fabryka. dzielę "megaregułę" na pojedyncze spójne "wyliczenia" a korzeń agregatu zawsze wie jak i kiedy tego użyć.

mgruca pisze...

Podobny problem ostatnio rozwiązywałem w własnej aplikacji.

Wpierw chciałem wprowadzić łańcuch który by składał się z wielu obiektów liczących koszty, i podejmujących inne istotne decyzje, jednak, jak to czasem bywa, nie wyszło :P

Z racji braku czasu przerobiłem to na prostszą implementację składającą się z listy kalkulatorów.
Każdy kalkulator implementuje 2 ifacy z których każdy zawiera po metodzie (jednej).
Pierwsza metoda decyduje czy kalkulacja się powinna odbyć, druga po prostu liczy.
Obiekt zawierający listę przyjmuje ją jako argument konstruktora, wpada to do niego z XMLa springowego. Początkowo miał być fabryką, decydującą jaki łańcuch zbudować, ale to jedna z części co nie wyszła.

Potrzebna mi drobna przeróbka, bo nie wydaje mi się, żeby potrzebne były mi 2 publiczne metody w każdej klasie, jednak chwilowo nie mam czasu na większy refactoring, a będzie potrzebny bo i testów się część sypnie. Choć nie jestem pewien czy to tak poważny problem w sumie.

Sławek Sobótka pisze...

@Jarek:
Innymi słowy:
"Tackling Complexity in the Heart of Software"
Podtytuł książki Evansa streszcza całe DDD

@mgruca
Dzięki za przykład. Stosowałem takie Łańcuchy (tak jak u Ciebie zarządzane jakimś managerem a nie czysta implementacja Chain of Responsibility Design Pattern) i różnica pomiędzy Stregy jest właściwie na poziomie intencji:
- strategy: z góry narzucony konkretny algorytm. Narzucony w konfiguracji ew. (tak jak w moim przykładzie) typ wyliczony gdzieś w fabryczkach w runtime. Ale z poziomu klienta strategii jest to niejako narzucone - zwykle wcześniej.
- chain: wachlarz możliwych algorytmów, do wyboru tuż przed wykonaniem.

Przy podejściu takim jak pokazałem (z wyliczaniem w Typu runtime) różnica jest subtelna - na poziomie intencji... sprowadza się do tego samego.

Można jedynie by się zastanowić nad tym czy ogniwa łańcucha potrzebują 2 interfejsów - czy jeden interfejs z metodami typu:
boolan canHandle(...)
object/void handle(...)
nie byłby bardziej naturalny ze względu na to, że obie metody są zawsze wołane jedna pod drugiej i raczej nie będzie sytuacji gdzie jakaś klasa implementuje tylko pierwszy interfejs a inne tylko drugi.

Michał Maryniak pisze...

Witaj,
obliczanie kosztu dostawy to faktycznie fascynujący temat.
Dzięki za opis problemu - obawiam się jednak, że nie dla każdego taki mimo wszystko dość abstrakcyjny przykład może nie być dla wszystkich wystarczająco obrazowy.

W celu zarówno propagacji jak i utrwalania zastosowania dobrych wzorców w praktyce chciałem zaproponować wspólną zabawę w tworzenie algorytmu obliczania kosztu dostawy zamówienia.

Proponuję abyś utworzył jakieś wstępne założenia, natomiast czytelnicy mogliby wystąpić w roli marudzącego i "wymyślającego" klienta.

Po zaproponowanym rozwiązaniu będziemy wymyślać kolejne "wymagania" a Ty postarasz się implementować te reguły zgodnie ze wszystkimi regułami "rzemiosła".
Zobaczylibyśmy na pewno wiele strategii, dekoratorów i innych wspaniałych wynalazków działających w praktyce.

Jestem bardzo ciekaw, czy uda się w przypadku tak wymagającego i zmieniającego zdanie klienta rzeczywiście tworzyć kod w "piękny" sposób.

Proszę tylko nie traktuj tej propozycji jako "sprawdzianu" tylko bardziej jak "zabawę". Czy masz ochotę na tego typu wyzwanie?

pozdrawiam
Michał

Sławek Sobótka pisze...

@Michał:
Pomysł ciekawy, aby zachować realizm trzeba tylko na początku złożyć, że celem nigdy nie jest masturbowanie się wzorcami. Innymi słowy wzorce nie są punktem docelowym a co najwyżej silnym punktem rozwiązania. Dlatego na przykład pochodzę z dystansem do "refaktoryzacji do wzorców". Wzorce działają i wnoszą wartość w określonych kontekstach - są to po prostu narzędzia, które warto mieć w swojej "skrzynce z narzędziami".

Generalnie zawsze chodzi o zrobienie dobrego modelu. Wzorzec może co najwyżej wspomóc model. Pytanie co to znaczy dobry - tutaj działają różne "siły". Czasem chodzi o potencjał do rozbudowy, ogarnięcie złożoności a czasem po prostu o to aby szybko zrobić "na wczoraj" coś co działa i robi pieniądze:)

Co do samego problemu, nad którym potencjalnie pracowalibyśmy, to aby zachować realizm musi być on opisany przez kogoś, kto ma o nim pojęcie (ok, czasem realia są takie, że nikt nie wie o co chodzi;). Chodzi mi po prostu o to aby osoby generujące wymagania miały pojęcie o domenie.

Problem obliczania kosztu dostawy z przykładu wziął się po prostu z 2 wcześniejszych postów - ot jakiś przykładzik z recenzowanej książki. Osobiście nie znam się na tej domenie, mam tylko jakieś tam ogólne "życiowe" pojęcie o tym co to jest dostawa i co może mieć wpływ na jej koszt. Co innego koszt dostawy książki z księgarni internetowej a co innego przesłanie kontenera z jednego kontynentu na drugi, z kilkoma przeładunkami, zawierającego niebezpieczne materiały, z ominięciem ryzykownych terenów, uwzględniając programy lojalnościowe i zmiany kursów walut w niestabilnych krajach gdzie odbywają się przeładunki.

Do prostej księgarni można podchodzić niemal ad'hoc, do tego drugiego problemu potrzebna jest gruntowna analiza togo co jest i tego co można się spodziewać - kierunków rozwoju. Myślę, że Jarek Ż. miałby tutaj wiele ciekawego do powiedzenia.
W tym drugim przypadku warto otworzyć sobie skrzynkę z narzędziami, szufladkę z wzorcami i przejrzeć je, bo mogą się opłacać.

Tak więc reasumując, aby zabawa miała sens i oddawała jakieś realia dobrze byłoby wybrać problem, w którym ktoś z uczestników dobrze się orientuje. Jeżeli masz np. coś takiego nad czym np. aktualnie pracujesz i chciałbyś to "wziąć na warsztat" to opisz pokrótce.

Michał Maryniak pisze...

Witaj, tak się skład, że nie mam akurat żadnego rzeczywistego problemu "na warsztacie". Pomyślałem raczej o czymś, co byłoby mniej więcej zrozumiałe pod kątem merytorycznym dla "przeciętnego czytelnika bloga".

Temat kosztu dostawy pojawił się oczywiście przypadkowo - wydaje mi się jednak, że jest to przykład dość naturalny i zrozumiały, a przy tym możliwe jest jego "twórcze komplikowanie".

Powiedzmy, że sprawa dotyczy wysyłki w przypadku sklepu internetowego. Chyba każdy ma jakieś wyobrażenie o tym jak taki koszt dostawy należałoby liczyć. Zakładając jednak, że taki internetowy sklep jest sklepem rozwijającym się - na pewno co tydzień właściciel miałby jakiś kolejny doskonały pomysł na ulepszenie/rozszerzenie algorytmu liczenia kosztu dostawy.

Myślę, że czytelnicy bloga wykazaliby się tu dużą pomysłowością "symulując" w ten sposób ciągle zmieniające się wymagania.

Na wstępie można powiedzieć, że koszt należałoby liczyć na podstawie wagi przedmiotów. Oczywiście powyżej jakiejś sumarycznej wartości koszt byłby darmowy. Na start można założyć, że jest możliwa wysyłka pocztą polską, firmą kurierską A, firmą kurierską B, oraz za pomocą "paczkomatu".

Wyobrażam to sobie w ten sposób, że
poczta nie jest w stanie przewozić towarów powyżej jakiejś wartości, natomiast "paczkomaty" nie są dostępne we wszystkich miastach.

Kolejnymi "wynalazkami" mogły by być przykładowo: dzień darmowej wysyłki, opcja "prezentu" (specjalne opakowanie), ubezpieczenie przesyłki, przesyłki "specjalne" (np. żywe zwierzątko, broń, lekarstwa), płatność za pobraniem. Okazjonalne promocje w sezonie "przedwalentynkowym", "przed dniem dziecka" - na wysyłkę określonych grup produktów. Po jakimś czasie sklep internetowy zapragnąłby wysyłać towary za granicę. Być może zostaną wprowadzone karty VIP - dla stałych klientów koszt dostawy byłby ustalony na stałym poziomie (lecz oczywiście nie drożej niż dla innych). "VIPowcy" mogliby też mieć możliwość skorzystania z własnej firmy kurierskiej, z którą mają podpisaną umowę i w takim przypadku nie płaciliby naszemu sklepowy za wysyłkę. itp... itd...

Sądzę, że pomysłów w tym zakresie można mnożyć i problem będzie można sobie dowolnie komplikować.

Niestety dostawca oprogramowania bardzo często musi implementować nawet absurdalne pomysły klienta i ciągle dostosowywać się do jego wymagań. Sądzę, że taka zabawa miałaby w związku z tym jakiś związek z sytuacjami "życiowymi. Uważam też, że przyjrzenie się powstawaniu modelu "na żywo" byłoby dla nas cennym doświadczeniem.

Oczywiście zgadzam się z Tobą, że nie ma sensu męczyć wzorców dla samych wzorców, lecz najważniejszy jest model. Jasne, że wzorce są metodą, a nie celem samym w sobie.
Pytanie jest jednak otwarte, na ile stosowanie wzorców ułatwia tworzenie modelu i na ile odpowiednie podejście pozwala, aby model był rzeczywiście gotowy na zmiany. Załóżmy, że ten problem nie jest "na wczoraj" - tylko projektujemy coś, aby przez wiele lat systematycznie udoskonalać model.

Jak sądzisz ma taka koncepcja szansę realizacji?
pozdrawiam
Michał

Sławek Sobótka pisze...

Odpisuję dopiero teraz, ale byłem całkowicie pochłonięty konferencją i szkoleniem.

Pomysł, który podsunąłeś będzie nieco rozszerzony. Planujemy upublicznić realistyczny sample-project: nie tylko kilka wzorców, ale cały DDD+CqRS z opcjonalnym event sourcingiem, kilka rodzajów klientów (android, flex, html) na springu (może również ejb+seam) z wielomodułowym mavenem.

Będzie to nieco trwało, bo do całości powstaje wiki.

Michał Maryniak pisze...

Świetnie! Bardzo mi się podoba pomysł realistycznego sample-projektu. Mam nadzieję, że wywiążą się przy tej okazji twórcze dyskusje nad zasadnością/celowością konkretnych rozwiązań. Jasne, że taki ambitny projekt wymagać będzie czasu - czekam jednak z niecierpliwością i życzę powodzenia.