Architektura hexagonalna w .NET - porty, adaptery i praktyka

Radosław Krajewski 27 maja 2026
Schemat warstwowy: serce "Domain", otoczone "Application", a na zewnątrz "Infrastructure". To przykład architektury hexagonalnej.

Spis treści

Gdy logika biznesowa zaczyna zależeć od Entity Framework, kontrolerów HTTP i zewnętrznych API, każda zmiana technologii robi się niepotrzebnie ryzykowna. Architektura hexagonalna pomaga odseparować reguły biznesowe od infrastruktury, a przy okazji ułatwia testowanie i rozwój aplikacji .NET. Pokażę, jak działają porty i adaptery, jak przełożyć ten model na kod oraz kiedy jego zastosowanie ma sens.

Najważniejsze założenia podejścia portów i adapterów

  • Rdzeń aplikacji zawiera reguły biznesowe i nie zna szczegółów infrastruktury.
  • Porty definiują kontrakty komunikacji między logiką aplikacji a światem zewnętrznym.
  • Adaptery podłączają HTTP, bazę danych, kolejki, pliki lub zewnętrzne API.
  • Zależności wskazują do środka, czyli implementacje infrastruktury zależą od interfejsów należących do aplikacji.
  • Testy jednostkowe mogą korzystać z adapterów zastępczych bez uruchamiania bazy danych czy serwera.

Schemat architektury hexagonalnej z warstwami: rdzeń aplikacji, domena, usługi aplikacyjne. Połączenia z adapterami napędzającymi i napędzanymi.

Na czym polega podejście portów i adapterów

Architektura hexagonalna, znana też jako Ports and Adapters, traktuje aplikację jak niezależny rdzeń otoczony punktami komunikacji. Sześciokąt jest tylko umownym rysunkiem. Nie oznacza sześciu warstw ani sześciu konkretnych elementów, lecz sugeruje, że aplikacja może mieć wiele wejść i wyjść.

Najważniejsza decyzja brzmi prosto: logika biznesowa nie powinna zależeć od technologii użytej do jej uruchomienia. Kod domeny nie musi wiedzieć, czy dane trafiają z kontrolera REST, komunikatu z Azure Service Bus, zadania cyklicznego czy testu jednostkowego.

Rdzeń aplikacji

W środku znajdują się przypadki użycia, reguły biznesowe, obiekty domenowe oraz kontrakty potrzebne do współpracy z otoczeniem. Przykładowo system rezerwacji może mieć operację „zarezerwuj pokój”, ale nie powinien w tej samej klasie decydować, jak wygląda zapytanie SQL albo odpowiedź HTTP.

W praktyce rdzeń może zostać podzielony na dwie części. Domena pilnuje reguł biznesowych, a warstwa aplikacyjna koordynuje przypadki użycia i wywołuje potrzebne porty. Granica między nimi zależy od projektu, ale żadna z nich nie powinna importować klas infrastruktury.

Porty i adaptery

Port jest interfejsem opisującym sposób współpracy z aplikacją. Port wejściowy mówi, co aplikacja potrafi zrobić, natomiast port wyjściowy określa, czego potrzebuje od zewnętrznego świata. Adapter tłumaczy konkretną technologię na język portu.

Element Rola Przykład w .NET
Port wejściowy Uruchamia przypadek użycia ICreateOrder
Adapter wejściowy Przyjmuje żądanie i wywołuje port Kontroler ASP.NET Core
Port wyjściowy Opisuje potrzebę rdzenia IOrderRepository
Adapter wyjściowy Realizuje port przy użyciu technologii Repozytorium EF Core

To rozróżnienie jest ważniejsze niż nazwy folderów. W dobrze zaprojektowanym systemie kontroler może zostać zastąpiony konsumentem kolejki, a SQL Server atrapą in-memory, bez przepisywania reguł biznesowych.

Jak wygląda przepływ żądania w aplikacji .NET

Załóżmy, że użytkownik składa zamówienie. Kontroler odbiera JSON, tworzy komendę i wywołuje port wejściowy. Przypadek użycia sprawdza reguły, korzysta z portu repozytorium i publikuje zdarzenie przez kolejny port, ale nie wie, czy pod spodem działa EF Core, Kafka czy prosty stub testowy.

Port wejściowy może wyglądać następująco:

public interface IPlaceOrder
{
    Task ExecuteAsync(
        PlaceOrderCommand command,
        CancellationToken cancellationToken);
}

Port wyjściowy definiuje potrzebną operację, a nie sposób jej wykonania:

public interface IOrderRepository
{
    Task SaveAsync(
        Order order,
        CancellationToken cancellationToken);
}

public interface IPaymentGateway
{
    Task AuthorizeAsync(
        Money amount,
        CancellationToken cancellationToken);
}

Serwis aplikacyjny zależy wyłącznie od tych kontraktów. Dzięki temu jego test nie wymaga uruchamiania kontenera z bazą danych ani wysyłania prawdziwej płatności.

public sealed class PlaceOrder : IPlaceOrder
{
    private readonly IOrderRepository orders;
    private readonly IPaymentGateway payments;

    public PlaceOrder(
        IOrderRepository orders,
        IPaymentGateway payments)
    {
        this.orders = orders;
        this.payments = payments;
    }

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

        await payments.AuthorizeAsync(
            order.Total,
            cancellationToken);

        await orders.SaveAsync(order, cancellationToken);

        return order.Id;
    }
}

Implementacja EF Core znajduje się poza rdzeniem:

public sealed class EfOrderRepository : IOrderRepository
{
    private readonly OrdersDbContext db;

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

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

Rejestracja zależności odbywa się w punkcie składania aplikacji, czyli w composition root. To miejsce łączy abstrakcje z konkretnymi implementacjami:

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

Sam mechanizm wstrzykiwania zależności nie tworzy jeszcze architektury hexagonalnej. Jeśli interfejsy należą do projektu infrastruktury, a logika biznesowa musi znać EF Core, mamy tylko dependency injection użyte w klasycznym układzie. O kierunku zależności decyduje własność kontraktów, nie obecność kontenera IoC.

Co realnie zyskuje zespół

Największą korzyścią jest łatwiejsze testowanie zachowania aplikacji. Test przypadku użycia może używać fałszywego repozytorium i bramki płatniczej, więc sprawdza reguły biznesowe bez kosztu i niestabilności infrastruktury.

Drugą korzyścią jest odporność na zmianę technologii. Migracja z SQL Servera do PostgreSQL, zamiana dostawcy płatności albo dodanie interfejsu komunikatów nie musi przenikać do domeny. Adapter zmienia sposób komunikacji, ale port pozostaje stabilnym kontraktem.

To podejście dobrze działa również wtedy, gdy jedna funkcja ma kilka kanałów wejściowych. Ten sam przypadek użycia może być wywołany przez REST API, worker, test integracyjny i zadanie uruchamiane przez harmonogram. Każdy kanał jest innym adapterem sterującym.

Testowalność nie oznacza automatycznie dobrego projektu

Łatwo przesadzić z abstrakcjami. Tworzenie osobnego interfejsu dla każdej klasy, metody i zapytania daje dużo plików, ale niekoniecznie lepszą architekturę. Port powinien chronić istotną granicę, a nie być mechanicznym opakowaniem każdej implementacji.

Nie wszystko trzeba mockować. Reguły domenowe najlepiej testować bezpośrednio, przypadki użycia z prostymi atrapami, a adaptery infrastrukturalne osobno testami integracyjnymi. W przeciwnym razie można uzyskać testy, które potwierdzają jedynie zgodność kodu z własnymi mockami.

Jak zaprojektować taki układ krok po kroku

Zaczynam od przypadków użycia, nie od tabel i kontrolerów. Spisuję operacje, które system ma wykonywać, oraz reguły, które muszą być spełnione niezależnie od kanału komunikacji.

  1. Wybierz granicę biznesową, na przykład składanie zamówienia albo naliczanie abonamentu.
  2. Zdefiniuj porty wejściowe opisujące operacje dostępne dla świata zewnętrznego.
  3. Zdefiniuj porty wyjściowe tylko dla zależności potrzebnych przez przypadki użycia.
  4. Zaimplementuj rdzeń bez odwołań do ASP.NET Core, EF Core, Azure SDK i bibliotek infrastrukturalnych.
  5. Dodaj adaptery dla HTTP, bazy danych, kolejek, plików lub zewnętrznych usług.
  6. Połącz zależności w composition root i sprawdź kierunek referencji między projektami.

Przykładowy podział rozwiązania może wyglądać tak:

src/
  Shop.Domain/
  Shop.Application/
  Shop.Adapters.In/
  Shop.Adapters.Out/
  Shop.Api/
tests/
  Shop.UnitTests/
  Shop.IntegrationTests/

Nie traktowałbym tego układu jako obowiązkowej recepty. W mniejszym systemie kilka projektów może tylko utrudnić pracę. Często lepiej zacząć od czytelnych granic w jednym projekcie, a rozdzielić assembly dopiero wtedy, gdy zależności faktycznie zaczynają się mieszać.

Przeczytaj również: Mediator w C# bez chaosu. Handlery, pipeline i dobre praktyki

Jak rozpoznać, że granica jest dobrze ustawiona

Dobry port opisuje potrzebę biznesową, a nie bibliotekę. IOrderRepository może być uzasadniony, jeśli przypadki użycia pracują na zamówieniach, ale interfejs nazwany IEfCoreOrderRepository zdradza szczegół implementacyjny i przenosi infrastrukturę do środka.

Pomaga też prosty test architektoniczny. Projekt domenowy nie powinien referować do adaptera, projekt aplikacyjny nie powinien importować kontrolera, a adapter może zależeć od portu, który ma zaimplementować. Jeśli strzałki zależności wskazują na zewnątrz, granica prawdopodobnie została odwrócona.

Czym różni się od architektury warstwowej i Clean Architecture

Te podejścia często wyglądają podobnie, ponieważ wszystkie próbują chronić logikę biznesową przed infrastrukturą. Różnią się jednak akcentem. Model portów i adapterów koncentruje się na granicy komunikacji oraz odwróceniu zależności, a Clean Architecture mocniej eksponuje koncentryczne warstwy i reguły zależności.

Podejście Główny punkt ciężkości Typowe ryzyko
Warstwowe Podział na prezentację, logikę i dane Warstwa biznesowa zaczyna zależeć od infrastruktury
Porty i adaptery Kontrakty na granicy rdzenia i otoczenia Nadmierna liczba interfejsów
Clean Architecture Koncentryczne warstwy i zależności skierowane do środka Formalny podział bez realnych granic
Onion Architecture Domena w centrum i infrastruktura na obrzeżach Mylenie warstw technicznych z granicami biznesowymi

W aplikacji .NET można połączyć te idee. Nie widzę problemu w tym, aby projekt był jednocześnie inspirowany Clean Architecture i portami oraz adapterami. Ważniejsze od etykiety jest to, czy reguły biznesowe da się rozwijać bez przepisywania ich przy każdej zmianie frameworka.

Kiedy ten wzorzec nie będzie dobrym wyborem

Mała aplikacja CRUD z kilkoma ekranami może nie potrzebować rozbudowanego rdzenia, wielu portów i osobnych adapterów. Jeśli system ma prostą logikę, a jego głównym zadaniem jest odczyt i zapis danych, pełna separacja może zwiększyć koszt utrzymania bardziej, niż poprawi jakość.

Uważałbym też na projekty, w których zespół traktuje wzorzec jako sztywny schemat folderów. Można mieć katalogi nazwane Domain, Application i Infrastructure, a mimo to umieszczać zapytania SQL w serwisach biznesowych. Nazwa projektu nie zastąpi właściwego kierunku zależności.

  • Nie twórz portu tylko dlatego, że każda klasa „powinna mieć interfejs”.
  • Nie umieszczaj modeli bazy danych w domenie, jeśli reprezentują szczegóły ORM.
  • Nie zwracaj encji domenowych bezpośrednio z kontrolera, gdy API ma własny kontrakt.
  • Nie ukrywaj wszystkich błędów za ogólnym wyjątkiem typu ApplicationException.
  • Nie zastępuj testów integracyjnych samymi mockami.

Najwięcej wartości ten model daje w systemach, które mają kilka sposobów wejścia, rozbudowane reguły biznesowe albo przewidywaną wymianę technologii. Przy prostym rozwiązaniu rozsądniejsza może być mniejsza liczba granic i stopniowe wydzielanie portów wraz ze wzrostem złożoności.

Najlepszy test tego podejścia zaczyna się od zmiany

Żeby ocenić projekt, wyobrażam sobie trzy zmiany: nowy kanał wejściowy, inną bazę danych i zastąpienie zewnętrznego dostawcy. Jeżeli każda z nich wymaga modyfikacji reguł biznesowych, granice są prawdopodobnie zbyt słabe. Jeżeli zmieniają się głównie adaptery i konfiguracja, kierunek jest właściwy.

Porty i adaptery nie są sposobem na wyeliminowanie całej złożoności. Są raczej świadomą decyzją, by złożoność techniczną trzymać na obrzeżach, a najważniejsze reguły chronić w środku. W aplikacjach .NET to często daje bardzo praktyczny efekt: czystsze testy, bezpieczniejsze zmiany i mniejszą zależność od aktualnego zestawu bibliotek.

FAQ - Najczęstsze pytania

Rdzeń aplikacji zawiera reguły biznesowe i przypadki użycia, ale nie zna ASP.NET Core, EF Core ani zewnętrznych API. Porty definiują kontrakty, a adaptery tłumaczą HTTP, bazę danych, kolejki lub płatności na język tych kontraktów. Zależności wskazują do środka, więc infrastruktura implementuje interfejsy należące do aplikacji.

Kontroler odbiera JSON, tworzy komendę i wywołuje port wejściowy, na przykład IPlaceOrder. Przypadek użycia sprawdza reguły, korzysta z portów wyjściowych, takich jak IOrderRepository i IPaymentGateway, a konkretne implementacje mogą używać EF Core oraz dostawcy płatności. Powiązanie interfejsów z implementacjami następuje w composition root.

Mała aplikacja CRUD z prostą logiką może nie potrzebować wielu portów i osobnych adapterów, ponieważ dodatkowe granice zwiększą koszt utrzymania. Warto ją rozważyć przede wszystkim przy rozbudowanych regułach biznesowych, wielu kanałach wejścia lub planowanej wymianie technologii. Nie należy tworzyć interfejsu dla każdej klasy bez wyraźnej granicy, którą ma chronić.

Architektura warstwowa koncentruje się na podziale na prezentację, logikę i dane, ale warstwa biznesowa może z czasem zależeć od infrastruktury. Porty i adaptery skupiają się na kontraktach na granicy rdzenia i otoczenia oraz na odwróceniu zależności. Clean Architecture mocniej eksponuje koncentryczne warstwy i reguły kierowania zależności do środka, dlatego oba podejścia można łączyć.

Oceń artykuł

Ocena: 0.00 Liczba głosów: 0

Tagi

porty
adaptery
testy jednostkowe
ef core
architektura hexagonalna
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