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.

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.
