Zyskaj większą swobodę w rozwijaniu oprogramowania dzięki architekturze heksagonalnej

30 sierpnia 2022

Autor: Łukasz Wełnicki

W dzisiejszych czasach oprogramowanie powstaje niezwykle szybko. Tempo to skłania twórców do przyjmowania pewnych założeń i podejmowania decyzji już na wczesnym etapie rozwoju aplikacji. Takie decyzje dotyczą na przykład wyboru źródeł danych, mechanizmów komunikacji, sposobów prezentacji danych czy wdrażania aplikacji.

Nie ma w tym nic złego dopóty, dopóki nie pojawi się coś, co zmusi nas do wprowadzenia zmian w kodzie. Zmiany te mogą być podyktowane względami licencyjnymi lub też takimi kwestiami jak skalowalność aplikacji, rozwój organizacji czy łatwość tworzenia aplikacji.

Brutalna prawda jest taka, że im bardziej dany system rośnie, tym trudniej go przebudować lub zmienić jego zewnętrzne komponenty. Z tego powodu twórcy oprogramowania powinni podejmować kluczowe decyzje projektowe tak późno, jak to tylko możliwe. Może im w tym pomóc dobór odpowiedniej lokalnej architektury aplikacji.

Architektura heksagonalna to wzorzec architektoniczny, który daje większą swobodę w tworzeniu aplikacji. Opiera się na architekturze warstwowej, jej więc przyjrzymy się w pierwszej kolejności. 

Czego dowiesz się z tego artykułu?

Co to jest architektura warstwowa i jakie są jej rodzaje?

Tradycyjną i szeroko obecnie stosowaną architekturą jest architektura warstwowa – Layered Architecture. Korzysta z niej ogromna liczba aplikacji. Podstawowa zasada jest prosta: dzielimy oprogramowanie na warstwy, z których każda zawiera komponenty o podobnym zachowaniu i roli. Komunikacja między warstwami przebiega w dół.

Model ten dzielimy na dwa rodzaje:

Rys. 1 pokazuje porównanie obu architektur:

Rys. 1: Porównanie Strict Layered Architecture oraz Relaxed Layered Architecture

Na Rys. 2 widzimy przykład Layered Architecture z następującymi czterema warstwami: User Interface, Application, Domain i Infrastructure. Przepływ sterowania (wskazywany przez strzałki) biegnie w tym samym kierunku, co zależności w kodzie źródłowym. 

Rys. 2: Przykład architektury warstwowej (Layered Architecture)

Na Rys. 3 natomiast możemy zobaczyć alternatywną wersję tej architektury. W tym przypadku zależności kodu źródłowego zostały podzielone dodatkowo na warstwy pionowe, z których każda reprezentuje określoną funkcjonalność. Ten typ architektury najczęściej spotyka się w aplikacjach backendowych.

Rys. 3: Architektura warstwowa z pionowym podziałem zależności kodu źródłowego

Warstwy pionowe powinny komunikować się ze sobą tylko poprzez dobrze zdefiniowane interfejsy. W ten sposób unikamy silnej zależności między nimi – zmiana w jednej nie będzie miała wpływu na inną. Niestety w systemach produkcyjnych prawie nigdy nie stosuje się tej zasady, co prowadzi do sytuacji znanej jako big ball of mud.

Co to jest odwrócenie zależności i dlaczego je stosujemy?

Jedna z zasad SOLID – a ściśle rzecz biorąc ostatnia – mówi, że:

 A: Moduły wysokopoziomowe nie powinny importować niczego z modułów niskopoziomowych. Oba powinny być zależne od abstrakcji (np. interfejsów).
B: Abstrakcje nie powinny być zależne od detali. To detale (czyli konkretne implementacje) powinny być zależne od abstrakcji.

Zauważmy, że w powyższych przykładach Domain, moduł wysokiego poziomu, jest zależna od Infrastructure, modułu niskiego poziomu. Zasada została więc tutaj złamana. Możemy ją jednak przywrócić, odwracając zależności między warstwami, tak jak to pokazano na Rys. 4.

Rys. 4: Zastosowanie inwersji zależności

W tym układzie przepływ sterowania biegnie w kierunku odwrotnym do zależności kodu źródłowego dla warstw Domain i Infrastructure.

Ten zabieg stanowił podstawę do opracowania architektury portów i adapterów.

Co to jest architektura heksagonalna i jakie są jej elementy?

Architekturę portów i adapterów, znaną także pod nazwą Architektury Heksagonalnej, wprowadził w 2005 roku Alistar Cockburn. Opiera się ona na architekturze warstwowej, z tym że zależności między warstwami Infrastructure i Domain zostały odwrócone.

Dodatkowo wprowadzono tu rozdział pomiędzy rdzeniem aplikacji a „światem zewnętrznym”. Zadaniem rdzenia jest implementacja krytycznej logiki biznesowej, która pozostaje niezależna od wszelkich komponentów zewnętrznych (czyli właśnie „świata zewnętrznego”).

Te komponenty to wszystkie punkty wejścia i wyjścia w aplikacji, na przykład REST API, CLI, baza danych SQL, S3, SNS, SQS, Kafka, Rabbit, i wiele innych. 

Jak wspomnieliśmy, rdzeń aplikacji jest zupełnie samodzielny i zawiera tylko logikę biznesową, można go więc zbudować bez łączenia z serwisami zewnętrznymi. Na to czas przyjdzie później. Dopiero gdy rdzeń będzie gotowy, powiążemy go ze “światem zewnętrznym” poprzez system I/O oparty na portach oraz adapterach. Na Rys. 5 możemy zobaczyć diagram przedstawiający całą architekturę.

Rys. 5: Diagram przedstawiający architekturę heksagonalną.

Architektura portów i adapterów doskonale nadaje się do tworzenia systemów przy zastosowaniu Domain Driven Design. Jest tak dlatego, że podejście DDD możemy stosować do rdzenia aplikacji zupełnie niezależnie od zewnętrznych komponentów.

Przyjrzyjmy się teraz rdzeniowi aplikacji i jego elementom, czyli warstwom.

Domain Model

Tę część (Rys. 6) tworzymy za pomocą podejścia DDD. Zawiera ona takie elementy jak Aggregates, Entities, Value Objects, Domain Events, Repositories i Factories.

Rys. 6: Domain Model

Domain Services

Ta warstwa (Rys. 7) zawiera w sobie Domain Model. Składa się z abstrakcji DDD wyższego poziomu, na przykład Domain Services i Use Cases. Domain Services i Domain Model razem wzięte stanowią odpowiednik Domain Layer z klasycznej wersji architektury warstwowej.


Rys. 7: Domain Services

Application Layer

Ta warstwa (Rys. 8) zawiera w sobie Domain Layer i składa się z Application Services.


Rys. 8: Application Layer

Czym są porty w architekturze heksagonalnej i jak ich używamy?

W architekturze portów i adapterów Domain Layer komunikuje się ze światem zewnętrznym za pomocą dwóch rodzajów portów: wejściowych i wyjściowych. 

Porty wejściowe

Portów wejściowych (określanych też jako pierwszoplanowe lub wiodące) używamy do komunikacji z domeną. Porty definiujemy jako interfejsy, które znajdują się wewnątrz domeny – podobnie jak ich konkretne implementacje. W moim przykładzie na Rys. 9 porty główne zdefiniowałem jako konkretne Use Cases.

public interface CreateProduct {
   Either<ProductError, Product> createProduct(CreateProductCommand createProductCommand);
}
public interface DeleteProduct {
   void deleteProduct(ProductId productId);
}
public interface GetProduct {
   Either<ProductError, Product> getProduct(ProductId productId);
}
public interface UpdateProduct {
   Either<ProductError, Product> updateProduct(UpdateProductCommand updateProductCommand);
}

Rys. 9: Porty wejściowe zdefiniowane jako konkretne Use Cases.

Inaczej mówiąc, porty to kontrakty, które wystawia Domain. Stanowią jedyny sposób komunikacji z nią.

Porty wyjściowe

Porty wyjściowe (nazywane również drugoplanowymi lub wtórnymi) domena wykorzystuje do komunikacji ze światem zewnętrznym. Definiujemy je jako interfejsy w domenie, a konkretne implementacje znajdują się w Infrastructure Layer. W moim przykładzie (rys. 10) portem pomocniczym jest klasyczne Repozytorium, a więc wzorzec znany z DDD. Zwrócmy jednak uwagę, że interfejs ten nie ma nic wspólnego z bazą danych, hibernatehibernation, JPA czy innymi detalami implementacji. Jest to po prostu definicja tego, czego domena potrzebuje, aby prawidłowo komunikować się ze światem zewnętrznym.

public interface ProductRepository {
   Option<Product> findById(ProductId productId);
   Product save(Product product);
   void delete(ProductId productId);
}

Rys. 10: Port wyjściowe

Czym są adaptery w architekturze heksagonalnej i jak ich używamy?

Zarówno główne jak i pomocnicze porty byłyby bezużyteczne bez adapterów. Porty są jedynie definicją tego, co należy zrobić, adaptery natomiast określają, jak to robić. Podobnie jak porty, adaptery też dzielą się na wejściowe i wyjściowe.

Adaptery wejściowe

Adaptery wejściowe to konkretne implementacje portów wejściowych. One również znajdują się wewnątrz domeny. W moim przykładzie (Rys. 11) istnieje tylko jeden wejściowy adapter, który implementuje wszystkie Use Cases.

public class ProductService implements CreateProduct, UpdateProduct, DeleteProduct, GetProduct {
   private final ProductRepository repository;
   public ProductService(ProductRepository repository) {
       this.repository = repository;
   }
   @Override
   public Either<ProductError, Product> getProduct(ProductId productId) {
       return repository.findById(productId)
               .toEither(() -> ProductNotFound.of(productId));
   }

   @Override
   public Either<ProductError, Product> createProduct(CreateProductCommand createProductCommand) {
       return Product.create(createProductCommand)
               .map(repository::save);
   }

   @Override
   public Either<ProductError, Product> updateProduct(UpdateProductCommand updateProductCommand) {
       return getProduct(updateProductCommand.id())
               .flatMap(product -> product.update(updateProductCommand))
               .map(repository::save);
   }

   @Override
   public void deleteProduct(ProductId productId) {
       repository.delete(productId);
   }
}

Rys. 11: Jeden adapter wejściowy implementuje wszystkie Use Cases

W ten konkretny adapter wejściowy wstrzyknięto port wyjściowy LoanProductRepository. Dla adaptera nie ma znaczenia, z której implementacji portu będzie korzystać. Konkretna implementacja zostanie przypisana przez narzędzie do Dependency Injection lub poprzez ręczną konfigurację aplikacji (na przykład w ramach testów). 

Adaptery wejściowe może następnie wykorzystać Application Layer (Rys. 12). Opisałem tę warstwę jako miejsce, w którym znajdują się Application Services. Typowe zadania Application Services to:

@Service
@Transactional

public class ProductApplicationService {
   private final GetProduct getProduct;
   private final CreateProduct createProduct;
   private final UpdateProduct updateProduct;
   private final DeleteProduct deleteProduct;

   public ProductApplicationService(
           GetProduct getProduct, CreateProduct createProduct, UpdateProduct updateProduct,
           DeleteProduct deleteProduct) {
       this.getProduct = getProduct;
       this.createProduct = createProduct;
       this.updateProduct = updateProduct;
       this.deleteProduct = deleteProduct;
   }

   public Either<ProductError, Product> getProduct(ProductId productId) {
       return getProduct.getProduct(productId);
   }

   public Either<ProductError, Product> createProduct(CreateProductCommand createProductCommand) {
       return createProduct.createProduct(createProductCommand);
   }

   public Either<ProductError, Product> updateProduct(UpdateProductCommand updateProductCommand) {
       return updateProduct.updateProduct(updateProductCommand);
   }

   public void deleteProduct(ProductId productId) {
       deleteProduct.deleteProduct(productId);
   }
}

Rys. 12: Application Service

Adaptery wyjściowe

Adaptery wyjściowe są implementacją portów wyjściowych. Znajdują się wewnątrz Infrastructure Layer

W tym miejscu po raz pierwszy podejmujemy istotne decyzje. Na rys. 13 chcę użyć JPA i relacyjnej bazy danych. Moja domena nic nie wie o tym detalu implementacyjnym.

@Repository
class SqlProductRepository implements ProductRepository {
   private final JpaProductRepository productRepository;
   private final EntityMapper entityMapper;

   public SqlProductRepository(JpaProductRepository productRepository, EntityMapper entityMapper) {
       this.productRepository = productRepository;
       this.entityMapper = entityMapper;
   }

   @Override
   public Option<Product> findById(ProductId productId) {
       return Option.ofOptional(productRepository.findById(productId.value())
               .map(entityMapper::from));
   }

   @Override
   public Product save(Product product) {
       var storedEntity = productRepository.save(entityMapper.from(product));
       return entityMapper.from(storedEntity);
   }

   @Override
   public void delete(ProductId productId) {
       productRepository.deleteById(productId.value());
   }
}

Rys. 13: Adapter wyjściowy specyficzny dla JPA implementuje port pomocniczy

Mógłbym również zastosować inny adapter wyjściowy do zaimplementowania `LoanProductRepository`, na przykład `InMemoryLoanProductRepository` używany do testów. Tak jak na rysunku 14 poniżej:

public class InMemoryProductRepository implements ProductRepository {

   private final Map<ProductId, Product> map = new ConcurrentHashMap<>();

   @Override
   public Option<Product> findById(ProductId productId) {
       return Option.of(map.get(productId));
   }

   @Override
   public Product save(Product product) {
       map.put(product.id(), product);
       return product;
   }

   @Override
   public void delete(ProductId productId) {
       map.remove(productId);
   }
}

Rys. 14: Adapter wyjściowy in-memory implementuje port wyjściowy

Dzięki takiej implementacji pisanie testów jednostkowych dla domeny jest bardzo proste. Tak mały fragment aplikacji nie wymaga ani orkiestracji związanej z wykorzystaniem narzędzia do testowania, ani zastosowania mockowania, co w przypadku najpopularniejszych bibliotek jest zaimplementowane z użyciem refleksji. Rys. 15 pokazuje moją implementację takiego testu.

class ProductServiceTest {

   private final InMemoryProductRepository repository = new InMemoryProductRepository();
   private final ProductService productService = new ProductService(repository);

   @Nested
   class GetProduct {

       @Test
       void wontGetProductWhichWasNotStored() {
           // when
           var result = productService.getProduct(ProductId.random());

           // then
           assertThat(result.isLeft()).isTrue();
           assertThat(result.getLeft()).isInstanceOf(ProductError.ProductNotFound.class);
       }

       @Test
       void findsStoredProduct() {
           // given
           var product = DomainGenerators.randomProduct();
           repository.save(product);

           // when
           var result = productService.getProduct(product.id());

           // then
           assertThat(result).singleElement().isEqualTo(product);
       }
   }

   @Nested
   class CreateProduct {

       @Test
       void savesInRepository() {
           // given
           var createProduct = DomainGenerators.randomCreateProduct();

           // when
           var result = productService.createProduct(createProduct);

           // then
           assertThat(result.isRight()).isTrue();
           assertThat(repository.findById(result.get().id())).singleElement().isEqualTo(result.get());
       }
   }

   @Nested
   class UpdateProduct {

       @Test
       void wontUpdateProductWhichWasNotStored() {
           // given
           var updateProduct = DomainGenerators.randomUpdateProduct(ProductId.random());

           // when
           var result = productService.updateProduct(updateProduct);

           // then
           assertThat(result.isLeft()).isTrue();
           assertThat(result.getLeft()).isInstanceOf(ProductError.ProductNotFound.class);
       }

       @Test
       void updatesProduct() {
           // given
           var product = DomainGenerators.randomProduct();
           repository.save(product);
           var updateProduct = DomainGenerators.randomUpdateProduct(product.id());

           // when
           var result = productService.updateProduct(updateProduct);

           // then
           assertThat(result).singleElement().isEqualTo(product.update(updateProduct).get());
       }
   }

   @Nested
   class DeleteProduct {

       @Test
       void noExceptionIsThrownOnDeletingNotExistingProduct() {
           // expect
           assertThatNoException().isThrownBy(() -> productService.deleteProduct(ProductId.random()));
       }

       @Test
       void deletesProduct() {
           // given
           var product = DomainGenerators.randomProduct();
           repository.save(product);

           // when
           productService.deleteProduct(product.id());

           // then

           assertThat(repository.findById(product.id())).isEmpty();
       }
   }
}

Rys. 15.: Testy jednostkowe domeny

Istnieje jedna ważna zasada dotycząca adapterów: nie mogą się ze sobą komunikować. To znaczy, że nie można wywoływać jednego adaptera z innego.

Jak na razie omówiłem rdzeń aplikacji. Co jednak znajduje się w Infrastructure Layer? W moim przykładzie (rys. 16) są tam wszystkie komponenty baz danych oraz implementacja REST API.

Rys. 16: Infrastructure Layer z komponentami baz danych i implementacją REST API

Jak testować architekturę heksagonalną?

Architektura portów i adapterów rządzi się pewnymi ścisłymi regułami dotyczącymi zależności kodu źródłowego. Przestrzeganie tych reguł nie powinno być tylko kwestią konwencji. Trzeba je też egzekwować testami. Archunit jest świetnym narzędziem do testowania architektury portów i adapterów. Posiada specjalny DSL, który pozwala łatwo sprawdzić strukturę architektury (Rys. 17).

public class PortsAndAdaptersTest {
   private final JavaClasses classes = new ClassFileImporter().withImportOption(new DoNotIncludeTests())
           .importPackages("com.lukaszwelnicki.products");

   @Test
   void shouldFollowPortsAndAdaptersRules() {
       onionArchitecture()
               .domainModels("com.lukaszwelnicki.products.domain.model..")
               .domainServices("com.lukaszwelnicki.products.domain.service..")
               .applicationServices("com.lukaszwelnicki.products.application..")
               .adapter("persistence", "com.lukaszwelnicki.products.infrastructure.persistence..")
               .adapter("rest", "com.lukaszwelnicki.products.infrastructure.rest..")
               .check(classes);
   }
}

Rys. 17: Testowanie architektury portów i adapterów za pomocą Archunit

Dlaczego Netflix zdecydował się na architekturę heksagonalną?

Netflix nie tylko udostępnia treści, ale też je tworzy. Co roku na platformie pojawiają się setki oryginalnych produkcji. Stworzenie choćby jednej to wieloetapowy proces, którego powodzenie zależy od zebrania danych pochodzących z wielu zewnętrznych źródeł.

W przypadku Netflixa dane takie jak scenariusze, aktorzy, listy płac, lokalizacje itp. znajdują się w różnych mikroserwisach. Może się zdarzyć, że każdy mikroserwis będzie używać do komunikacji innego protokołu. W tej sytuacji skoordynowanie produkcji wymaga aplikacji zdolnej do szybkiej integracji danych z różnych rodzajów protokołów. 

Netflix poszukiwał oprogramowania działającego na zasadzie szablonu. Powinno ono zawierać tylko niezbędną logikę biznesową i pozwalać stosunkowo łatwo dopasowywać do różnych źródeł danych. Architektura heksagonalna wydawała się idealnym rozwiązaniem.

Wkrótce po wdrożeniu architektury heksagonalnej aplikacja Netflixa musiała dostosować się do nowego źródła danych – a konkretnie przejść z JSON API na GraphQL. Udało się jej to zrobić w zaledwie dwie godziny.

Być może Twoja organizacja również ma styczność z różnymi rodzajami źródeł danych i potrzebuje szybkiego oraz niezawodnego sposobu na przełączanie się między nimi przy minimalnej ingerencji w kod. Jeśli tak jest, architektura heksagonalna będzie idealnym rozwiązaniem.

Jakie są zalety architektury heksagonalnej?

Nie piszę o architekturze heksagonalnej z abstrakcyjnego punktu widzenia. Miałem okazję współtworzyć kilka komercyjnych produktów, które ją wykorzystywało. Dzięki temu mogę się podzielić kilkoma obserwacjami na jej temat.

Kiedy już się do niej przyzwyczaić, architektura heksagonalna staje się naturalnym wyborem przy tworzeniu oprogramowania. Nawet nowicjusz może ją napisać przy użyciu Test-Driven Development, ponieważ łatwo uzyskuje się full test coverage dla domeny.

Jeśli chodzi o potencjalne zastosowania, architektura heksagonalna szczególnie dobrze nadaje się do mikroserwisów – promuje to na przykład Vaughn Vernon w swojej książce „Implementing Domain Driven Design”.

Ponadto, moje własne doświadczenie potwierdza, że architektura heksagonalna daje dużą swobodę w żonglowaniu różnymi rozwiązaniami bez naruszania rdzenia aplikacji. Kiedyś na przykład mój zespół musiał przejść z JPA na JOOQ i  zmiana ta nie miała żadnego wpływu na naszą domenę. Wszystkie testy pozostały nienaruszone, ponieważ stworzyliśmy nasze porty w taki sposób, w jaki domena ich potrzebowała.

Jakie są wady architektury heksagonalnej?

Istnieje kilka zastrzeżeń dotyczących stosowania architektury heksagonalnej.

Po pierwsze, nie jest to dobry wybór dla CRUD-ów. W tym miejscu powinienem przyznać, że przykładowa aplikacja wykorzystana w tym artykule jest właśnie CRUD-em, nadmiernie skomplikowanym przez zbyt wiele interfejsów i mapperów. Dlatego warto pamiętać, że architektury portów i adapterów należy używać tylko wtedy, gdy wiadomo, że aplikacja będzie wykonywała nietrywialną logikę biznesową.

Sprawy komplikują się także, gdy stosujemy podejście reaktywne. Zgodnie z założeniami architektury portów i adapterów domena musi być niezależna od frameworków i bibliotek. W dłuższej perspektywie oczywiście opłaca się to i pozwala na większą elastyczność. Ale w przypadku bibliotek do programowania reaktywnego cały przepływ opiera się o wrappery, jak choćby Flux i Mono ze Spring Webflux. W konsekwencji typy te lądują również w domenie.

Wreszcie, strukturyzacja pakietów w architekturze heksagonalnej jest kwestią wyboru. Niektóre zespoły podchodzą do tego w jeden sposób, inne w inny. Chociaż na pierwszy rzut oka taka swoboda wydaje się dobra, to może utrudniać śledzenie konwencji między zespołami.

Kiedy zastosować architekturę heksagonalną u siebie?

Wiele czynników wpływa na wybór podejścia do tworzenia oprogramowania. Z jednej strony firma może mierzyć się z podobnymi wyzwaniami jak Netflix, przez co architektura heksagonalna wyda się atrakcyjnym wyjściem; ale szala może przechylić się w drugą stronę, jeśli oprogramowanie w organizacji wykorzystuje podejście reaktywne.

Jeśli nie jesteś pewien, co zrobić, skontaktuj się z naszymi ekspertami. Pracowaliśmy przy wielu projektach wykorzystujących architekturę heksagonalną i potrafimy wskazać, jakie jest najlepsze rozwiązanie.

Kod z artykułu można znaleźć pod tym adresem:
https://github.com/lukaszwelnicki/ports-and-adapters

bannerbanner

Your software development experts

We’re a team of experienced and skilled software developers – and people you’ll enjoy working with.

Start Your Projectadd