Architektura Heksagonalna (Ports & Adapters) w aplikacji FastAPI

W wielu projektach backendowych logika biznesowa szybko zaczyna mieszać się z kodem infrastrukturalnym: zapytaniami do bazy danych, obsługą HTTP czy integracjami z zewnętrznymi API. Na początku projektu taki sposób organizacji kodu wydaje się wygodny i szybki w implementacji, jednak wraz z rozwojem systemu zaczyna prowadzić do poważnych problemów. Poszczególne warstwy aplikacji przestają być wyraźnie oddzielone, a zmiana jednego elementu – na przykład sposobu zapisu danych – często wymaga modyfikacji wielu fragmentów kodu.

W rezultacie kod staje się trudny w testowaniu i rozwijaniu. Testy jednostkowe wymagają dostępu do prawdziwej bazy danych lub zewnętrznych usług, przez co stają się wolne i skomplikowane w konfiguracji. Dodatkowo logika biznesowa, która powinna być najważniejszą częścią systemu, zostaje rozproszona w kontrolerach, repozytoriach i serwisach infrastrukturalnych. Taki kod jest również trudniejszy do utrzymania, ponieważ silnie zależy od konkretnych technologii.

Jednym ze sposobów rozwiązania tego problemu jest architektura heksagonalna, znana również jako Ports and Adapters. Koncepcja ta została zaproponowana przez Alistaira Cockburna i ma jeden główny cel: oddzielić logikę biznesową od technologii infrastrukturalnych. W tym podejściu aplikacja traktowana jest jak centralny rdzeń systemu, który komunikuje się ze światem zewnętrznym za pomocą jasno zdefiniowanych portów, natomiast konkretne implementacje tych portów – czyli adaptery – odpowiadają za integrację z bazami danych, interfejsami HTTP, kolejkami wiadomości czy zewnętrznymi usługami.

Dzięki takiemu podejściu logika domenowa pozostaje niezależna od szczegółów technologicznych. Oznacza to, że można ją łatwo testować, rozwijać oraz wykorzystywać w różnych kontekstach – niezależnie od tego, czy aplikacja komunikuje się przez REST API, CLI czy system kolejkowy. Architektura heksagonalna promuje również wyraźne granice pomiędzy domeną a infrastrukturą, co sprzyja tworzeniu bardziej modularnych, czytelnych i długowiecznych systemów.

Problem tradycyjnej architektury

Tworząc aplikacje backendowe bardzo często wykorzystuje się klasyczny podział na warstwy, który można uprościć do następującego schematu:

Controller -> Service -> Repository -> Database

Controller przyjmuje żądanie od użytkownika i odpowiada za warstwę komunikacji zewnętrznej – najczęściej HTTP. Odbiera dane z requestu, wykonuje podstawową walidację oraz przekazuje żądanie dalej do warstwy logiki biznesowej.

Kolejnym elementem jest Service, czyli miejsce, w którym znajduje się właściwa logika biznesowa aplikacji. To tutaj realizowane są operacje na danych, podejmowane są decyzje biznesowe oraz koordynowane są różne procesy w systemie. Service nie powinien zajmować się szczegółami komunikacji z bazą danych ani obsługą protokołów sieciowych – jego zadaniem jest jedynie realizacja logiki domenowej.

Jeżeli logika biznesowa potrzebuje danych, Service korzysta z warstwy Repository. Repozytorium odpowiada za komunikację z bazą danych i ukrywa szczegóły związane z zapytaniami. W praktyce oznacza to wykonywanie zapytań, na przykład SQL, mapowanie wyników na obiekty domenowe oraz zapisywanie zmian w bazie danych.

Na końcu tego łańcucha znajduje się Database, czyli system zarządzania bazą danych, w którym przechowywane są informacje wykorzystywane przez aplikację.

W efekcie przepływ danych wygląda następująco:

  1. Controller odbiera żądanie użytkownika.
  2. Controller wywołuje odpowiednią metodę w Service.
  3. Service wykonuje logikę biznesową.
  4. W razie potrzeby Service korzysta z Repository, aby pobrać lub zapisać dane.
  5. Repository komunikuje się z Database.

Taki model architektury jest bardzo popularny, ponieważ wprowadza podstawowy podział odpowiedzialności pomiędzy poszczególne elementy aplikacji. Jednak wraz z rozwojem projektu często pojawiają się problemy związane z silnym powiązaniem logiki biznesowej z konkretną infrastrukturą, co prowadzi do trudności w testowaniu i rozbudowie systemu. Właśnie w tym miejscu alternatywą może być architektura heksagonalna.

Architektura heksagonalna odwraca ten sposób myślenia

Najważniejsza zasada brzmi:

logika biznesowa powinna być w centrum systemu i nie powinna zależeć od infrastruktury.

W tym modelu cała domena – czyli kluczowa logika biznesowa – stanowi „rdzeń” aplikacji. Nie wie nic o tym, jak dane są pobierane z bazy, jak przychodzą żądania HTTP ani jak wysyłane są komunikaty do zewnętrznych usług. Wszystko, czego domena potrzebuje, określone jest przez porty, czyli abstrakcyjne interfejsy definiujące, jakie operacje są możliwe.

Konkretną implementacją portów zajmują się adaptery – mosty między światem zewnętrznym a logiką domenową. Adaptery mogą odpowiadać za:

  • odczyt i zapis danych w bazie (Repository adaptery),
  • obsługę żądań HTTP (Controller adaptery),
  • integracje z zewnętrznymi systemami (API adaptery, kolejki wiadomości itp.).

Dzięki takiemu podejściu zależności biegną „do środka” – infrastruktura zależy od logiki biznesowej, a nie odwrotnie. Oznacza to, że rdzeń aplikacji pozostaje niezależny, łatwy do testowania i możliwy do ponownego wykorzystania w różnych kontekstach, np. w aplikacjach webowych, CLI czy systemach batchowych.

HTTP -> Adapter -> Port -> Domain -> Port -> Adapter -> Database
  1. Żądanie użytkownika (HTTP)
    To punkt wejścia do aplikacji. Może to być żądanie REST API, GraphQL, WebSocket, czy nawet CLI. W tradycyjnej architekturze byłby to Controller, ale w heksagonalnej Controller staje się adapterem.
  2. Adapter wejściowy
    Adapter wejściowy odpowiada za odbiór żądania i dopasowanie go do formatu zrozumiałego dla domeny.

    Przykłady:
    HTTP Adapter: mapuje JSON z requestu na obiekty domenowe.
    CLI Adapter: mapuje parametry linii poleceń na strukturę danych dla domeny.

    Adapter ten nie zna logiki biznesowej ani szczegółów implementacji – jego zadanie to „przekazać” dane do portu.
  3. Port wejściowy
    Port to abstrakcyjny interfejs, który określa, jakie operacje domena może wykonać.

    Przykład: CreateOrder, CalculateDiscount, TransferFunds.

    Port definiuje metodę, np. execute(command), którą domena może wykonać.
    Adapter wywołuje port, a domena realizuje logikę biznesową niezależnie od tego, skąd przyszło żądanie.
  4. Domena
    Tutaj znajduje się logika biznesowa – modele domenowe, serwisy domenowe, reguły biznesowe. Nie wie nic o HTTP, bazach danych ani zewnętrznych API. Wykonuje operacje zgodnie z regułami biznesowymi. Jeżeli potrzebuje danych z zewnątrz lub musi coś zapisać, komunikuje się przez porty wyjściowe.
  5. Port wyjściowy
    Port wyjściowy definiuje, jakie działania mogą zostać wykonane przez system zewnętrzny.

    Przykłady:
    OrderRepository z metodami save(order) i findById(id)
    PaymentGateway z metodą charge(amount)

    Domena wywołuje port, nie wiedząc, jak jest on zrealizowany.
  6. Adapter wyjściowy
    Adapter wyjściowy to konkretna implementacja portu wyjściowego.
    Przykład dla bazy danych: adapter wykonuje zapytania SQL lub korzysta z ORM i mapuje obiekty domenowe na rekordy w bazie.
    Przykład dla API zewnętrznego: adapter wykonuje request HTTP i mapuje odpowiedź na obiekty domenowe.
  7. Database
    To końcowy punkt ścieżki, np. relacyjna baza danych, magazyn dokumentów, kolejka wiadomości czy system płatności. Adapter odpowiada za komunikację z tym systemem, ale rdzeń domeny w ogóle nie zna jego szczegółów.

Jaki jest efekt?
Logika biznesowa jest całkowicie odizolowana od technologii: może być testowana niezależnie, łatwo wymienić bazę danych, API czy interfejs HTTP, a rdzeń pozostaje czysty i stabilny.

Zbudujmy prostą aplikację w FastAPI w architekturze heksagonalnej.

W pierwszym kroku zbudujmy sobie strukturę katalogową naszego projektu.
W katalogu app stwórzmy kilka podkatalogów:

  1. api – adaptery wejściowe HTTP
  2. application – logika aplikacji
  3. domain – warstwa domenowa / porty
  4. infrastructure – adaptery wyjściowe (bazy danych, api zewnętrznych dostawców itp)

Stwórzmy też plik main.py który będzie naszym punktem startowym, w pliku tym będziemy integrować całą naszą architekturę.

Tworzymy środowisko wirtualne, aktywujemy je oraz instalujemy pakiet FastAPI

python3 -m venv .venv
source .venv/bin/activate
pip install "fastapi[standard]" sqlalchemy psycopg2-binary

Jeżeli nie czujesz się pewnie na tym etapie to zapraszam do zapoznania się z moim wcześniejszym wpisem FastAPI – szybki start dla backend developera

Projekt, który chciałbym tutaj przedstawić, jest celowo bardzo prosty. Jego zadaniem jest pokazanie idei architektury heksagonalnej na minimalnym, łatwym do zrozumienia przykładzie. Będzie to niewielki system pozwalający na zapis użytkownika do bazy danych – funkcjonalność, która występuje praktycznie w każdym współczesnym systemie informatycznym.

Choć sam problem jest prosty, bardzo dobrze nadaje się do zaprezentowania kluczowych elementów architektury Ports and Adapters. Nawet tak podstawowa operacja jak utworzenie użytkownika wymaga bowiem przejścia przez kilka warstw systemu: odebrania żądania HTTP, przetworzenia danych w logice biznesowej oraz zapisania ich w bazie danych. Dzięki temu możemy w praktyce zobaczyć, w jaki sposób porty i adaptery oddzielają logikę domenową od infrastruktury.

W kolejnych krokach zbudujemy więc małą aplikację w FastAPI, w której:

  • endpoint HTTP będzie pełnił rolę adaptera wejściowego,
  • domena będzie zawierała logikę odpowiedzialną za tworzenie użytkownika,
  • repozytorium bazy danych zostanie zaimplementowane jako adapter wyjściowy,
  • komunikacja pomiędzy elementami będzie odbywała się przez jasno zdefiniowane porty.

Taki przykład pozwoli zobaczyć, jak w praktyce wygląda zastosowanie architektury heksagonalnej nawet w bardzo małym projekcie – a także dlaczego takie podejście ułatwia testowanie, rozwój oraz zmianę technologii w przyszłości.

W pierwszym kroku stworzymy encję użytkownika, która będzie należeć do warstwy domenowej aplikacji. W architekturze heksagonalnej to właśnie w domenie znajdują się modele reprezentujące kluczowe elementy systemu oraz reguły biznesowe, które nimi rządzą.

Przejdź do katalogu domain i utwórz w nim plik user.py. W tym pliku zdefiniujemy klasę reprezentującą użytkownika w naszym systemie. Encja domenowa powinna być możliwie niezależna od infrastruktury – nie powinna zawierać zależności od frameworków webowych, bibliotek do obsługi baz danych ani innych technologii zewnętrznych.

Nasza encja użytkownika będzie przechowywać podstawowe informacje, takie jak identyfikator oraz adres e-mail. Przykładowa implementacja może wyglądać następująco:

from dataclasses import dataclass

@dataclass
class User:
    id: int
    email: str
    password: str

W tym przykładzie korzystamy z dekoratora dataclass, który upraszcza tworzenie klas przechowujących dane. Klasa User reprezentuje model domenowy i opisuje strukturę użytkownika w naszym systemie.

Warto zwrócić uwagę, że ta klasa nie zawiera żadnej logiki związanej z bazą danych, HTTP ani frameworkiem aplikacyjnym. Dzięki temu pozostaje czystą częścią domeny, którą można łatwo testować i wykorzystywać niezależnie od technologii użytych w pozostałych częściach systemu.

W kolejnym kroku zdefiniujemy port repozytorium użytkownika, który określi, w jaki sposób domena może zapisywać i pobierać użytkowników z systemu przechowywania danych.

plik ports.py

from abc import ABC, abstractmethod
from app.domain.user import User

class UserRepository(ABC):
    
    @abstractmethod
    def store(self, user: User) -> None:
        pass
    
    @abstractmethod
    def get_by_email(self, email: str) -> User | None:
        pass
    

Port określa co system może zrobić, ale nie mówi jak dokładnie jest to realizowane. Dzięki temu domena może korzystać z repozytorium użytkowników, nie wiedząc, czy dane są zapisywane w bazie SQL, bazie NoSQL, pliku czy w pamięci.

Klasa bazowa ABC

Moduł abc (Abstract Base Classes) pozwala tworzyć abstrakcyjne klasy bazowe w Pythonie. Klasa UserRepository dziedziczy po ABC, co oznacza, że jest interfejsem, a nie gotową implementacją.

Nie możemy utworzyć jej instancji bez zaimplementowania metod abstrakcyjnych.


Przejdź teraz do katalogu application i stwórz tam plik register_user.py

from app.domain.user import User
from app.domain.ports import UserRepository

class RegisterUser:
    
    def __init__(self, repo: UserRepository):
        self.repo = repo
        
    def execute(self, email: str, password: str) -> User:
        check_exists = self.repo.get_by_email(email)
        if check_exists:
            raise ValueError("Uytkownik istnieje w systemie")
        
        user = User(
            id=1, email=email, password=password
        )
        
        self.repo.store(user)
        return user
    

Plik ten zawiera logikę biznesową odpowiedzialną za rejestrację użytkownika. W architekturze heksagonalnej taki element często nazywany jest use case’em lub serwisem aplikacyjnym – reprezentuje konkretną operację biznesową, którą system może wykonać.

Zwróć uwagę na ten fragment

user = User(
    id=1, email=email, password=password
)

W prawdziwym systemie identyfikator (id) byłby zazwyczaj generowany automatycznie (np. UUID lub przez bazę danych), jednak tutaj dla uproszczenia użyta jest stała wartość.

Następnie, w katalogu infrastructure utwórz plik db.py

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

DATABASE_URL = "postgresql+psycopg2://user:password@localhost:5432/mydb"

engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autoflush=False, bind=engine)

def get_db_session():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

Ten fragment kodu odpowiada za konfigurację połączenia z bazą danych oraz zarządzanie sesją bazy danych przy użyciu biblioteki SQLAlchemy. Jest to element warstwy infrastrukturalnej aplikacji – jego zadaniem jest przygotowanie mechanizmu komunikacji z bazą danych, z którego będą korzystać adaptery repozytoriów.

Dodaj kolejny plik, o nazwie user_orm.py

from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import declarative_base

Base = declarative_base*()

class UserORM(Base):
    __tablename__ = "users"
    
    id = Column(Integer, primary_key=True, index=True)
    email = Column(String, unique=True, nullable=False, index=True)
    password = Column(String, nullable=False)

Ten fragment kodu definiuje model ORM użytkownika, który odpowiada za mapowanie obiektów Pythona na rekordy w tabeli bazy danych. Jest to element warstwy infrastrukturalnej, ponieważ zawiera szczegóły związane z bazą danych i biblioteką SQLAlchemy.

Model ORM różni się od encji domenowej – jego zadaniem jest jedynie reprezentacja struktury tabeli w bazie danych.

kolejnym krokiem będzie utworzenie naszego repozytorium, dalej w katalogu infrastructure utwórz plik repository.py

from sqlalchemy.orm import Session
from app.domain.user import User
from app.domain.ports import UserRepository
from infrastructure.user_orm import UserORM

class SQLAlchemyUserRepository(UserRepository):
    
    def __init__(self, db: Session):
        self.db = db
        
    def store(self, user) -> None:
        user_orm = UserORM(
            id=user.id,
            email=user.email,
            password=user.password
        )
        
        self.db.add(user_orm)
        self.db.commit()
        self.db.refresh(user_orm)
        user.id = user_orm.id
        
        
    def get_by_email(self, email) -> User | None:
        user_orm = self.db.query(UserORM).filter_by(email=email).first()
        if user_orm:
            return User(
                id=user_orm.id,
                email=user_orm.email,
                password=user_orm.password
            )
        
        return None
    

Ten plik zawiera adapter repozytorium, który implementuje port UserRepository i odpowiada za komunikację z bazą danych przy użyciu SQLAlchemy. W architekturze heksagonalnej jest to klasyczny adapter wyjściowy (output adapter) – jego zadaniem jest połączenie logiki domenowej z konkretną technologią infrastrukturalną.

Adapter tłumaczy obiekty domenowe (User) na modele ORM (UserORM) i odwrotnie.

Przejdź teraz do katalogu api i utwórz tam plik user_controller.py

from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session

from app.application.register_user import RegisterUser
from app.infrastructure.repository import SQLAlchemyUserRepository
from app.infrastructure.db import get_db_session

router = APIRouter()

class RegisterRequest(BaseModel):
    email: str
    password: str
    
    
def get_use_case(db: Session = Depends(get_db_session)):
    repo = SQLAlchemyUserRepository(db)
    return RegisterUser(repo)

@router.post("/")
def register_user(
    request: RegisterRequest,
    use_case: RegisterUser = Depends(get_use_case)
):
    user = use_case.execute(request.email, request.password)
    return {
        "id" : user.id,
        "email" : user.email
    }
    

Ten plik implementuje adapter wejściowy HTTP przy użyciu frameworka FastAPI. Jego zadaniem jest przyjęcie żądania od użytkownika, przekształcenie danych wejściowych oraz wywołanie odpowiedniego use case’a z warstwy aplikacyjnej.

W architekturze heksagonalnej ten plik znajduje się na zewnętrznej warstwie systemu i pełni rolę adaptera wejściowego (input adapter).

Na koniec, otwórz jeszcze plik main.py

from fastapi import FastAPI

from app.api.user_controller import router as user_router
from app.infrastructure.user_orm import Base
from app.infrastructure.db import engine

Base.metadata.create_all(bind=engine)

app = FastAPI(title="Przykład aplikacji w architekturze hexagonalnej")

app.include_router(user_router, prefix="/users", tags=["Users"])

Ten plik stanowi punkt startowy aplikacji zbudowanej przy użyciu frameworka FastAPI. Odpowiada za konfigurację aplikacji, inicjalizację bazy danych oraz rejestrację routerów API.

Można go traktować jako warstwę kompozycji aplikacji – miejsce, w którym wszystkie elementy systemu zostają połączone.

W tym artykule zbudowaliśmy prostą aplikację do rejestracji użytkownika, wykorzystując architekturę heksagonalną (Ports and Adapters). Na przykładzie niewielkiego projektu można było zobaczyć, w jaki sposób oddzielić logikę biznesową od warstw infrastrukturalnych takich jak HTTP czy baza danych.

Najważniejszą ideą tego podejścia jest umieszczenie logiki domenowej w centrum aplikacji, a wszystkie elementy technologiczne – framework webowy, ORM czy system bazy danych – traktowanie jako zewnętrzne adaptery. Komunikacja między tymi warstwami odbywa się przez jasno zdefiniowane porty, czyli interfejsy określające kontrakt między domeną a infrastrukturą.

Dzięki temu aplikacja staje się bardziej modularna. Warstwa domenowa nie wie nic o frameworku FastAPI ani o bibliotece SQLAlchemy, co pozwala łatwo zmieniać technologię infrastrukturalną bez ingerencji w logikę biznesową.

Zalety architektury haksagonalnej

1. Wyraźne oddzielenie odpowiedzialności
Logika biznesowa jest oddzielona od infrastruktury, co sprawia, że kod jest bardziej czytelny i łatwiejszy w utrzymaniu.

2. Łatwiejsze testowanie
Ponieważ domena nie zależy od bazy danych ani frameworków, można ją testować przy użyciu prostych implementacji portów (np. repozytoriów w pamięci).

3. Niezależność od technologii
Możliwe jest stosunkowo łatwe zastąpienie jednego adaptera innym, np. zmiana bazy danych lub frameworka HTTP bez modyfikowania logiki domenowej.

4. Lepsza modularność systemu
Aplikacja składa się z jasno określonych komponentów: domeny, portów oraz adapterów.

Wady zarchitektury heksagonalnej

1. Większa złożoność struktury projektu
W małych aplikacjach liczba warstw, klas i plików może wydawać się przesadzona w stosunku do prostoty problemu.

2. Więcej kodu „infrastrukturalnego”
Pojawia się konieczność tworzenia portów, adapterów i mapowania modeli domenowych na modele bazy danych.

3. Wyższy próg wejścia dla początkujących
Zrozumienie koncepcji portów, adapterów oraz odwrócenia zależności wymaga pewnego doświadczenia w projektowaniu architektury aplikacji.

Kiedy warto stosować architekturę heksagonalną?

Architektura heksagonalna najlepiej sprawdza się w projektach:

  • które mają rozbudowaną logikę biznesową,
  • które będą rozwijane przez dłuższy czas,
  • w których istnieje potrzeba łatwego testowania domeny,
  • lub gdzie infrastruktura może się zmieniać w trakcie życia systemu.

W bardzo małych aplikacjach lub prototypach może być to podejście zbyt rozbudowane, jednak w średnich i dużych projektach często znacząco poprawia czytelność i elastyczność systemu.

Architektura heksagonalna nie jest jedynym sposobem projektowania aplikacji backendowych, ale stanowi bardzo wartościowe narzędzie w arsenale developera. Pozwala myśleć o systemie w kategoriach logiki biznesowej i kontraktów, zamiast konkretnych technologii – a to z kolei prowadzi do bardziej stabilnych i łatwiejszych w utrzymaniu projektów.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Wymagane pola są oznaczone *