Architektura heksagonalna w .NET - porty, adaptery i praktyka

Radosław Krajewski 2 września 2026
Sieć połączonych sześciokątów w różnych kolorach, przypominająca schemat **portów i adapterów** w technologii.

Spis treści

Gdy kontroler zaczyna znać szczegóły Entity Framework Core, klienta HTTP i kolejki wiadomości, logika biznesowa szybko traci niezależność. W tym artykule pokazuję, jak działa wzorzec ports and adapters, czym różnią się porty wejściowe od wyjściowych oraz jak przełożyć tę ideę na aplikację w .NET. Dodaję też praktyczny przykład, strukturę projektu, korzyści, ograniczenia i błędy, które najczęściej psują architekturę.

Najważniejsze założenia architektury heksagonalnej w praktyce

  • Rdzeń aplikacji zawiera reguły biznesowe i nie zależy bezpośrednio od frameworków.
  • Port definiuje kontrakt komunikacji, a adapter dostarcza jego konkretną implementację.
  • Porty wejściowe uruchamiają przypadki użycia, natomiast porty wyjściowe opisują potrzeby aplikacji wobec świata zewnętrznego.
  • Ten sam port może mieć kilka adapterów, na przykład bazodanowy, testowy i API.
  • Największą wartością jest odwrócenie zależności, nie sama liczba projektów w solution.

Na czym polega ports and adapters

Architektura heksagonalna, znana też jako ports and adapters, ustawia logikę aplikacji w centrum, a technologie zewnętrzne na obrzeżach. Inspiracją dla tego podejścia był Alistair Cockburn, który chciał ograniczyć sytuacje, w których interfejs użytkownika, baza danych albo konkretny framework zaczynają dyktować kształt logiki biznesowej.

Najważniejsza zasada brzmi prosto: rdzeń nie powinien znać szczegółów infrastruktury. To aplikacja definiuje, czego potrzebuje, a infrastruktura dostosowuje się do tych wymagań. Dzięki temu przypadek użycia może działać tak samo niezależnie od tego, czy dane pochodzą z PostgreSQL, SQL Servera, pliku, pamięci RAM czy zewnętrznego API.

Port jest kontraktem

Port to najczęściej interfejs opisujący sposób komunikacji z rdzeniem. Nie jest jednak regułą, że każda klasa musi otrzymać własny interfejs. Tworzę port wtedy, gdy chcę odizolować decyzję biznesową od technologii albo gdy potrzebuję kilku wymiennych sposobów komunikacji.

Port wejściowy może opisywać operację „złóż zamówienie”. Port wyjściowy może deklarować zapis zamówienia, pobranie kursu walut lub wysłanie powiadomienia. Oba należą do świata aplikacji, choć ich implementacje znajdują się poza nim.

Adapter tłumaczy jeden świat na drugi

Adapter implementuje port i tłumaczy dane między zewnętrznym systemem a aplikacją. Kontroler ASP.NET Core zamienia żądanie HTTP na komendę, adapter EF Core mapuje model domenowy na encję, a klient Azure Service Bus przekształca komunikat aplikacji w format wymagany przez brokera.

To właśnie dlatego adapter nie powinien przejmować reguł biznesowych. Jego zadaniem jest transport, mapowanie i integracja. Jeżeli w kontrolerze pojawia się decyzja typu „czy klient może anulować zamówienie”, część logiki trafiła prawdopodobnie w niewłaściwe miejsce.

Schemat pokazuje, jak różne adaptery i porty łączą aplikację z użytkownikiem, zewnętrzną aplikacją, bazą danych i zewnętrznym API.

Jak przepływa komunikacja między aplikacją a światem zewnętrznym

„Heksagon” nie oznacza sześciu warstw ani sześciu portów. To umowny rysunek, który pokazuje, że aplikacja może komunikować się z wieloma stronami. Liczba portów nie wynika z geometrii, tylko z potrzeb przypadków użycia.

Element Rola Przykład w .NET
Port wejściowy Udostępnia operację aplikacji PlaceOrderHandler
Adapter wejściowy Uruchamia port na podstawie zewnętrznego sygnału Kontroler HTTP, worker, konsument komunikatu
Port wyjściowy Opisuje zależność potrzebną aplikacji OrderRepository
Adapter wyjściowy Realizuje komunikację z infrastrukturą EF Core, REST client, Azure Service Bus

Przepływ wejściowy wygląda zwykle tak: HTTP request → adapter → port wejściowy → przypadek użycia → porty wyjściowe → adaptery infrastruktury. Kierunek zależności jest ważniejszy niż fizyczna lokalizacja plików. Projekt aplikacyjny może znać interfejs repozytorium, ale nie powinien znać klasy używającej konkretnego dostawcy bazy.

W praktyce spotkasz też określenia driving i driven albo inbound i outbound. Pierwsze opisuje stronę, która uruchamia aplikację, drugie stronę, z której aplikacja korzysta. Nazwy są mniej istotne niż zachowanie granicy między decyzjami biznesowymi a szczegółami technicznymi.

Jak zastosować ten wzorzec w aplikacji .NET

Najlepiej zacząć od przypadku użycia, a nie od tworzenia pustych projektów o nazwach Domain, Application i Infrastructure. Załóżmy, że system ma przyjąć zamówienie. Aplikacja potrzebuje zapisać je i opublikować zdarzenie, ale nie powinna wiedzieć, czy zapis odbędzie się przez EF Core, a komunikat trafi do RabbitMQ czy Azure Service Bus.

Porty definiowane przez aplikację

public interface PlaceOrder
{
    Task Execute(
        PlaceOrderCommand command,
        CancellationToken cancellationToken);
}

public interface OrderStore
{
    Task Save(Order order, CancellationToken cancellationToken);
}

public interface OrderEvents
{
    Task Publish(OrderPlaced message, CancellationToken cancellationToken);
}

Interfejsy opisują potrzeby przypadku użycia, a nie możliwości biblioteki. Nie umieszczam tu DbContext, HttpClient ani typów zależnych od brokera. Dzięki temu test aplikacji nie musi uruchamiać kontenera z bazą danych tylko po to, by sprawdzić regułę składania zamówienia.

Przypadek użycia pozostaje technicznie obojętny

public sealed class PlaceOrderHandler : PlaceOrder
{
    private readonly OrderStore store;
    private readonly OrderEvents events;

    public PlaceOrderHandler(OrderStore store, OrderEvents events)
    {
        this.store = store;
        this.events = events;
    }

    public async Task Execute(
        PlaceOrderCommand command,
        CancellationToken cancellationToken)
    {
        var order = Order.Create(command.CustomerId, command.Items);

        await store.Save(order, cancellationToken);
        await events.Publish(
            new OrderPlaced(order.Id),
            cancellationToken);

        return order.Id;
    }
}

Ten kod nie zna ASP.NET Core ani EF Core. Może zostać wywołany przez kontroler, zadanie cykliczne, funkcję Azure albo test. Oczywiście sam interfejs nie gwarantuje dobrej architektury. Jeśli handler zacznie zawierać zapytania SQL, wywołania HTTP i mapowanie każdego formatu zewnętrznego, granica zostanie tylko na papierze.

Adaptery są składane na brzegu systemu

public sealed class EfOrderStore : OrderStore
{
    private readonly OrdersDbContext db;

    public EfOrderStore(OrdersDbContext db)
    {
        this.db = db;
    }

    public async Task Save(
        Order order,
        CancellationToken cancellationToken)
    {
        db.Orders.Add(OrderEntity.FromDomain(order));
        await db.SaveChangesAsync(cancellationToken);
    }
}

Rejestracja zależności odbywa się w composition root, czyli w miejscu, gdzie aplikacja składa wszystkie elementy. W ASP.NET Core może to być konfiguracja usług:

services.AddScoped();
services.AddScoped();
services.AddScoped();

Do testów podmieniam adapter na implementację in-memory albo atrapę. Najlepszy test portu sprawdza wspólny kontrakt, dzięki czemu adapter EF Core i adapter pamięciowy muszą zachowywać się zgodnie z tymi samymi oczekiwaniami.

Kiedy architektura heksagonalna daje realną przewagę

Ten styl ma największy sens tam, gdzie aplikacja będzie żyła dłużej niż jeden prosty endpoint. Dobrze sprawdza się w systemach integrujących kilka kanałów, w produktach rozwijanych przez wiele zespołów oraz w domenach, w których reguły biznesowe są ważniejsze niż sam sposób przechowywania danych.

Sytuacja Przydatność Dlaczego
Prosty CRUD z kilkoma tabelami Niska Dodatkowe porty mogą tylko zwiększyć liczbę plików.
System z API, workerem i komunikatami Wysoka Różne wejścia mogą korzystać z tych samych przypadków użycia.
Integracja z kilkoma dostawcami Wysoka Adapter ogranicza wpływ zmian zewnętrznego API.
Rozbudowane reguły domenowe Wysoka Logika pozostaje testowalna bez infrastruktury.
Jednorazowy skrypt Niska Koszt abstrakcji może przewyższyć jej korzyści.

Nie budowałbym pełnej architektury heksagonalnej dla każdego małego endpointu. Abstrakcja ma sens wtedy, gdy chroni decyzję, która prawdopodobnie będzie zmieniana albo intensywnie testowana. Sam fakt użycia interfejsu nie jest jeszcze dowodem dojrzałości projektu.

W systemie Azure port wyjściowy może ukrywać publikowanie zdarzeń, dostęp do Blob Storage albo komunikację z usługą AI. Gdy dostawca zmieni SDK, aktualizuję adapter i testy integracyjne, zamiast przebudowywać przypadki użycia. To szczególnie cenne przy integracjach, których kontrakty i biblioteki zmieniają się szybciej niż domena biznesowa.

Najczęstsze błędy i rozsądne kompromisy

Porty tworzone dla samej zasady

Interfejs typu IProductService, mający jedną implementację i pozbawiony wyraźnej granicy, często nie daje żadnej wartości. Lepiej zacząć od konkretnej potrzeby, na przykład FindAvailableProducts, niż od generycznej warstwy usługowej. Port powinien chronić kontrakt, a nie zwiększać liczbę abstrakcji.

Infrastruktura przecieka do domeny

Typowym problemem jest zwracanie z portu IQueryable, encji EF Core albo odpowiedzi konkretnego klienta HTTP. Wtedy aplikacja nadal zależy od technologii, tylko zależność została schowana za interfejsem. Własne modele wejściowe i wyjściowe są często mniej efektowne, ale zapewniają stabilniejszą granicę.

Adapter przejmuje logikę biznesową

Adapter powinien mapować, transportować i obsługiwać szczegóły techniczne. Jeżeli decyduje, czy zamówienie spełnia warunki promocji, powstaje ukryta logika biznesowa zależna od konkretnego kanału. Kontroler i konsument wiadomości mogą różnić się formatem, ale powinny uruchamiać ten sam przypadek użycia.

Mylenie architektury z układem folderów

Można mieć katalogi Domain, Application i Infrastructure, a mimo to dopuścić zależności w złym kierunku. Sprawdzam przede wszystkim, kto może importować który projekt, gdzie znajdują się interfejsy i czy test przypadku użycia wymaga bazy danych. Dopiero później oceniam nazwy folderów.

Przeczytaj również: Flyweight w C# - jak współdzielić dane i oszczędzać pamięć

Brak testów adapterów

Testy jednostkowe rdzenia nie wykryją błędnego mapowania kolumn, niezgodności schematu ani problemu z retry w kliencie HTTP. Dlatego adaptery powinny mieć osobne testy integracyjne. Nie muszą pokrywać każdej linijki, ale powinny sprawdzać najważniejsze zachowania, takie jak zapis, odczyt, idempotencja i obsługa błędów.

Jak ocenić, czy granica jest dobrze zaprojektowana

Najprostszy test polega na wyobrażeniu sobie zmiany technologii. Czy mogę zamienić SQL Server na PostgreSQL, REST na komunikaty albo ASP.NET Core na worker bez przepisywania reguł składania zamówienia? Jeśli odpowiedź brzmi „nie”, warto znaleźć zależności, które przeniknęły do rdzenia.

Drugi test jest jeszcze bardziej praktyczny. Uruchom przypadek użycia z adapterami zastępczymi i zobacz, czy test jest szybki, czytelny oraz pozbawiony konfiguracji infrastruktury. Jeżeli do sprawdzenia reguły biznesowej potrzebujesz sieci i bazy danych, granica prawdopodobnie nie została poprowadzona wystarczająco dobrze.

Nie chodzi jednak o to, by każdą klasę otoczyć interfejsem. Dobra implementacja ma kilka wyraźnych portów, małe adaptery i przypadki użycia, które mówią językiem domeny. Taki układ zwykle daje mniej bólu przy zmianach niż klasyczna warstwa usługowa połączona bezpośrednio z ORM-em.

Najlepszy moment na wprowadzenie portów i adapterów

Najbezpieczniej wprowadzać ten wzorzec tam, gdzie już widać koszt konkretnej zależności: drugi kanał wejściowy, wymianę dostawcy, trudne testy albo rozrastającą się logikę w kontrolerach. Nie trzeba przebudowywać całego systemu naraz. Można zacząć od jednego przypadku użycia, wydzielić jego porty, dodać adapter testowy i dopiero potem powtarzać schemat tam, gdzie naprawdę pomaga.

Moja praktyczna reguła jest prosta: chronić domenę przed technologią, ale nie chronić jej przed zdrowym rozsądkiem. Jeśli abstrakcja ułatwia zmianę, testowanie lub integrację, jest warta utrzymania. Jeśli tylko komplikuje prosty kod CRUD, lepiej odłożyć ją na później.

FAQ - Najczęstsze pytania

Największą wartość daje w systemach z wieloma kanałami wejściowymi, takimi jak API, worker i komunikaty, a także przy kilku dostawcach infrastruktury lub rozbudowanych regułach domenowych. W prostym CRUD-zie albo jednorazowym skrypcie dodatkowe porty mogą zwiększyć liczbę plików bez istotnej korzyści.

Port wejściowy udostępnia operację aplikacji, na przykład złożenie zamówienia, i jest uruchamiany przez kontroler, workera lub konsumenta komunikatu. Port wyjściowy opisuje potrzeby aplikacji wobec świata zewnętrznego, takie jak zapis zamówienia, pobranie danych lub publikacja zdarzenia, a jego implementację dostarcza adapter infrastruktury.

Przypadek użycia powinien korzystać z portów, takich jak OrderStore i OrderEvents, bez znajomości DbContext, HttpClient ani typów konkretnego brokera. Adapter EF Core mapuje model domenowy na encję i zapisuje dane, a rejestracja konkretnych implementacji odbywa się w composition root, na przykład w konfiguracji usług ASP.NET Core.

Typowe problemy to tworzenie interfejsów bez realnej granicy, zwracanie przez port IQueryable lub encji EF Core oraz umieszczanie reguł biznesowych w kontrolerach i adapterach. Warto też testować adaptery integracyjnie, sprawdzając między innymi mapowanie, zapis, odczyt, idempotencję i obsługę błędów.

Oceń artykuł

Ocena: 0.00 Liczba głosów: 0

Tagi

architektura heksagonalna
porty
adaptery
testy integracyjne
odwrócenie zależności
Autor Radosław Krajewski
Radosław Krajewski
Nazywam się Radosław Krajewski i od 6 lat zgłębiam tajniki programowania .NET, chmury Azure oraz sztucznej inteligencji. Moja przygoda z tymi technologiami zaczęła się od fascynacji tym, jak złożone problemy można rozwiązywać za pomocą kodu i innowacyjnych narzędzi. Staram się przekazywać tę wiedzę w sposób zrozumiały, dzieląc się swoimi doświadczeniami i spostrzeżeniami na kursdotnet.pl. W moich artykułach skupiam się na praktycznych aspektach, porównuję różne rozwiązania i analizuję najnowsze trendy, aby dostarczyć Wam rzetelne i aktualne informacje, które pomogą Wam rozwijać się w tej dynamicznie zmieniającej się dziedzinie.

Udostępnij artykuł

Napisz komentarz