Aktualizacja 24.03.2020
Dodałem podział na porty wewnętrzne i zewnętrzne. Zaktualizowałem heksagonalny wykres zależności.
Spotkałem już kilka nazw, a każda z nich wskazywała na tę samą architekturę. Różniły się one co prawda tym, jak był rysowany wykres zależności, ale idea była taka sama.
Ogólnie mówiąc, ta architektura wydaje się czymś naturalnym, a mimo wszystko jest wciąż dość rzadko spotykana. Czym się wyróżnia? Tak w skrócie, to w tej architekturze najważniejsza jest logika biznesowa, tzw. „Core” aplikacji. Jest tam zdefiniowane wszystko to, co jest niezbędne dla działania. Wszystko inne jest implementowane w oddzielnych projektach/warstwach, które mają zależność do „Core”.
Przykład
Aby łatwiej to zrozumieć, posłużmy się prostym przykładem – aplikacja potrzebuje gdzieś przechowywać dane. Z punktu widzenia logiki biznesowej nie ma różnicy, czy to będzie plik tekstowy, baza SQL, czy baza NoSQL. Zatem warstwa logiki biznesowej („Core”) definiuje interfejs, w którym zawiera jakie operacje będą jej niezbędne, a inna warstwa (np. dostępu do danych) implementuje ten interfejs. W takiej implementacji wykorzystuje to, co jest jej potrzebne, np. łączy się z bazą SQL z wykorzystaniem EF. Jednakże to wszystko jest w osobnym projekcie, „Core” nic o tym nie wie. Ponadto główne modele również są zdefiniowane w warstwie logiki biznesowej – jeśli warstwa dostępu do danych potrzebuje trochę innych modeli, to tworzy własne i dodaje mapowanie jednych na drugie.
W praktyce może to wyglądać następująco:
Mamy projekt „Core”, w którym jest zdefiniowana cała nasza logika biznesowa (w tym przypadku w folderze „Services”). Są tam też umieszczone wszystkie modele, które ta logika wykorzystuje (w tym przypadku w folderze „Models”). Dodatkowo mamy zdefiniowane wszystkie interfejsy, których implementacja jest niezbędna do działania logiki biznesowej (w tym przypadku w folderze „Interfaces”).
Do tego mamy projekt „DAL”, który zależy od projektu „Core”. W tym projekcie łączymy się z bazą danych z wykorzystaniem EF – tylko ten projekt ma zależności do EF, „Core” nie ma żadnych zależności.
Uproszczony opis
Nasze projekty możemy podzielić na 3 kategorie:
- Core
- Infrastruktura
- Prezentacja
W „Core” powinien znajdować się tylko projekt (lub projekty) posiadający logikę biznesową. Do „prezentacji” należą wszystkie projekty, które są odpowiedzialne za zwracanie danych z naszego systemu (np. aplikacja WebApi lub MVC). W „infrastrukturze” znajdują się wszystkie pozostałe projekty. To tutaj znajdują się implementacje wszystkich interfejsów zdefiniowanych w kategorii „Core”. Całość może wyglądać tak:
Każdy interfejs publiczny zdefiniowany w Core jest pewnego rodzaju portem. Może być on portem wewnętrznym lub zewnętrznym – w zależności od tego, gdzie jest używany.
Porty wewnętrzne to wszystkie te, z których korzysta infrastruktura. Ich implementacja znajduje się na zewnątrz Core (w Infrastrukturze), a ich użycie znajduje się wewnątrz Core.
Porty zewnętrzne są zdefiniowane w Core. Ich implementacja znajduje się również wewnątrz Core, ale ich użycie znajduje się na zewnątrz Core (w warstwie Prezentacji).
Wykres zależności
Dla takiej aplikacji wykres zależności może wyglądać następująco:
Można go też zaprezentować tak:
W obu jednak przypadkach zależności nie idą od góry do dołu, tylko od zewnątrz do wewnątrz.
Ale po co to wszystko?
Jaki jest w ogóle sens stosowania takiej architektury? Co jest nie tak w najbardziej popularnej, n-warstwowej architekturze, w której to z reguły baza danych jest na samym dole/w centrum?
Bardzo często jest tak, że na początku nasza aplikacja jest prosta. Robi ona niewiele – dane z formularza są zapisywane do bazy danych. Taki typowy CRUD. Wtedy taka n-warstwowa architektura jest jak najbardziej ok. Wtedy to „n” z reguły wynosi 3 – Api (kontrolery), logika biznesowa (serwisy), baza danych (repozytoria). W takich projektach logika biznesowa praktycznie nie istnieje – kontrolery są jednolinijkowe i wywołuje się w nich serwisy. Serwisy są jednolinijkowe i wywołuje się w nich repozytoria. Repozytoria są jednolinijkowe i wywołuje się w nich podstawowe zapytania SQL (z reguły z użyciem ORM).
Niestety, wszystko co dobre szybko się kończy. Biznes zaczyna oczekiwać, aby nasza aplikacja robiła coraz więcej. Do naszego systemu zaczynają dochodzić walidacje – najpierw proste, potem coraz bardziej skomplikowane. Dodatkowo okazuje się, że podczas dodawania czegoś do bazy, musimy wysłać e-mail lub sms. Ponadto potrzebujemy połączyć się z jakąś inną aplikacją, wysłać jej żądanie, a potem odpowiednio je przetworzyć. Wszystko to powoduje, że nasza aplikacja się rozrasta, a logika biznesowa zaczyna komplikować.
W takich sytuacjach dzieją się z reguły dwie rzeczy – albo liczba warstw z „3” zaczyna rosnąć dość mocno, albo nasz kod biznesowy zaczyna być upychany we wszystkich warstwach. Bardzo często wtedy to logika biznesowa pojawia się już nie tylko w serwisach, ale również w kontrolerach i w repozytoriach. Nasza aplikacja coraz bardziej puchnie i coraz trudniej jest nam nad tym wszystkim zapanować. Nie mówiąc już o sytuacjach, w których klient zaczyna nam specyfikować funkcjonalności zaczynając od bazy danych, bo to ona, z punktu widzenia naszej architektury, wydaje się najważniejsza.
Aby do tego wszystkiego nie dopuścić, najlepiej jest od razu założyć, że to logika biznesowa jest w centrum. Że to ona jest najważniejsza. Że to od niej wszystko się zaczyna. Że modele danych służą logice biznesowej, a nie bazie danych. Że jeśli warstwa prezentacji potrzebuje wyświetlić dane z kilku modeli albo ograniczony zbiór danych z jednego modelu, to może nasze obecne modele nie do końca odpowiadają potrzebom biznesowym i powinniśmy je rozbudować lub zmienić.
Dzięki takiemu podejściu, warstwy mniej ważne muszą dostosowywać się do naszej logiki biznesowej (a nie nasza logika biznesowa do tych warstw). Co to oznacza? Często jest tak, że interfejsy mówią pojęciami charakterystycznymi dla danej warstwy. Jeśli zdefiniujemy je w warstwach mniej ważnych, to może okazać się, że będą miały operacje niezrozumiałe (lub nawet niepotrzebne) z punktu widzenia logiki biznesowej. Jeśli natomiast stworzymy je tam, gdzie faktycznie są wykorzystywane (czyli w warstwie logiki biznesowej), to łatwiej będzie nam sprecyzować w nich takie operacje, które będą miały znaczenie z punktu widzenia logiki biznesowej (szczegóły ich implementacji będą ukryte w warstwach zależnych). Dodatkowo te operacje będą współgrały z najważniejszymi elementami naszej aplikacji.
Ponadto taka architektura daje nam dość dużo korzyści. Pisanie testów staje się łatwiejsze, ponieważ cała logika jest zdefiniowana w jednym miejscu. Osobno możemy przetestować logikę biznesową, a osobno „adaptery”, czyli implementacje interfejsów zdefiniowanych w „Core”. Dzięki temu nasze testy będą bardziej granularne i łatwiejsze w utrzymaniu. Aplikacja rośnie nam wszerz, a nie wzdłuż. Staje się ona wygodna w rozszerzaniu, ponieważ mamy stały schemat działania – dodajemy interfejs w „Core”, a jego implementację w osobnym projekcie. Wiadomo gdzie co jest i łatwiej jest taki projekt utrzymać.
Dzięki temu nie ma już problemów związanych z tym, gdzie należy umieścić kolejną funkcjonalność – albo robimy to bezpośrednio w „Core”, albo dodajemy interfejs, który dostarcza wymaganą funkcjonalność, a jego implementację umieszczamy w osobnym projekcie. Skąd mamy wiedzieć, który kod umieścić w którym projekcie? Na początku proponuję cały kod, który nie wymaga zewnętrznych zależności trzymać w „Core”, a wszystko to, co wymaga dostępu do jakiś innych bibliotek (np. EF czy inny ORM, HttpClient, FluentValidation) trzymać w osobnych projektach. Niech nasz „Core” na początku będzie bez żadnych zależności. Z czasem nauczymy się, które zależności są „ok”, a które powinniśmy umieścić w osobnym projekcie.
Pingback: dotnetomaniak.pl
„Ponadto główne modele również są zdefiniowane w warstwie logiki biznesowej – jeśli warstwa dostępu do danych potrzebuje trochę innych modeli, to tworzy własne i dodaje mapowanie jednych na drugie.”
i
„Że jeśli warstwa prezentacji potrzebuje wyświetlić dane z kilku modeli albo ograniczony zbiór danych z jednego modelu, to może nasze obecne modele nie do końca odpowiadają potrzebom biznesowym i powinniśmy je rozbudować lub zmienić.”
są trochę sprzeczne. Nie do końca jest jasne, szczególnie jeśli mówimy o połączeniu Core-UI, czy Core wystawia interfejsy (port) skrojone pod tego co potrzebuje UI, czy może jednak nie patrzy na potrzeby UI zbytnio (jeżeli to drugie to na podstawie czego są kształtowane te interfejsy) ?
Dla DAL’a jest to bardziej naturalne bo w tym przypadku Core jest czynnikiem sterującym bo to on wie co chce zapisać i kiedy i on na swoje potrzeby modeluje port (interfejs) który adapter ma zaimplementować.
W przypadku UI jest odwrotnie, tzn zazwyczaj UI woła Cora kiedy mu potrzeba i wydaje się, że to UI powinien definiować interfejs który Core ma z kolei zaimplementować (czyli klasyczna architektura warstwowa).
Możesz przybliżyć jak faktycznie wygląda połączenia UI – Core w architekturze heksagonalnej?
Jeśli chodzi o DAL, to jest dokładnie tak, jak mówisz. Jeśli chodzi o Modele wykorzystywane po stronie UI, to może faktycznie użyłem zbyt dużego skrótu myślowego.
Często spotykałem się z przypadkami, kiedy ktoś umieszczał logikę w warstwie prezentacji, ponieważ modele zwracane z warstwy domenowej (Core) nie były takie, jakich oczekiwał — np. potrzebował części właściwości z dwóch różnych modeli. Moim zdaniem, skoro taka sytuacja miała miejsce, to znaczy, że był jakiś przypadek biznesowy, który wymagał takiego połączonego modelu, ale w naszej logice biznesowej go brakowało. W takiej sytuacji powinniśmy rozbudować naszą logikę biznesową, a nie zajmować się tym po stronie UI — warstwa prezentacji powinna służyć jedynie do przyjmowania i zwracania danych, a nie operowania na nich.
W dużym uproszczeniu metody po stronie UI powinny być jednolinijkowe — zawierać wywołanie metod z Core. Oczywiście nie zawsze takie będą, bo czasami będziemy musieli sprawdzić najpierw, czy użytkownik jest zalogowany, sprawdzić jego uprawienia albo pobrać jakieś wartości z jego claimów, aby przekazać je dalej. To też powoduje, że modele po stronie UI nie zawsze będą dokładnie takie, jakie po stronie Core. Jednak jeśli mamy sytuację, w której okazuje się, że po stornie UI potrzebujemy jakiegoś Modelu, a logika biznesowa nam go nie zwraca, to warto zastanowić się, czy przypadkiem nie powinniśmy rozbudować naszej logiki, bo czegoś w niej brakuje.
Czyli na jedno pytanie już mam odpowiedź: to UI kształtuje interfejsy (porty) dla warstwy domenowej. Jeżeli UI potrzebuje czegoś konkretnego, a Core tego nie zapewnia, to trzeba go rozbudować – UI ma być maksymalnie głupi pod tym względem (takie też było zawsze moje podejście).
Teraz kolejne pytanie. Załóżmy, że mamy aplikację w postaci monolitu, ale modularnego i zgodnego z założeniami architektury heksagonalnej.
Jak ma wyglądać komunikacja UI-Core w takim wypadku? Core który wystawia interfejs zaimplementowany przez UI nie ma sensu w takim wypadku. Bo tak jak wspominałem wyżej, to UI raczej używa Cora, więc to Core musi implementować interfejs za pomocą którego UI będzie używał Core’a.
Jak to powinno wyglądać w architekturze heksagonalnej?
Dzięki za komentarze – widzę, że nie wyczerpałem dostatecznie tematu.
Nie jestem pewny czy do końca dobrze zrozumiałem pytanie.
Moim zdaniem Core powinien odzwierciedlać potrzeby biznesu. Posłużę się przykładem:
Biznes potrzebuje, aby była możliwość dodania zamówienia, które będzie zawierało dane klienta oraz listę artykułów. Dodatkowo oczekuje, aby po pomyślnym zapisaniu tego zamówienia w naszej bazie danych, wysłać wiadomość e-mail do tego klienta. I ta logika powinna być po stronie Core. Oczywiście biznes tego tak nie opisze — oni raczej powiedzą, że chcą mieć na stronie kilka pól tekstowych, gdzie będzie można wpisać dane klienta oraz wybrać listę artykułów do zamówienia. Ponadto powinien tam znajdować się przycisk, który sprawi, że zamówienie zostanie dodane, a następnie zostanie wysłany e-mail do klienta z potwierdzeniem zamówienia.
Patrzą na to z tej strony można przyjąć, że to UI steruje tym, jak powinny wyglądać interfejsy — są one jednak zdefiniowane po stronie Core.
Gdy mamy modularny monolit wygląda to tak samo. Różnica jest taka, że wtedy prawdopodobnie będziemy mieli jeden wspólny projekt UI, a projektów Core będzie tyle, ile jest modułów. Zamysł powinien być taki sam — Core odpowiada za logikę biznesową, a UI jedynie ją za przyjmowanie i zwraca danych.
Wydaje mi się, że podobną sytuację opisał Robert C. Martin w książce „Czysta architektura” – takie podejście nazwał „pakowanie według komponentów”.
Pracowałem raz w projekcie gdzie byłą zastosowana taka architektura i to działało.
Czy to odpowiada na Twoje pytanie, czy powinienem coś jeszcze doprecyzować?
Czekałem na komentarz, ale z jakiegoś powodu nie pojawiło mi się powiadomienie na maila, mimo że zaznaczyłem subskrypcję i dlatego dopiero teraz odpowiadam.
Koncepcyjnie rozdział prac i odpowiedzialności pomiędzy Corem a UI (czy innymi adapterami) jest zrozumiały.
Chodzi mi bardziej o przykład implementacji.
Załóżmy że mamy DLL’ke z naszą logiką domenową (Corem) która zawiera
DomainLogic.dll
{
class SomeDomainLogicClass // klasa z logiką domenową
{
…
IUIPort uiPort; // używa UI za pomocą portu zdefiniowanego w oddzielnej DLL’ce
…
}
}
Jako część Core’a zaliczamy wystawione przez niego porty i dla większej modularności niech taki UI’owy port będzie zdefiniowany w osobnej dll’ce
DominaLogicUIPort.dll – koncepcyjnie jest to część Core’a
{
interface IUIPort – interfejs (adapter do UI’a)
}
i w końcu sam UI adapter który już do Core’a nie należy
UIAdapter.dll
{
class SomeUIClass : IUIPort
}
Czy tak to ma wyglądać? Czy Core ma faktycznie wciągać UI jako zależność poprzez port i to Core ma sterować UI (np go startować) i czekać na jakieś akcje ze strony UI? Jeśli tak to IUIPort w swojej definicji powinien zawierać w domyśle sporo eventów tak żeby Core był notyfikowany jeżeli użytkownik coś zmieni na UI’u.
Zazwyczaj w podejściu warstwowym jest tak, że to Core implementuje jakiś interfejs na potrzeby UI i UI wciąga Core’a za pomocą tego właśnie interfejsu i wywołuje odpowiednie akcje na Corze używając metod z tego interfejsu. W opisie z poprzedniego akapitu ejst odwrotnie – to Core wciąga UI za pomocą portu i czeka na powiadomienia od niego.
Czy tak to ma właśnie wyglądać?
Niestety silnik do komentarzy nie pozwala na kolejne zagnieżdżenia (odpowiedź), dlatego odpowiem na mój komentarz, aby to jakoś wyglądało 😉
Rozumiem pytanie. Wydaje mi się, że za mało informacji podałem w poście i to mogło spowodować zamieszanie.
Każdy interfejs publiczny zdefiniowany w Core jest pewnego rodzaju portem. Może być on portem wewnętrznym lub zewnętrznym.
Porty wewnętrzne to wszystkie te, z których korzysta infrastruktura. Ich implementacja znajduje się na zewnątrz Core, a ich użycie znajduje się w Core.
Porty zewnętrzne to te, które są zdefiniowane w Core. Ich implementacja znajduje się w Core, ale ich użycie znajduje się poza Core (w warstwie Prezentacji).
W związku z tym, jeśli chodzi o komunikację pomiędzy UI, a Core, wygląda ona tak samo, jak dla aplikacji warstwowych.
Jedyne, o czym powinniśmy pamiętać stosując architekturę heksagonalną to, że UI powinien pozostać „głupi”, czyli nie mieć w sobie żadnej logiki (w aplikacjach warstwowych czasami taką logikę posiada).
Core nie powinien mieć w sobie interfejsów (portów) do sterowania UI, ponieważ on tego nie potrzebuje.
Załóżmy, że mamy aplikację MVC i ktoś na stronie nacisnął przycisk. Obsługa tego powinna znajdować się po stronie UI, ponieważ dla Core jest nieważne, że taka sytuacja miała miejsce. Jednakże to, co biznesowo ma się wydarzyć w momencie naciśnięcie tego przycisku, powinno już być w Core.
Dzięki piękne za odpowiedź. Właśnie informacji o tym, że mamy dwa rodzaje portów w których interfejsy są implementowany na zewnątrz albo wewnątrz Cora brakowało w tym artykule. Co ciekawe ogólnie brakuje takich szczegółów (z mojego punktu widzenia istotnych) we wpisach na temat architektury heksagonalnej zarówno w polskich jaki i anglojęzycznych opisach.
Pingback: Czysta architektura - Karol Bocian
Możliwość komentowania została wyłączona.