stratified design - plusy / minusy

2

Co o tym podejściu sądzicie:

https://medium.com/clean-code-development/stratified-design-over-layered-design-125727c7e15

Trochę charakterystyczne dla osób, które preferują bottom-up

Idąc w kierunku stratified design poszczególnym komponentom wystarczy przekazać dane zamiast referencji na konkretny mutowalny obiekt w którego sercu często są ukryte mutacje i kontakt ze światem (nic dobrego).

Dla mnie tutaj ciekawe jest dostrzeżenie dwóch faktów, które wynikają, gdy pominie się wstrzykiwanie obiektów:

  1. Komponenty z logiką wtedy nie tylko nie muszą o sobie wzajemnie nic wiedzieć (co upraszcza pisanie szybkich testów, bo nie trzeba nic mockować), ale również nie ukrywają komunikacji wewnątrz siebie, komponenty wystawia metody do zarządzania sobą. Te metody interesują głównie wartości, a zatem częściej pojawia się opcja do skorzystania z niemodyfikowalnych danych.

  2. Komponenty nie są od siebie zależne, a więc są prostsze (pomyśl o tym jak o możliwym mutowaniu jakie ma miejsce gdy przekazujesz mutowalny obiekt do publicznej metody). Unikamy w ten sposób osadzenia interakcji wewnątrz komponentu, zamiast tego wypychamy tą interakcję do góry, na zewnątrz, a co za tym idzie w komponencie zostaje sama to logika.

Czyli:

  • mamy prostszy sposób na zapisanie testu (mniej rytuałów i więcej konkretów)
  • docelowy komponent zawiera w sobie tylko logikę i pisząc testy testujemy tylko logikę (o to chodzi), a nie interakcję wielu komponentów

Myśle, że to jest właśnie dobra odpowiedź na bolączki jakie poruszane są przy testach typu: nie piszą mniejszych testów (bo i po co skoro te testy przysypać mogą), albo nie piszą ich na mniejszą skalę (bo i po co skoro szerszy test również to samo pokryje). Moim zdaniem to jest życzeniowe myślenie. Szerszy test sprawdzi interakcje między komponentami, łatwo zrobi pokrycie wykonania kodu bliske 80-100%, ale co oznacza ten % Czy % wykonanego kodu przekłada się na taki sam %pokrycia logki? No nie jest tak. Możesz uruchomić raz metodę z jednym przypadkiem, czy to oznacza, że jest wszystko jest wtedy poprawne? No własnie nie bardzo, musisz sprawdzić przypadki brzegowe inaczej zgadujesz, że działa. Dziwi mnie za każdym razem jak ktoś często w dyskusjach zasłania się mocą statycznego typowania, to oczywiście rzutuje na poprawność, ale jest to poprawność w typach, w przekazywanu wartości, a typy to wciąż nie jest logika więc to rozumowanie zawsze będzie dla mnie niepojęte.

UWAGA: Po przez ten tekst nie nie zmierzam nikomu psuć dnia (chociaż byłoby miło wzbudzić w kimś ziarno wątpliwości). Po prostu dziwi mnie to, że tego sposobu nigdy w firmach nie widziałem, mimo, że słyszałem o oddolnym podejściu, ale nigdy nie czułem jaki może mieć ono mieć wpływ na pisanie lżejszych testów i prostszej logiki.

Byłoby super podyskutować o problemach, czy możecie uargumentować logicznie swój wybór dlaczego projekty w firmach nie idą w tą stronę?

Zwracam uwagę, że to podejscie nie ma związku z FP (chociaż hasla takie jak unikanie interaktywność / efekty uboczne / niemodyfikowalne wartości) sugerują jakby mieć powinno związek. FP jest pomocne, ale to podejście również można zastosować w obiektowych, czy nawet nieobiektowych językach takich jak C.

Odnośnie problemów jakie sam widzę to fakt, że tutaj jak widzimy, interakcja jest wypychana do góry, a zatem żaden pomniejszy komponent nie chcę przejąć za nią odpowiedzialności. Myślę, że to podejście ciężej jest spiąć z frameworkiem (który interakcje osadza głęboko w strukturze naszego kodu więc często nie mamy na to żadnego wpływu) i być to jest powód dla którego to podejscie się nie przyjeło.

3
  1. Trochę mi to wygląda na wynajdywanie koła na nowo - jeśli chodzi o luźno powiązane komponenty to takie coś nazywa się hexagonal architecture
  2. Pomysł żeby w ogóle nie wiązać ze sobą warstw sprawdzi się moze w hello world, ale to by było na tyle, bo jak masz trochę więcej logiki to nagle wyciąganie jej całej w jedno miejsce zwyczajnie się nie sprawdzi i nie będzie się tego dało czytać. Jak masz taki przykład jak w artykule że robisz x = getString(), printString(x) to problemu jeszcze nie widać :D Ja osobiście wolę mieć kod podzielony warstwami abstrakcji bo czytając go możesz się skupić na tym co jest w tej chwili istotne. Np. jak robie sobie x = loadRequest() to w ogóle nie obchodzi mnie że połowa danych przychodzi z bazy danych, a druga połowa z jakiegoś mikroserwisu i jak zaczniesz to tutaj wpychać to przestanie być jasne co ta metoda w ogóle miała robić.
  3. Bardzo często te same elementy są używane w różnych miejscach. Powiedzmy że mam jakiegoś AuthenticationProvider. Znając życie, będę go używać w wielu różnych miejscach, bo różne biznesowe operacje potrzebują sprawdzić uprawnienia. Wygodnie jest mieć taki self-contained obiekt i z niego korzystać w ogóle nie zastanawiając się czy on pod spodem czyta z bazy, z innego serwisu czy robi jeszcze coś innego.
2
pan_krewetek napisał(a):

Co o tym podejściu sądzicie:

https://medium.com/clean-code-development/stratified-design-over-layered-design-125727c7e15

Trochę charakterystyczne dla osób, które preferują bottom-up

Idąc w kierunku stratified design poszczególnym komponentom wystarczy przekazać dane zamiast referencji na konkretny mutowalny obiekt w którego sercu często są ukryte mutacje i kontakt ze światem (nic dobrego).

Za daleko idąca analogia. Przekazywanie danych nie wyklucza wykonywania mutacji przez dany komponent, jedynie zmienia miejsce, gdzie te mutacje mają punkt początkowy (z komponentu integracyjnego). Nie wyklucza także kontaktu ze światem. To czy A wywoła B2 poprzez interfejs B1, nie różni się od tego czy C weźmie dane od A i wywoła B2 w kontekście wykonanych mutacji i interakcji zewnętrznej. Problem, który postawiłeś nadal jest nierozwiązany. Mutowalność i efekty uboczne w postaci kontaktu ze światem nie mają tutaj nic do rzeczy.

Dla mnie tutaj ciekawe jest dostrzeżenie dwóch faktów, które wynikają, gdy pominie się wstrzykiwanie obiektów:

  1. Komponenty z logiką wtedy nie tylko nie muszą o sobie wzajemnie nic wiedzieć (co upraszcza pisanie szybkich testów, bo nie trzeba nic mockować), ale również nie ukrywają komunikacji wewnątrz siebie, komponenty wystawia metody do zarządzania sobą. Te metody interesują głównie wartości, a zatem częściej pojawia się opcja do skorzystania z niemodyfikowalnych danych.

W aplikacjach istnieją zależności pomiędzy konkretnymi stadiami przetwarzania danych. W podejściu warstwowym problem owy rozwiązuje się przez kontrakty, które to można zamodelować na przykład poprzez interfejsy. Wiedza jednego komponentu o drugim jest ograniczona do minimum, który potrzebuje do zrealizowania swojej pracy.
Podejście z w.w. artykułu nie rozwiązuje problemu zależności pomiędzy stadiami przetwarzania danych. Owe zależności nadal istnieją, tylko są spisane w innym miejscu - w komponencie integracyjnym.

  1. Komponenty nie są od siebie zależne, a więc są prostsze (pomyśl o tym jak o możliwym mutowaniu jakie ma miejsce gdy przekazujesz mutowalny obiekt do publicznej metody). Unikamy w ten sposób osadzenia interakcji wewnątrz komponentu, zamiast tego wypychamy tą interakcję do góry, na zewnątrz, a co za tym idzie w komponencie zostaje sama to logika.

Znikają jawne zależności pomiędzy komponentami, ale zostają zależności w przetwarzaniu danych. Programista w takim podejściu i tak musi zakodować owe zależności, tylko teraz robi to w dwóch miejscach - w konkretnym komponencie wystawiając odpowiednie metody i w tym integracyjnym, wywołując je. A ilość interakcji pomiędzy komponentami jest taka sama, tylko ukryta przed samym komponentem.
Dla aplikacji o dużej ilości komponentów, ten komponent integracyjny to będzie istne monstrum.

Czyli:

  • mamy prostszy sposób na zapisanie testu (mniej rytuałów i więcej konkretów)
  • docelowy komponent zawiera w sobie tylko logikę i pisząc testy testujemy tylko logikę (o to chodzi), a nie interakcję wielu komponentów

Testując tylko logikę, nie wiemy czy aplikacja jako całość działa poprawnie, a chyba nie o to chodzi.

Myśle, że to jest właśnie dobra odpowiedź na bolączki jakie poruszane są przy testach typu: nie piszą mniejszych testów (bo i po co skoro te testy przysypać mogą), albo nie piszą ich na mniejszą skalę (bo i po co skoro szerszy test również to samo pokryje). Moim zdaniem to jest życzeniowe myślenie. Szerszy test sprawdzi interakcje między komponentami, łatwo zrobi pokrycie wykonania kodu bliske 80-100%, ale co oznacza ten % Czy % wykonanego kodu przekłada się na taki sam %pokrycia logki? No nie jest tak. Możesz uruchomić raz metodę z jednym przypadkiem, czy to oznacza, że jest wszystko jest wtedy poprawne? No własnie nie bardzo, musisz sprawdzić przypadki brzegowe inaczej zgadujesz, że działa. Dziwi mnie za każdym razem jak ktoś często w dyskusjach zasłania się mocą statycznego typowania, to oczywiście rzutuje na poprawność, ale jest to poprawność w typach, w przekazywanu wartości, a typy to wciąż nie jest logika więc to rozumowanie zawsze będzie dla mnie niepojęte.

Jak ktoś nie chce pisać testów, to zawsze znajdzie wymówkę. owy programista leniuch może równie dobrze napisać testy dla komponentu integracyjnego i nic więcej.

1

tylko teraz robi to w dwóch miejscach

Bardzo optymistyczne założenie ;) Weźmy znów mój przykład z góry czyli jakiś AuthorizationProvider. Używamy bo np. w 100 miejscach w kodzie bo tyle mamy biznesowych operacji w których weryfikujemy uprawnienia użytkownika do wykonania danej akcji.
Załóżmy że chwilowo weryfikowaliśmy je za pomocą danych z jakiejś tabelki w bazie, więc w podejściu OPa mieliśmy coś w stylu:

data = db.getAuthData()
return authorizationProvider.canInvokeAction(user,action,data)

Ale złóżmy że okazało się że ta tabelka to za mało :( Teraz potrzeba jeszcze pociągnąć dane z LDAPa i nasze canInvokeAction to teraz canInvokeAction(user,action,dbData, ldapData).
Prosta sprawa, musimy to teraz poprawić tylko we wszystkich 100 miejscach które tą metodę wołały. Co więcej żadne z tych miejsc w ogóle nie jest zainteresowane jakąś bazą danych czy ldapem, to są biznesowe operacje na zupełnie innym poziomie abstrakcji, ale zmuszamy je do wnikania w szczegóły implementacyjne systemu.

Oczywiście ktoś mógłby zasugerować że czemu to nasze getAuthData nie zwróci od razu wszystkich potrzebnych danych, ale tu złamalibyśmy zasady, bo trzeba by zrobić komponent który wie zarówno o bazie danych jak i ldapie!

0

Tak naprawdę to jest powrót do starego, dobrego programowania proceduralno-strukturalnego, gdzie rejestrowało się parametry wejściowe i wyjściowe dla procedur. Takie podejście może mieć zastosowanie, np. w batchach, gdzie tworzenie warstw raczej średnio się sprawdza (przy podejściu warstwowym traci się flow z oczu).

0

@Shalom Ciężko jest bronić podejścia, które wypycha interakcję do góry. Podczas gdy Ty przedstawiasz problem, gdzie docelowo zależy Ci na zepchnięciu interakcji w dół.

Dla mnie ten przykład jest tak samo niewdzięczny jak managery (które robią wszystko i nic), w ich przypadku ciężko uchronić kod od niesłużących im modyfikacji, bo tu występuje zbyt kruchy interfejs, i jednocześnie szeroko nadużywany.

Gdybym mógł jakoś obronić główny temat to wypychałbym interakcję do góry. Wyższy poziom abstrakcji nie ma tu większego znaczenia. Tam też podejmuje się decyzje, a jedyną różnicą są dane, które po prostu mają większy związek z docelowym zastosowaniem aplikacji.

Wypychając interaktywność, uciekam od niej, oczywiście nie da się wiecznie uciekać. Inaczej program stanie się biblioteką zamiast konkretną aplikacją.

Dlatego na samej górze musi być interakcja. To taki najprostszy przykład, który pokazuje, że aplikacja nie stanie się instnym monstrum :-)

Jak to wygląda:

  1. piszemy pętlę, która w dużym uproszczeniu robi to co ten kod niżej:
  • decidery to kod realizujacy logikę
  • workery to jak najmniejsze parte kodu odpowiedzialne za efekty uboczne, wywołania bibliotek
  • to czego używa pod spodem worker jest mniej istotne (może to być globalna / może to być coś wstrzyknięte / lub coś oddziedziczone) niezależnie co to jest - ta cześć nie przechodzi na logikę
def run():
   result = None
   cmd = None
   while True:
     cmd = run_decider(cmd, result)
     result = run_worker(cmd)

Jak można zauważyć to nie jest tak, że logika nie ma wplywu na efekty i odwrotnie, obie rzeczy mają miejsce i na siebie wpływają.

Sorki też za to, że przykład opiera się na dynamicznie typowanym języku, ale do końca nie jestem pewien jak mogłoby taki przykład wyglądać w statycznie typowanym języku.

  1. Innym zbliżonym podejściem może być kod gry wujka boba, on tutaj wylicza nowy stan na podstawie starego. Funkcja update-state odpowiada głównie za obliczenie stanu, a draw-state rysuje ten stan. Także defsketch w pewnym stopniu wziął na siebe kwestie dotyczące interaktywności: https://github.com/unclebob/spacewar/blob/master/src/spacewar/core.cljc#L252
3
pan_krewetek napisał(a):

dlaczego projekty w firmach nie idą w tą stronę?

Bo kult kargo na to nie pozwala. Ma być kontroler, serwis, repozytorium, każde ze swoim interfejsem, jeśli serwis ma jakieś klasy pomocnicze, to też muszą mieć swoje interfejsy. Do tego koniecznie trzeba wszystkie zależności przekazywać wszędzie, i wszystkie klasy oraz ich interfejsy zarejestrować w kontenerze IoC. Najlepiej przy użyciu jakiegoś pliku XML, bo tak jest bardziej profesjonalnie i można podmieniać implementacje w locie.

O ile to dobrze rozumiem, to ja tak programuję właściwie "od zawsze", tylko nie wiedziałem, że to jest jakiś wzorzec. Dla mnie to po prostu programowanie z poprawnym wydzieleniem abstrakcji - jedna klasa realizująca dany proces przy użyciu dwóch rodzajów klas: klas-interakcji np. pobierających dane z różnych źródeł, oraz klas-algorytmów, które przetwarzają dane. Tych pierwszych nie ma sensu testować, te drugie testuje się w TDD z łatwością.
Tyle, że ja nie mieszam w to prezentacji, bo pomijając już fakt, że raczej na backendzie siedzę, to raczej nie mam konsolowych frontendów, a wszelkie GUI czy to webowe czy desktopowe nie dałoby tu żadnej korzyści. (Przynajmniej w technologiach, w których pracuję.)

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