Architektura heksagonalna (porty i adaptery): plusy i minusy

Dzisiaj pokażę Ci architekturę heksagonalną, która jest znana jako wzorzec portów i adapterów. Nauczysz się podstaw tego wzorca architektonicznego, a także zobaczysz jakie są jego jego zalety i wady. Dodatkowo pokażę Ci przypadki, w których warto zastosować ten sposób projektowania aplikacji. Artykuł porusza temat połączenia architektury heksagonalnej z technikami modelowania Domain Driven Design (DDD).

Architektura heksagonalna, nazywana również architekturą ośmioboczną lub architekturą portów i adapterów, to podejście do projektowania oprogramowania, w którym logika biznesowa jest odizolowana od szczegółów implementacyjnych.

W architekturze heksagonalnej, aplikacja składa się z trzech głównych części:

  1. Portów - interfejsów, które definiują sposoby komunikacji aplikacji z otoczeniem zewnętrznym. Porty są punktami wejścia i wyjścia aplikacji, przez które przepływają informacje.

  2. Adapterów - implementacji portów, które umożliwiają komunikację między aplikacją a otoczeniem zewnętrznym. Adaptery tłumaczą informacje z formatu zrozumiałego dla zewnętrznego systemu na format zrozumiały dla aplikacji i vice versa.

  3. Logiki biznesowej - centralnej części aplikacji, która przetwarza informacje przekazywane przez porty i adaptery. Logika biznesowa jest odizolowana od warstwy technicznej, co umożliwia łatwą wymienność adapterów i portów bez zmian w logice biznesowej.

domain layer is the most important in the hexaconal architecture

Architektura heksagonalna (porty i adaptery)


Porty

Oddzielenie logiki biznesowej od komponentów infrastruktury to główne zadanie architektury heksagonalnej. W warstwie logiki biznesowej zamiast bezpośrednio odwoływać się do komponentów infrastrukturalnych definiujesz “porty”, które są niczym drzwi dla komponentów niższego rzędu.

Pomyśl o tych portach jako o portach USB. jest to wtyczka w którą możesz coś wsadzić. Możesz tam wsadzić metodę zapisu do bazy danych a w innym przypadku metodę zapisu do pliku. Logika biznesowa ma w głębokim poważaniu gdzie te dane zostaną zapisane. Wysyła dane do portu i co dalej dzieje się z danymi zależy od tego co jest do tego portu “wsadzone”.

Adaptery

Adapter to jest właśnie to coś co chcesz wsadzić do portu. Na podstawie przykładu powyżej w którym logika biznesowa chce zapisać dane możemy mieć zdefiniowany taki port:

interface Writer {
	saveData(data: string): boolean
}

Widzisz tutaj interfejs w którym jest funkcja saveData, która przyjmuje argument data typu string i zwraca true/false.

Zobacz teraz jak mogą wyglądać adaptery dla tego portu:

const databaseWriter: Writer = {
    saveData: (data: string): boolean => {
        console.log(`"${data}" is saved in the database...`);

        return true;
    }
}

const fileWriter: Writer = {
    saveData: (data: string): boolean => {
        console.log(`"${data}" is saved in the file...`);

        return true;
    }
}

Teraz definiujesz klasę która na potrzeby przykładu ma dość enigmatyczna nazwę: BussinesLogic:

class BussinesLogic {
    constructor(
        private writer: Writer
    ) {}

    execute(): void {
        this.writer.saveData('my data');
    }
}

Klasa oczekuje, że dostanie Writer jako argument. Poniżej widzisz metodę execute, która w naszym prostym przykładzie zapisuje dane.

Teraz mając zdefiniowane dwa różne adaptery możesz użyć ich tak jak poniżej:

const app1 = new BussinesLogic(fileWriter);
app1.execute(); // "my data" is saved in the file..."

const app2 = new BussinesLogic(databaseWriter);
app2.execute(); // "my data" is saved in the database..."

Ma to sens?

Zobacz teraz na bardziej skomplikowany przykład w którym robisz stronę produktu sklepu internetowego. Masz takie przypadki użycia:

  1. Jako użytkownik mogę zobaczyć informację o produkcie i jego cenę

  2. Jako użytkownik mogę dodać produkt do koszyka

Zacznijmy od zdefiniowania typów i interfejsów dla trzech encji których potrzebujemy:

type Price = {
    currency: string;
    amount: number;
}

interface Product {
    id: string
    getDescription(): string|null
    getPrice(): Price|null
}

interface Cart {
    addToCart(productId: string): void
}

Mamy tutaj cenę, produkt i koszyk. Mówiłem, że będzie trochę bardziej skomplikowany przykład i chyba trochę jest. :)

Teraz zobacz jak może wyglądać logika biznesowa:

class CommerceBussinesLogic {
    constructor(
        private product: Product,
        private cart: Cart
    ) {}

    execute(): void {
        this.product.getDescription();
        this.product.getPrice()
    }

    public addProductToCart(productId: string) {
        return this.cart.addToCart(productId);
    }
}

Logika biznesowa wystawia dwa porty: product i cart. Napiszmy teraz adaptery do tych portów. Klient mówi, że chce się zintegrować z systemem eCommerce Magento. Mówi i ma:

class MagentoProductAdapter implements Product {

    private description: string|null = null;
    public price: Price|null = null;

    constructor(
        public id: string
    ) {
        console.log('Imagine that you fetch product data from ecommerce here...') // ex. fetch('<ecommerce_api_url>/product/{id}')
        this.price = {
            amount: 199.00,
            currency: 'EUR'
        }
        this.description = 'Lorem ipsum dolor sit amet';
    }

    public getDescription() {
        return this.description;
    }

     public getPrice() {
        return this.price;
    }
}

class MagentoCartAdapter implements Cart {
        private items: Array<Product>
        private subtotal: Price

    constructor() {
        console.log('Imagine that you fetch cart here ...') // ex. fetch('<ecommerce_api_url>/cart')
        this.items = []
        this.subtotal = {
            amount: 0,
            currency: 'EUR'
        }
    }

     public addToCart(productId: string) {
        console.log('addToCart clicked') // send request to eccommerce here
    }
}

BTW te przykłady sa bardzo prostym pseudo kodem w TypeScripcie bardziej, żeby pokazać Ci ideę niż dać gotowy produkcyjny kod, więc jak widzisz takie coś:

console.log("Imagine that you fetch cart here ..."); // ex. fetch('<ecommerce_api_url>/cart')

Teraz zamknij na chwilę oczy i wyobraż sobie, żę ten kod wysyła request do systemu eCommerce i pobiera prawdziwe dane.

Mój kod nie ma wyobraźni dlatego musiałem tam wpisać na sztywno jakieś dane typu:

this.items = [];
this.subtotal = {
  amount: 0,
  currency: "EUR",
};

W każdym razie - adaptery powyżej pobierają kod zsystemu eCommerce. W tym przypadku jest to Magento. Zobaczjak można ten kod wykonać:

const myProductid = "123";
const myCommerce = new CommerceBussinesLogic(
  new MagentoProductAdapter(myProductid),
  new MagentoCartAdapter(),
);

myCommerce.execute();
// imagine that a user clicks add to cart button...
myCommerce.addProductToCart(myProductid);

Konsolawydrukuje coś takiego:

[LOG]: "Imagine that you fetch product data from ecommerce here..."
[LOG]: "Imagine that you fetch cart here ..."
[LOG]: "addToCart clicked"

Brawo, właśnie napisaliśmy kod w architekturze Porty i Adaptery!

To nie wszystko. Teraz wyobraż sobie, że po trzech miesiącach okazało się, że Twój klient jest niestabilny emocjonalnie i stwierdził, że chce teraz się zintegrować z systemem BigCommerce. Logika biznesowa zostaje taka sama. Co robisz?

Dopisujesz adaptery dla BigCommerce:

class BigCommerceProductAdapter implements Product {

    private description: string|null = null;
    public price: Price|null = null;

    constructor(
        public id: string
    ) {
        console.log('Imagine that you fetch product data from BigCommerce here...') // ex. fetch('<ecommerce_api_url>/product/{id}')
        this.price = {
            amount: 199.00,
            currency: 'EUR'
        }
        this.description = 'Lorem ipsum dolor sit amet';
    }

    public getDescription() {
        return this.description;
    }

     public getPrice() {
        return this.price;
    }
}

class BigCommerceCartAdapter implements Cart {
        private items: Array<Product>
        private subtotal: Price

    constructor() {
        console.log('Imagine that you fetch cart from BigCommerce here ...') // ex. fetch('<ecommerce_api_url>/cart')
        this.items = []
        this.subtotal = {
            amount: 0,
            currency: 'EUR'
        }
    }

     public addToCart(productId: string) {
        console.log('addToCart Bigcommerce clicked') // send request to eccommerce here
    }
}

No i wciskasz je w swoje porty:

const bigCommerce = new CommerceBussinesLogic(
  new BigCommerceProductAdapter(myProductid),
  new BigCommerceCartAdapter(),
);

Opdalasz:

bigCommerce.execute();
// imagine that a user clicks add to cart button...
bigCommerce.addProductToCart(myProductid);

Console mówi:

[LOG]: "Imagine that you fetch product data from BigCommerce here..."
[LOG]: "Imagine that you fetch cart from BigCommerce here ..."
[LOG]: "addToCart Bigcommerce clicked"

Infrastruktura

application layer

W architekturze heksagonalnej warstwa prezentacji i warstwa dostępu do danych to integracja z komponentami zewnętrznymi takimi jak:

  • Baza danych

  • UI

  • dostawca zewnętrzny

  • magistrala komunikatów

Driving side

Interfejs aplikacji mobilnej lub kod interfejsu użytkownika aplikacji internetowej (UI) to coś co rozpoczyna interakcję z aplikacją. Dane od użytkownika z UI są popierane przez adapter i wysyłane do logiki biznesowej przez port. Po angielsku mówi się o tym: driving side. Najlepsze tłumaczenie jakie znalazłem w języku polskim to… driving side

Driven side

Bazy danych a nawet serwisy zewnętrzne potrzebują aplikacji żeby działać. W tym przypadku aplikacja wywołuje serwis zewnętrzny lub wysyła request do bazy danych. Następnie adapter implementuje port z którego ma korzystać.


Zasada inwersji zależności

dependency injection

Zasada inwersji zależności (Dependency Inversion Principle) mówi, że moduły wyższego poziomu, które implementują logikę biznesową, nie powinny zależeć od modułów niższego poziomu. Oznacza to, że interfejsy powinny być zdefiniowane przez moduły wyższego poziomu. Dzięki temu system staje się bardziej elastyczny i łatwiejszy w modyfikacji, ponieważ zmiany wprowadzone w jednym module nie wpłyną na pozostałe moduły, jeśli interfejsy pozostaną niezmienione.


traditional layered architecture

W architekturze warstwowej jest dokładnie na odwrót - moduły wyższego poziomu i mówiąc wprost - logika biznesowa - zależą od modułów niższego poziomu.

Dzięki odwróceniu zależności logika biznesowa nie jest pomieszana z szczegółami implementacyjnymi ani z problemami technologicznymi.

Żeby to wszystko miało sens, potrzebujesz jeszcze coś co w architekturze warstwowej jest warstwą usług, a w architekturze Porty i adaptery jest publicznym interfejsem, który opisuje wszystkie operacje systemu.


Architektura heksagonalna – korzyści

business rules are not mixed with implementation details

  • Łatwa skalowalność

  • Rozwój aplikacji

  • Izolacja logiki biznesowej od warstwy technicznej, ułatwiająca wprowadzanie zmian bez wpływu na cały system

Architektura heksagonalna – wady

  • Zwiększona złożoność - architektura heksagonalna dodaje komponenty, które są pośrednikami co wpływa na złożoność

  • debugowanie - aplikacje utworzone przy użyciu wzorca architektury heksagonalnej mogą być trudniejsze do debugowania, ponieważ nie korzystają bezpośrednio z konkretnych implementacji.

  • tłumaczenie - gdy domena biznesowa jest modelowana niezależnie od bazy danych lub innej technologii, tłumaczenie między modelami używanymi do trwałości lub komunikacji i modelem domeny może być niewygodne. Problem ten pogarsza się, gdy modele zasadniczo różnią się od siebie pod względem technicznym i koncepcyjnym.

  • krzywa uczenia się - Architektura heksagonalna różni się od tradycyjnych wzorców architektonicznych, które często narzucane są programistom przez frameworki. Może to być trudniejsze dla nowych programistów ze względu na potrzebę pośrednictwa, tłumaczenia i wzorców projektowych.


Kiedy użyć architektury heksagonalnej?

use hexagonal architecture when application logic is complex

Prawidłowa odpowiedź brzmi zapewne jak zawsze: to zależy.

Jeśli budujesz prostą aplikację CRUD, prawdopodobnie nie warto pchać się w porty i adaptery.

Architektura porty i adaptery nadaje się do do złożonej logiki biznesowej bardziej niż architektura warstwowa.

Gdy używasz różnych systemów zewnętrznych, farameworków, metod odczytu i zapisu danych to wtedy warto rozważyć architekturę heksagonalną.

Możesz też rozważyć wdrożenie tylko niektórych aspektów architektury, aby poprawić separację problemów. Można to zrobić na wiele sposobów i jest to coś, co należy omówić ze swoim zespołem programistów, ponieważ odpowiedź może być inna dla każdego projektu.


Architektura heksagonalna i DDD (Domain Driven Design)

Architektura heksagonalna i Domain Driven Design (DDD) to dwa uzupełniające się podejścia do projektowania oprogramowania, które mają ten sam cel - ułatwienie elastyczności, skalowalności i łatwości konserwacji systemów oprogramowania.

Oba podejścia podkreślają ważność oddzielenia biznesowej logiki od technicznej warstwy i oba korzystają z interfejsów do określenia sposobów, w jakie różne części systemu komunikują się ze sobą.

W DDD chodzi o to, żeby stworzyć jasny i spójny model domeny biznesowej i wykorzystać go do projektowania systemu oprogramowania.

Architektura heksagonalna pozwala na wprowadzenie tego modelu w sposób elastyczny i skalowalny, przez oddzielenie biznesowej logiki od technicznej warstwy i zapewnienie przejrzystych interfejsów komunikacyjnych.

Łącząc zasady architektury heksagonalnej z technikami modelowania DDD, można tworzyć systemy oprogramowania, które są zarówno elastyczne, jak i łatwe w utrzymaniu oraz idealnie dopasowane do potrzeb klienta.

Trzeba jednak pamiętać, że oba podejścia wymagają dokładnego planowania i projektowania oraz że mogą nie być odpowiednie dla wszystkich projektów oprogramowania.


Podsumowanie

Architektura heksagonalna, zwana również porty i adaptery, to wzorzec architektoniczny, który pozwala na oddzielenie logiki biznesowej od warstwy technicznej i ułatwia wprowadzanie zmian bez wpływu na cały system.

W tym wzorcu, logika biznesowa wystawia porty, których implementacja zależy od adapterów napisanych dla konkretnych technologii. W ten sposób, każda warstwa jest oddzielona i może być rozwijana niezależnie.

Architektura heksagonalna jest szczególnie przydatna w złożonych projektach, które wymagają integracji z różnymi systemami zewnętrznymi, takimi jak bazy danych, UI, dostawcy zewnętrzni i magistrala komunikatów.

Wadami tej architektury to zwiększona złożoność, trudności w debugowaniu, tłumaczeniu i krzywa uczenia się.

Warto rozważyć zastosowanie architektury heksagonalnej w projektach, które wymagają oddzielenia warstwy biznesowej od warstwy technicznej i łatwej integracji z różnymi systemami zewnętrznymi.

Można także połączyć zasady architektury heksagonalnej z technikami modelowania DDD, aby stworzyć systemy oprogramowania, które są zarówno elastyczne, jak i łatwe w utrzymaniu oraz idealnie dopasowane do potrzeb klienta.

Źródła

  • Eric Evans’ Domain-Driven Design: Tackling Complexity in the Heart of Software

  • Learning Domain-Driven Design: Aligning Software Architecture and Business Strategy - Vlad Khononov

Subskrybuj mój blog