Porty i adaptery w praktyce

Paweł Witkowski | Rozwój oprogramowania | 24.09.2019

Podczas tworzenia systemów informatycznych pojawiają się pytania dotyczące wyboru architektury i organizacji kodu. W dzisiejszym artykule chciałbym bliżej przedstawić architekturę portów i adapterów (ports & adapters) w praktyce oraz rozwiać wszelkie wątpliwości związane z tym wzorcem. Jakie są największe zalety architektury tupu porty i adaptery? Czy sprawdzi się ona w każdym przypadku?

Architektura wzorca porty i adaptery

Wzorzec porty i adaptery (ang. ports & adapters) określany również jako architektura hexagonalna (ang. hexagonal architecture) to nic innego jak wzorzec narzucający sposób budowy i organizacji kodu aplikacji. Głównym założeniem tego wzorca jest tworzenie kodu w taki sposób, aby maksymalnie odseparować implementację logiki biznesowej od wszelkich zależności zewnętrznych, takich jak frameworki, bazy danych, usługi zewnętrzne itp. Dzięki temu zyskujemy większą kontrolę nad kodem i niezależność od zewnętrznych bibliotek oraz wprowadzanych w nich zmian. Przyjrzyjmy się bliżej wzorcowi:

Porty i adaptery

Wzorzec portów i adapterów dzieli strukturę kodu na dwa obszary:

  • wewnętrzny (application / domain)
  • zewnętrzny (infrastructure)

Obszar wewnętrzny skupia się na rozwiązaniu i implementacji głównego problemu (use case). Obszar ten nie ma żadnych odniesień do frameworków, baz danych i innych usług lub bibliotek zewnętrznych, ani nie czerpie z nich. Obszar zewnętrzny z kolei zawiera implementację portów w postaci adapterów oraz klasy związane z frameworkami, jak na przykład Spring, Hibernate itp. Komunikacja pomiędzy obszarem wewnętrznym i zewnętrznym realizowana jest za pomocą portów i ich implementacji, czyli adapterów (stąd też nazwa wzorca).

Kilka podstawowych założeń architektury typu porty i adaptery:

  1. Obszar wewnętrzny, czyli właściwa implementacja logiki, „nic nie wie” o obszarze zewnętrznym. Nie ma zależności pomiędzy tym obszarem a frameworkami (jak wspomniane Spring, Hibernate itp.).
  2. Logika rozwiązania problemu zamknięta jest w obszarze wewnętrznym. Dostęp do niej mamy wyłącznie za pośrednictwem klasy fasady domenowej, o której piszę więcej w dalszej części artykułu.

Porty i adaptery – przykładowa implementacja

Głównym celem przykładu jest zaprezentowanie implementacji klas i organizacja pakietów w oparciu o idee architektury typu ports & adapters. Za przykład niech posłuży obszar domeny odpowiedzialny za rejestrację nowych użytkowników w systemie. Zaimplementowane zostały tutaj dwa przypadki użycia:

  • rejestracja nowego użytkownika – obejmuje funkcjonalności związane z wygenerowaniem kodu aktywacji konta oraz wysłanie maila aktywacyjnego.
  • aktywacja konta użytkownika – weryfikuje wygenerowany kod i aktywuje konto oraz wysyła komunikat do systemu zewnętrznego.

Architektura hexagonalna – organizacja i opis pakietów

Porty i adaptery
  1. Pakiet: user

To główny pakiet domeny, w obrębie której będziemy się poruszali. Czyli domeny użytkowników. Pakiet ten zawiera dwa podpakiety: crud oraz registration 

  1. Pakiet: user.crud 

W pakiecie tym została zamknięta logika związana z prostymi operacjami pobierania danych. Ponadto może on zawierać inne operacje, które nie posiadają złożonej logiki biznesowej i służą wyłącznie prostym operacjom typu CRUD (od: create, read, update and delete).

  1. Pakiet: user.registration 

Ten pakiet zawiera wszystkie klasy związane z logiką rejestracji nowych użytkowników w aplikacji (implementacja i obsługa wspomnianych wcześniej przypadków użycia).

  1. Pakiet: user.registration.domain

Pakiet zawiera klasy z główną implementację logiki biznesowej oraz definicje portów (interfejsy Java), za pośrednictwem których odbywa się komunikacja z innymi komponentami systemu. Pakiet core.user.registration.domain zawiera kod vanilla Java, tzn. kod, który nie jest zależny od frameworków i innych bibliotek zewnętrznych. Jedynym punktem dostępu do zaimplementowanej logiki w tym pakiecie jest klasa fasady. Co ważne: to, jakich bibliotek będziemy używali (np.: commons-lang, Dozer, Orika) wewnątrz pakietu, zależy od zespołu i ogólnych ustaleń w projekcie.

  1. Pakiet: user.registration.infrastructure

Pakiet ten zawiera implementację adapterów, które wykorzystywane są za pośrednictwem zdefiniowanych w kodzie domeny portów. W pakiecie zamknięta jest implementacja i konfiguracja komponentów korzystających z funkcji i metod frameworków. Często używane są tutaj biblioteki innych dostawców rozwiązań, które są odpowiedzialne np. za generowanie plików PDF, wysyłanie maili czy komunikację z systemami zewnętrznymi, takimi jak Kafka, SOAP itp.

  1. Pakiet: user.registration.infrastructure.config

W pakiecie tym powinna znaleźć się konfiguracja związana z tworzeniem i obsługą komponentów wykorzystywanego frameworka.

  1. Pakiet: user.registration.infrastructure.entrypoint

Pakiet ten zawiera klasy kontrolerów i klasy typu DTO, za pośrednictwem których inne systemy czy moduły mogą korzystać z funkcji naszej domeny. Klasy DTO opisują struktury danych wejściowych i wyjściowych dla REST API.           

  1. Pakiet: user.registration.infrastructure.repository

Pakiet klas związanych z warstwą persystencji, czyli klasy odpowiedzialne za pobieranie i zapisywanie danych w bazie. Są to definicje modelu danych (ORM), klasy DAO. Pakiet ten zawiera m.in. implementację klasy adaptera dostępu do danych. W bardziej rozbudowanych przypadkach możemy mieć więcej klas modelu lub odwoływać się do klas modelu (ORM) z innych pakietów.

Architektura hexagonalna – opis implementacji klas

  1. Klasy z pakietu user.registration.domain:
  • UserRegistration – główna klasa implementacji logiki biznesowej. Dostęp do klasy ograniczony na poziomie pakietu.
  • UserRegistrationDataProvider – port dla operacji wejścia / wyjścia (we/wy) związanych z dostępem do bazy danych,
  • UserRegistrationNotifier – port wyjściowy do wysyłania komunikatów na kolejkę,
  • ConfirmationMailSender – port wyjściowy do wysyłania wiadomości mailem.

Wyżej wymienione porty to publiczne interfejsy klas Java, których implementacja odbywa się na poziomie pakietów infrastruktury.

UserRegistrationFacade – fasada domeny. Klasa, za pośrednictwem której mamy dostęp do metod (funkcji) domeny. W przedstawionym przykładzie klasa fasady pełni funkcję wywołującą dla klasy UserRegistration, czyli pełni dla niej funkcję wrappera. W bardziej złożonych przypadkach możemy posiadać więcej klas zawierających logikę biznesową. Wówczas fasada stanowi punkt dostępu do wywołań wszystkich tych funkcji domeny. Czasami może się zdarzyć, że będziemy potrzebowali dołożyć na tym etapie określone walidacje związane z frameworkami. Może być to podyktowane tym, że niektóre inne obszary domenowe będą potrzebowały wykorzystać logikę z innej domeny. Można wówczas na poziomie fasady korzystać z funkcji walidacji. Oczywiście wszystko podobnie jak we wspomnianych wcześniej przypadkach zależy od ogólnych ustaleń i założeń projektowych w zespole.

  1. Klasy z pakietu user.registration.infrastructure

Implementacja adapterów:

  • ConfirmationMailSenderAdapter
  • UserRegistrationNotifierAdapter
  • UserRegistrationDataProviderAdapter
  1. Klasa UserRegistrationService jest odpowiedzialna za transakcyjność wykonywanych operacji. Klasa ta za pośrednictwem klasy fasady UserRegistrationFacade wywołuje kod logiki biznesowej. W przypadku bardziej złożonych operacji na poziomie tej klasy mogą być wstrzykiwane fasady pochodzące z innych obszarów.
  2. Klasy z pakietu user.registration.entrypoint.
  3. Klasa kontrolera UserRegistrationController wystawia REST API. Używa klasy UserRegistrationService realizującej operacje wymagające. transakcji. W przypadku gdy operacje nie wymagają od nas transakcji, możemy w klasie tej bezpośrednio korzystać z klasy fasady.

W przykładowej implementacji pakietu domain widać dużą liczbę klas – jest to jeden z atrybutów tego wzorca. Dzięki temu uzyskujemy hermetyczność implementacji logiki biznesowej i struktur z nią związanych. Punktem dostępu do funkcji domeny jest klasa fasady. Z założenia klasy pakietu domain nie powinny używać klas frameworków, stąd też większa liczba klas POJO (plain old Java object) związanych z danymi. W wypadku złożonych przypadków użycia można tworzyć większą liczbę klas z implementacją logiki biznesowej. W przykładzie można np. wydzielić aktywację konta do osobnej klasy.

Przeczytaj także: Test-Driven Development na co dzień

Porty i adaptery – najczęstsze pytania

Dlaczego musimy ograniczać dostęp do niektórych klas na poziomie pakietu?

Za pomocą modyfikatorów dostępu do klas i metod ukrywamy szczegóły implementacji – czyli stosujemy tzw. hermetyzację klas. Zapewniamy tym samym kontrolę nad dostępem, stanem i zachowaniem obiektu.  W naszym przykładzie w pakiecie domain dostęp publiczny posiadają: klasa fasady oraz klasy reprezentujące dane wejściowe i wyjściowe. Cała implementacja logiki jest zamknięta na poziomie pakietu i dostęp do operacji jest możliwy wyłącznie za pośrednictwem metod zdefiniowanych w klasie fasady.

Co z bibliotekami typu Lombok, JSR 303 (walidacja) lub loggerami w pakiecie domain?

Zgodnie z główną ideą architektury hexagonalnej nie powinniśmy używać bibliotek zewnętrznych. Praktyka dowodzi jednak czegoś innego. To, jakich bibliotek użyjemy wewnątrz domeny, zależy od decyzji zespołu, gdyż każda osoba w zespole ma inne doświadczenia i pomysły. Warto jednak zastosować pewne ograniczenia co do tych narzędzi. W projektach, w których brałem udział, ograniczaliśmy się głównie do bibliotek typu java commons, biblioteki mapperów oraz walidacji.

Porty i adaptery – gdzie zakładać transakcje?

Jednym z ważniejszych elementów implementacji operacji jest ich transakcyjność.
Często wymagana operacja będzie potrzebowała wykorzystać funkcję z innej domeny kodu lub będziemy chcieli wywołać funkcję domeny w transakcji. Wówczas musimy zapewnić transakcyjność dla wywołań tych funkcji. Dla takiego przypadku można zbudować dedykowany serwis, który będzie zawierał klasy fasad domenowych.
W przykładzie serwis został zaimplementowany w klasie UserRegistrationService.

Dlaczego nie powinniśmy zakładać transakcji w klasie fasady?

Brak transakcji w klasie fasady wynika z założenia, że kod w pakiecie domain ma być wolny od wszelkich frameworków.

Czy można używać DDD razem ze wzorcem ports & adapters?

DDD (Domain-Driven Design) skupia się na domenie i zawartej w niej logice biznesowej. Nie ma nic wspólnego z frameworkami i transakcjami. W przykładzie część związana z DDD została zaimplementowana w pakiecie domain. Wzorzec ports & adapters pomaga nam uporządkować kod i odseparować dostęp do zaimplementowanej logiki. Można stwierdzić, że wzorzec ten bardziej odpowiada za warstwę aplikacyjną rozwiązania, ponieważ odpowiada m.in. za dostarczenie mechanizmów konfiguracji, transakcji czy persystencji. Podejście DDD oraz wzorzec ports & adapters współgrają ze sobą w strukturach kodu. DDD rozwiązuje problem logiczny, a P&A zapewnia dostęp do technicznych mechanizmów i bibliotek.

Czy zawsze powinniśmy stosować wzorzec ports & adapters?

Jeżeli zastanawiamy się, kiedy stosować architekturę hexagonalną, decydujący będzie aspekt logiki biznesowej. Wzorzec ports & adapters idealnie sprawdzi się w przypadku rozbudowanej logiki biznesowej, gdy chcemy skupić się na rozwiązaniu problemów, które zostały tam postawione. Kod jest wolny od wszelkich frameworków, dzięki czemu łatwo się nim zarządza i testuje. jest to, że testy uruchamia się szybko i pisze łatwo. Piszemy mniej testów integracyjnych – dużą część logiki pokrywają testy jednostkowe. W testach integracyjnych weryfikujemy krytyczne ścieżki.

Podejście to narzuca nam tworzenie większej liczby klas w celu odseparowania się od reszty kodu spoza pakietu, co na pierwszy rzut oka wydaje się nadmiarowe. Dzięki temu jednak łatwiej jest wydzielić logikę do osobnych niezależnych modułów (w architekturach mikroserwisowych często pojawia się potrzeba wydzielenia logiki do osobnej usługi).

W przypadku aplikacji, które posiadają mało logiki biznesowej lub wręcz jej nie ma (aplikacje typu CRUD), lepszym podejściem jest budowa struktury kodu w formie wielowarstwowej (Controller – Service – Repository). W przykładzie został wydzielony pakiet CRUD, w którym zaimplementowana została prosta operacja pobrania listy użytkowników.

Podsumowanie

Mam nadzieję, że udało mi się przedstawić główne idee budowy kodu w architekturze portów i adapterów oraz odpowiedzieć na część pytań, które mogą pojawić się podczas implementacji. Oczywiście w każdym projekcie może wyglądać to inaczej. Wszystko zależy od Was. To zespół tworzy oprogramowanie – organizacja i struktura kodu powinna być czytelna dla wszystkich i umożliwiać wygodną pracę. Tak naprawdę to, jak zaimplementowany zostanie wzorzec, zależy więc od zespołu i jego decyzji.

Zobacz więcej:

TOPICS