Architektura heksagonalna to oszustwo

0

Cześć, będzie trochę długo.

Oglądam sobie ostatnio sporo nagrań z różnych konferencji i temat, który się często przewija to architektura heksagonalna.

Jako, że wszyscy zachwalają, że to takie och i ach, i w ogóle najlepsze co może być, to chciałem sobie zobaczyć jakiś przykład jak to wygląda. I szczerze mówiąc, to trochę jestem zawiedziony, bo żaden z projektów, które widziałem mnie nie przekonał. Powiem więcej, jak dla mnie to mogłyby konkurować z FizzBuzzEnterpriseEdition o nagrodę za najbardziej rozdmuchany kod. Potem zrobiłem najgłupszą rzecz jaką można zrobić, czyli zacząłem używać mózgu i chyba coś sobie uświadomiłem.

Załóżmy, że klepiemy sobie wesoło klasę i wychodzi nam coś takiego:

class ABClass {
	doA(){/*kilometry kodu */}
	doB(){/*kilometry kodu */}
	doAB(){/*kilometry kodu */}
}

Potem zauważamy, że ta nasza klasa to chyba robi trochę za dużo i generalnie jest taka jakaś za długa. Dzielimy ją więc na części.

class A {
	doA(){/* kilometry kodu */}
}

class B {
	doB(){/* kilometry kodu */}
}

class ABClass {
	ABClass(A a, B b) {
		this.a = a; this.b = b;
	}
	doA(){ a.doA(); }
	doB(){ b.doB(); }
	doAB(){ a.doA(); b.doB2(); }
}

Nie zrobiłem tu nic nadzwyczajnego wydzieliłem osobne funkcjonalności do osobnych klas. Potem przekazuję je sobie w konstruktorze. Nie wiem, ja tak robię i jest to dla mnie naturalne. Zwykła zasada pojedynczej odpowiedzialności. Ale czy na pewno? Czy to nie jest właśnie ta słynna "architektura portów i adapterów"?.

Ja patrzę na to tak: mam ABClass, która ma 2 porty, konkretnie klasy A i B, do które mogę sobie wymieniać, jak mi się żywnie podoba. Jeżeli przykładowo A przyjmuje w konstruktorze jednego inta, to mogę sobie tam podać 1, 3, czy 5 milionów. Złośliwi powiedzą, "ale teraz to tylko tworzysz różne instancje tej samej klasy. Nie możesz wymienić samej klasy A na taką z inną implementacją", a ja bym powiedział "pacz na to:"

interface IA {
	doA();
}

class A implements IA{
	doA();
}

class A2 implements IA {
	doA();
}

Jeżeli z jakiegoś powodu okazałoby się po napisaniu klasy A,że potrzebuję 2 wersje klasy, to robię interfejs i 2 klasy które go implementują. Nie wcześniej, nie później. Po co mi interfejs, gdy mam tylko jedną klasę, która robi X? Dlaczego nie miałbym mieć interfejsu, jeśli mam 2 klasy, które mają robić podobne rzeczy?

Tak, takie podejście (iteracyjne? z braku lepszego określenia), będzie się wiązało z tym, że trzeba będzie robić refactor, choć mnie jakoś ta perspektywa nie przeraża, bo nie sądzę, żeby przy sensownie napisanym kodzie, było to jakieś strasznie długie. Są też narzędzia, które to ułatwią (obstawiam, że pewnie nawet zwykły find&replace da radę). Alternatywą jest oczywiście pisanie tony śmieciowego kodu "w razie jak będę chciał wymienić kiedyś tę klasę na inną", co jakoś tak w moim odczuciu zdarza się bardzo rzadko (albo wcale).

Żeby była jasność: nie jestem przeciwnikiem tworzenia interfejsów na samym początku, ale wtedy kiedy jest to rzeczywiście uzasadnione. Jeżeli wiem, że będę miał (co najmniej) 2 różne implementacje, które będę chciał używać zamiennie w zależności od jakichś czynników (np. sensowne wydają się 2 implementacje repository, jedna na hashmapie do testów, druga, która rzeczywiście się łączy do bazy danych) to ok. Podobnie mógłbym od razu podzielić moją ABClass, jeśli widziałbym na początku, że będzie robić za dużo (w sumie pewnie większość ludzi by tak zrobiła, ale chciałem pokazać mój tok rozumowania).

Natomiast zupełnie nie przekonuje mnie podejście: masz taki wzorzec, mądrzy ludzie mówią, że masz pisać tonę boilerplate'u i będziesz mógł sobie super łatwo wymienić każdy interfejs na dowolną implementację! Interfejs, która ma tylko jedną implementację...

W zasadzie to jeszcze sobie pomyślałem, że może to jest jakaś odpowiedź na irracjonalne zachowania biznesu. Nie wiem klient przychodzi co 2 dni, wywraca początkowe ustalenia do góry nogami, więc piszemy bardzo defensywny kod, bo wiemy, że na 90% każdą klasę trzeba będzie zmienić. Ale to już tylko moje dywagacje, bo moje doświadczenie zawodowe jest raczej mizerne.

Podsumowując jak dla mnie cała ta architektura, to tak na prawdę nic więcej poza zasadę pojedynczej odpowiedzialności opakowaną w tonę niepotrzebnego kodu i ładny marketing żeby dobrze się sprzedawało. Jednym słowem pic na wodę, fotomontaż.

Uff, to chyba koniec moich wywodów. Generalnie to zastanawiam się, czy to co tutaj opisałem ma sens, czy może jednak pierniczę głupoty? Fajnie jakby ktoś mógłby mi pokazać luki w moim rozumowaniu.

2

Tak

10

Podsumowując jak dla mnie cała ta architektura, to tak na prawdę nic więcej poza zasadę pojedynczej odpowiedzialności opakowaną w tonę niepotrzebnego kodu i ładny marketing żeby dobrze się sprzedawało. Jednym słowem pic na wodę, fotomontaż.

A nie Dependency Inversion Principle?

Ja heksagonalną/czystą architekturę rozumiem tak, że mam wewnętrzną warstwę biznesową i ona powinna wiedzieć jak najmniej o zewnętrznym świecie. Dlatego jak muszę odpytać jakiś zewnętrzny serwis (REST, SOAP, a może bezpośrednio bazę systemu legacy) to wtedy tworzę port i warstwy biznesowej nie obchodzi co jest pod spodem.
Ale to, że jakiś serwis biznesowy ma coś wstrzyknięte, to jeszcze nie znaczy, że wszystkie zależności mają być portami schowanymi za interfejsem. Jak w handlerze mam walidator to nie tworzę portu walidatora, bo to nie ma sensu. Jak mam pomocniczy serwis określający czy dany użytkownik może wykonać akcję to nie tworzę do niego portu, bo to znowu nie ma sensu.

Przynajmniej tak ja to widzę ;)

5

Też rozumiem to jak some_ONE wspomniał - porty tworzysz do zewnętrznych(pozaaplikacyjnych) usług (REST service, baza danych, operacje na filesystemie itp.), a nie do jakiś drobnych klas, które wydzieliłeś, żeby zachować SRP. A do tych zewnętrznych serwisów prawie zawsze i tak chcesz mieć wiele implementacji: do testów, do środowisk nie-produkcyjnych itp.
Tworzenie tony single-implementation interfejsów tylko dla zasady jest głupie i szkodliwe.

7

Chyba nie zrozumiałeś, na czym polega ta architektura. Nie musisz robić interfejsów do każdej jednej klasy. Wewnątrz każdej z warstw możesz sobie dzielić klasy do woli :)

Interfejsy jako porty stosujesz w 2 celach:

  1. Aby uniezależnić się od warstwy niższej (dependency inversion), dzięki czemu kod jest bardziej testowalny. Naturalnie tutaj masz 2 implementacje - produkcyjna i testowa (np. in-memory lub mock).
  2. Nie mieszać infrastruktury z kodem domenowym - wynosisz takie rzeczy do adapterów.
4

Przykład podany przez @ObywatelRP jest podejrzanie prosty, jakieś dwie klasy in memory. Dajmy coś z reala, mamy klasę która wpałowuje do DB dane z Kafki, czyli taka prosta architektura:

Kafka -> Komponent Wpałowujący Dane -> MySQL

I teraz zobaczmy jak to przetestować. Nie ma H, albo robisz test integracyjny pełny z Kafką i MySQL albo musisz jakoś to zrefaktorować. Takie grube testy integracyjne, jak pokazała praktyka, nie są najlepsz bo: są flaky (czyli zawodne, niedeterministyczne), są trudne w utrzymaniu (dużo zależności). Dopóki masz ich 20 to jest ok, jak zaczynasz mieć takich testów 200 to prawie za każdym przebiegiem CI jakiś test pada, bo a to port zajęty, a to kafka nie wstaje bo plik configuracji używany przez inny proces, a to MySQL nie wstaje bo katalog używany przez inną instację MySQL. Puszczanie tych testów równolegle to też masakra, najlepiej to robić w Dockerach tak żeby się nie pogryzły.

Teraz ktoś pomyślał, będzie super jak Kafkę i MySQL ukryjemy za interface'ami. Zrobimy sobie fake'i lub jakieś implementacje InMemory i będziemy mogli to UnitTestować. Po kilku iteracjach wychodzi coś takiego:

Kafka -> Consumer -> Publikuje Event wewnętrzny w aplikacji DataReceivedEvent
Handler na DataRecievedEvent który korzysta z IRepository i wpałowuje dane gdzie trzeba
MySQLRepository które implementeuje IRepository

Potem ktoś popatrzył na to i widzi, hmm, ten kod z handlera to można wsadzić do osobnego modułu z interface'ami.

Potem ktoś inny zauważa, hmm teraz jak są te interface'y to można by ten Handler wołać np. z API Restowego bez żadnego eventu. Taka elastyczność jest często w korpo aplikacjach potrzebna, gdzie jest misz-masz integracji między różnymi systemami (często między nowym a starym crapem ;) ).

Mniej więcej tak ludzie doszli do Hexagonu. Esencja aplikacje zostaje w środku, a elementy zewnętrzne stają się interfacemi oraz ich implementacjami.

Także Obywatelu daj przykład z DB i REST API jakby to wyglądało w twoim podejściu. Jestem ciekawe.
PS. Wielki plus za kwestionowanie autorytetów, zbyt wiele osób bierze opinie Uncle Boba na wiarę.

7

Koledzy wyjaśnili na czym polega taka architektura i że nie do końca ją rozumiesz.

Od siebie dodam że hexagonal to taki buzzword na stare, dobre onion architecture. Wszystko sprowadza się do tego żeby moduły w wewnętrznych warstwach operowały na abstrakcjach, których implementacje dostarczają moduły z warstw zewnętrznych.

3

Dodam jeszcze że taką architekture nie wszędzie się stosuje. Jak przysłowiowego CRUDa gdzie aplikacja to (bieda) RESTowe api do bazy danych do hexagonal jest bez sensu.

0
  1. Hexagonal jest dobre do języków dynamicznie typowanych, bo sprawia, że modele nie są rozpierniczone po całym projekcie. Modele są blisko rdzenia, który łatwiej jest otestować. To sprawia, że refaktoryzacja mniej boli, bo to co najbardziej się zmienia ma zawężony zakres.

  2. Minusem hexagonal jest to, że widzisz dużo mniejszych rzeczy, ale tracisz z obrazu procedurę. Gdy nie widać procedury ciężko jest ocenić jaki jest przepływ sterowania.

  3. Hexagonal to inaczej porty i adaptery, jakby nie patrzeć dane są przekazywane do pośrednich struktur. Te pośrednie struktury mogą być mniej lub bardziej ogólne, tutaj jest pole do zabawy gdzie w obrębie systemu tworzymy wiadomości i kod związany z ich interpretracją (DSL).

Dla mnie to podejście jest słabe tam gdzie:

  1. wyliczamy zróżnicowane typy, wyliczamy specyficzne działania w ich obrębie
  2. obsługujemy dane drobnoziarniście
  3. gdzie przeplatamy efekty (np. odczyt z zapisem)

Natomiast warto rozważyć tą architekturę gdy:

  1. mamy wiele bardzo podobnych modeli lub gdy mamy modele, ale do końca ich nie znamy (bo np. określa je użytkownik systemu)
  2. logika w obrębie modeli jest bardzo zbliżona
  3. przetwarzamy dane grupowo i nie przeplatamy efektów
  4. gdy tworzony system przybiera postać interpretera zdarzeń
2

@fgh:

W cebuli każda warstwa obejmuje temat aplikacji. W hexagonal jest na odwrót.

Nie za bardzo rozumiem co masz na myśli. Możesz to rozwinąć?

Użyjmy prostego przykładu: mam warstwę domenową z logiką biznesową która musi zmodyfikować koszyk zakupów. Aby to zrobić, trzeba go załadować a następnie zapisać z/do bazy. Ponieważ domena to warstwa wewnętrzna (core) to operuje na abstrakcji, w tym przypadku niech to będzie interfejs IBasketRepository. Implementacja tego interfejsu znajduje się w warstwie zewnętrznej (infrastruktura) i odpowiada za komunikację z bazą danych. To klasyczny onion. Czym to się będzie różnić w hexagonal? Co tutaj będzie na odwrót?

0

@0xmarcin Dzięki za wyjaśnienie, chyba właśnie czegoś takiego mi brakowało. Czyli, jeśli dobrze zrozumiałem, to chodzi nam przede wszystkim o to, żeby izolować się przed światem zewnętrznym, bo praktyka pokazuje, że to będzie ciężkie i zawodne, więc dobrze jest móc to sobie podmieniać na jakieś wersje inMemory, np. do testów.

Jakoś tego nie czuję np. tutaj: https://github.com/thombergs/buckpal

Projekt na githubie 1000 gwiazdek, pierwsze co się pojawia po wpisaniu w ichniej wyszukiwarce hexagonal architecture (w javie).
Przykładowa klasa
https://github.com/thombergs/buckpal/blob/master/src/main/java/io/reflectoring/buckpal/account/application/service/SendMoneyService.java

Tutaj mam wrażenie, że ktoś stara się włożyć tyle niepotrzebnej abstrakcji w klasy domenowe ile to tylko możliwe. Każde z pól tej klasy to jakiś interfejs, który ma jedną metodę (no dobra poza LoadAccountPort on ma aż 2). Każdy z tych interfejsów ma tylko jedną implementację (a nawet jest jedna klasa która implementuje UpdateAccountStatePort i LoadAccountPort). I ja właśnie w stosunku do takich przykładów się pytam po co mi taki overengineering? Czy nie lepiej by było wywalić wszystkie te interfejsy, skoro i tak mają tylko jedną implementację, i nie jest to żaden świat zewnętrzny (no chyba, że mam jakiegoś zewnętrznego dostawce, który udostępnia nam interfejs do blokowania operacji na koncie ale wątpię?), tylko nasza strefa komfortu, więc przed czym się tu izolować? I jeszcze czuć taki strach przed włożeniem kilku metod do tej samej klasy, nie wiem java wtedy wybucha?

0

Na moje oko to jest właśnie overengineering, a wpychanie wielu obiektów "port" do logiki biznesowej to w zasadzie odwrotność tego co najlepiej powinno się robić- czyli operować jak najwięcej na modelach i serwisach domenowych a nie jakichś technikaliach w postaci obiektów z dopiskiem "port". W tym konkretnym przypadku który podlinkowałeś wystarczyło by po prostu użyć repozytorium.

4

@Aventus: a wiesz co w tym jest najlepsze? Że to przykład to książki...i tak, powinno być repozytorium przy czym to repozytorium to tak naprawdę port/adapter. Interface to cześć domeny, implementacja to część infrastruktury.
Za to używanie jakiś przyrostków jak port czy adapter jest jakimś absurdem.
Robisz np. interface ExchangeRateProvider a implementacja to ExchangeRateRestProvider.

0

@scibi_92:

tak, powinno być repozytorium przy czym to repozytorium to tak naprawdę port/adapter.

Dokładnie, stąd też moje wcześniejsze komentarze na temat onion i hexagonal. No chyba że hexagonal wymusza takie nazewnictwo, wtedy śmiem twierdzić że hexagonal to po prostu onion zrobiony źle :D

1

@Aventus: różnica leży w warstwach. W onion one wystąpują, w hexagonal niekoniecznie.

Jak masz warstwy to wszystko co jest na górze zarządza tym co jest na dole. Tak możesz pisać biznesowe procedury, które mogą objąć całe zadanie od początku do końca (one znają nie tylko swój zakres, ale również kontekst). Procedura zleca zadania i to ona jest za nie odpowiedzialna, za całość, która dzieje się w jej obrębie. W połączeniu z klasami możesz tworzyć zamienne komponenty, które rozwiązują problemy biznesowe.

W hexagonal nie musisz tworzyć warstw, a przynajmniej ja nic o tym takiego nie wiem. Hexagonal jest trochę odpowiedzią na to, gdy układanie warstw za bardzo nie wychodzi, gdzie nie idzie za bardzo ująć przypadku biznesowego tak wprost jak to ma miejsce przy komponentach. Tutaj piszesz bardziej oddolnie. Drobne operacje składasz do kupy, grupujesz podobne operacje, grupujesz podobne efekty, nie myslisz o przypadku biznesowym dość długo, a dopiero na samym szarym końcu to ze sobą łączysz.

Do obu podejść można dojść bez tworzenia wielkich rzeczy, gdy w obu przypadkach zaczniesz pracę od środka, skupisz się na mięsie + będziesz chciał to przetestować. Z tym, że:

  1. w przypadku z onion zaczniesz pisać zgodnie z OOP
  2. w przypadku z hexagonal zaczniesz pisać zgodnie z FP
0

@fgh: ok, to co piszesz ma sens. Co ciekawe to we wszelakich materiałach o hexagonal widziałem przykłady stosujące warstwy (domain w core itp) stąd też moje wątpliwości. Wychodzi na to że sporo osób opisujących hex tak naprawdę opisują onion, albo zwyczajnie nie potrafią tego dobrze wytłumaczyć.

0

@ObywatelRP: Tak to działa w IT: im dany termin jest bardziej niejasny albo nieoczywisty tym lepiej dla niego, bo każdy może się z nim identyfikować z uwagi na to, że potrzeba interpretacji, którą można dowolnie naginać i ewoluować wraz z upływem czasu i mody. Np. nikt do końca nie wiem czym w zasadzie jest OOP albo czym tak naprawdę są unit testy. Sama nazwa to szamanizm, bo czym są te heksy w ujęciu hex architecture xd

Dla mnie hex arch ma dużo analogi do "Functional core, imperative shell", które jest dużo łatwiejsze do zrozumienia.Co do mojej interpretacji: to po prostu sposób projektowania kodu w taki sposób, żeby odseparować jak najwięcej logiki biznesowej od IO i dobra enkapsulacja IO w adapterach tak, żeby interfejs był czysty.

Co do interfejsów: każda klasa implementuje niejawnie jeden interfejs: są nim wszystkie działania, które możesz zrobić z instancją danej klasy. Jak będziesz projektował mądrze klasy to nie potrzebujesz dodatkowych intefejsów, gdy nie jest to potrzebne z innych powodów: w razie potrzeby odpalenie opcji extract w IDE załatwia problem. Pamiętaj, że żadna architektura nie odnosi się do szczegółów języka: to, że ktoś swoim blogu stworzył przykład, gdzie ma pakiety /adapters, /ports/ i /domain nie oznacza wcale, że taki layout == hex arch, albo, że brak takiego layoutu != hex arch.

6

Dla mnie generalnie rozbija się to o bardzo prosty koncept: domain bez zależności.
Domena nie wie nic o tym ze komunikujemy się z jakimiś zewnętrznymi systemami i nigdzie w domenie nie ma referencji do żadnej "zewnętrznej" klasy (oczywiście nie mówię tu o jakimś vavr czy innych libkach), bo w moim przypadku moduł domain nie ma dependency na nic takiego.
To automatycznie wymusza, żeby wszystkie "zewnętrze" zależności były owrapowane jakimś "naszym" modułem (adapterem), który spina naszą domenę z tym zewnętrznym serwisem a sam jest wystawiony w domenie jako jakiś nasz interfejs (port).

2
Shalom napisał(a):

Dla mnie generalnie rozbija się to o bardzo prosty koncept: domain bez zależności.

Ja takie coś po prostu nazywam Clean Architecture, jak wujek Bob to zdefiniował w „Clean Architecture”.

Clean Architecture ma swoje warianty: Onion, Hexagonal i „Tradycyjna Lazania z jedną warstwą przewróconą nierówno, bo Spring Data JPA ma swoje ograniczenia”.

3

Mi się wydaje, że autorzy pisząc o Clean Architecture, Hexach itd często nie dają rady przekazać dwóch podstawowych rzeczy:

  • po pierwsze co jest sednem takich architektur - dla mnie to jest dokładnie to co napisał @Shalom czyli wydzielenie domeny od infrastruktury. Wszelkie kwestie jak to zrobimy są drugorzędne i powinny być rozważane biorąc pod uwagę czynniki typu planowana wielkość projektu
  • drugi temat to wielu autorów pisze o najbardziej zaawansowanej formie mając w głowie jakiegoś wielkiego enterprisowego potwora, jednocześnie używając przykładów dość trywialnych apek aby zobrazować koncept. Przez to wychodzi im jakiś kosmiczny overengeeniering. Trochę winy leży tu też po stronie czytającego, któremu brakuje wyobraźni aby sobie zeskalować to na duży system bo na przykład często nie miał styczności tak dużym systemem w którym to miało sens (ja w sumie jeszcze w aż tak dużych systemach nie pracowałem).

Co mam w praktyce na myśli? Kolega wyżej pisał o tym, że natknął się na 1 klasę implementującą 2 interfejsy i podaje to jako przykład overengenieringu, bo przecież apka potem używa obu interfejsów w tym samym miejscu. Przy małej czy nawet średnie aplikacji to może faktycznie jest overkill, ale jak najbardziej taki agresywny interfejs segregation ma rację bytu w niektórych sytuacjach i nie jest tylko wciskaniem na siłę miliona interfejsów.

Kiedy to może mieć sens? Np. gdy piszemy aplikację, która w przyszłości może mieć ogromny ruch. Na dzisiaj całość korzysta z SQLej bazy więc np. można by stworzyć UserRepositoryInterface zamiast ReadUserInterface i SaveUserInterface. Ale taki podział ma sens jeśli np. myślimy w kierunku "kiedyś przejdziemy na CQRS i te funkcjonalności zostaną rozbite". Drugi powód jaki widzę to przy dużych systemach wstrzykiwanie opasłych intefrejsów utrudnia np. mockowanie, bo zmusza nas do myślenia na zasadzie "które metody z klasy A będą wykorzystywane w tym UseCase, a które mogę pominąć w mocku". Mając bardzo wąskie interfejsy bardzo dokładnie określamy zależności między klasami co ułatwia ogarnięcie testów czy choćby jakiś refactoring w przyszłości. Po prostu trzeba zrozumieć, że czym do wielu rzeczy takich jak SRP trzeba podchodzić zdroworozsądkowo - w jednej aplikacji UserRepository będzie wystarczającą SRP, a w innej już niekoniecznie.

Mam też takie wrażenie, że wielu autorów w swoich książkach próbuje przekazać "jaki jestem mądry" i wychodzi jak wychodzi - mocno podejrzewam, że ich prawdziwe apki to taki sam crap jak każdy inny, ale na konferencji przecież tego nie można powiedzieć. Mało jest natomiast książek typu https://reflectoring.io/book/, która na ~200 stronach przekazuje ideę rozwiązania i mówi otwarcie, że czasami warto iść na skróty i na przykład nie duplikować modeli na każdej warstwie, gdy i tak są one identyczne. W wielu aplikacjach nawet sam Hex w najprostszej postaci jest overkillem. Inna fajna pozycja pokazująca bardzo praktycznie sposoby użycia różnych technik to np. https://leanpub.com/ddd-in-php, aczkolwiek pewnie książka od PHP nie znajdzie na forum wielkiego poklasku ;p

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