19 lut 2025
5 min

„Porty i adaptery” a „architektura heksagonalna” – czy to ten sam wzorzec?

„Architektura heksagonalna” podkreśla ideę rdzenia otoczonego wieloma bokami (jak sześciokąt, choć liczba boków nie ma znaczenia), które reprezentują różne systemy zewnętrzne (adaptery), z portami jako ich interfejsami.

„Porty i adaptery” opisuje ten sam uproszczony model w bardziej bezpośredni sposób i wskazuje bezpośrednio na zaangażowane komponenty: porty (interfejsy) i adaptery (implementacje). Wzorzec ten jest częściej używany, gdy wizualna metafora sześciokąta nie jest potrzebna.

Zarówno architektura heksagonalna, jak i porty i adaptery dążą do oddzielenia logiki biznesowej od systemów zewnętrznych za pomocą interfejsów (portów) i implementacji (adapterów). Są to w zasadzie te same koncepcje, często używane zamiennie do opisania tego samego wzorca architektonicznego.

Czym jest wzorzec portów i adapterów?

Architektura portów i adapterów została wprowadzona przez Alistaira Cockburna (znanego głównie jako współtwórca i sygnatariusz Manifestu Agile z 2001 roku). Kluczową ideą jest izolacja głównej logiki biznesowej aplikacji (jej „serca”) od zewnętrznych zależności, takich jak bazy danych, interfejsy użytkownika czy usługi zewnętrzne.

Rdzeń logiki biznesowej znajduje się w centrum architektury. To tutaj znajdują się wszystkie kluczowe reguły biznesowe, modele domenowe i usługi aplikacyjne. Rdzeń jest niezależny od jakichkolwiek systemów zewnętrznych, co zapewnia możliwość działania logiki biznesowej bez bezpośredniego polegania na szczegółach infrastruktury, takich jak API przeglądarki, warstwa HTTP czy specyficzne funkcje frameworków. Rdzeń powinien być wysoce spójny, zawierając logikę definiującą, czym zajmuje się aplikacja. Brak zależności od infrastruktury ułatwia testowanie i utrzymanie.

Porty to abstrakcyjne interfejsy definiujące sposób, w jaki rdzeń komunikuje się ze światem zewnętrznym. Przykłady to usługi definiujące przypadki użycia aplikacji, takie jak „utwórzElementTodo” lub „zmieńStatusElementu”.

Adaptery to konkretne implementacje portów. Działają jako most łączący główną logikę biznesową z systemami zewnętrznymi. Adaptery tłumaczą zewnętrzne formaty danych, protokoły lub żądania na formę, którą rdzeń logiki może zrozumieć i obsłużyć.

Kiedy i dlaczego warto używać wzorca Portów i Adapterów?

Ten wzorzec projektowy ma na celu uczynienie aplikacji bardziej modułowymi, łatwymi w utrzymaniu i dostosowującymi się do zmian.

  • Rozdzielenie odpowiedzialności: Oddzielenie logiki biznesowej od systemów zewnętrznych pozwala na niezależny rozwój każdej części aplikacji. Zmiany w infrastrukturze lub usługach zewnętrznych nie wpływają na logikę rdzenia i odwrotnie.
  • Modularność: Architektura sprzyja tworzeniu modułów, które łatwo wymieniać lub ulepszać. Na przykład można zmienić adapter bazy danych (np. z relacyjnej na NoSQL) bez modyfikowania logiki biznesowej.
  • Testowalność: Izolacja logiki biznesowej od zależności zewnętrznych pozwala na jej niezależne testowanie za pomocą atrap portów. To prowadzi do bardziej niezawodnych testów jednostkowych i łatwiejszej identyfikacji błędów.
  • Elastyczność: Architektura wspiera wiele interfejsów do interakcji z aplikacją. Na przykład ta sama logika rdzenia może być dostępna przez webowy interfejs użytkownika, narzędzie wiersza poleceń lub zewnętrzne API, dzięki utworzeniu różnych adapterów wejściowych.

Jeśli czujesz, że masz trudności z przynajmniej jednym z powyższych aspektów, warto rozważyć implementację wzorca portów i adapterów.

Porty i adaptery (architektura heksagonalna) w Angularze

Na szczęście implementacja portów i adapterów w Angularze jest bardzo prosta, dzięki TypeScript i mechanizmowi Dependency Injection dostępnemu w Angularze.

Zacznijmy od implementacji portu. Jak wyjaśniono wcześniej, port to nic innego jak abstrakcyjny interfejs, więc możemy użyć interfejsu TypeScript do tego celu. Sam port, podobnie jak cała logika biznesowa, nie powinien mieć zależności od zewnętrznej infrastruktury.

export interface FruitService {
  getAllFruits(): Observable<Fruit[]>;
  getFruitById(id: string): Observable<Fruit>;
}

Z kolei adapter to konkretna implementacja portu, więc możemy użyć klasy TypeScript, która implementuje port:

@Injectable()
export class FruitServiceAdapter implements FruitService {
  private readonly httpClient = inject(HttpClient);

  getAllFruits(): Observable<Fruit[]> {
    return this.httpClient.get<Fruit[]>('/fruits/all');
  }
  getFruitById(id: string): Observable<Fruit> {
    return this.httpClient.get<Fruit>(`/fruits/${id}`);
  }
}

Aby połączyć port z adapterem, możemy utworzyć injection token, który reprezentuje port, ale zapewnia adapter w jego miejscu.

export const FRUIT_SERVICE = new InjectionToken<FruitService>('fruit-service');



@Component({
  ...
  providers: [
    {
      provide: FRUIT_SERVICE,
      useClass: FruitServiceAdapter,
    },
  ],
})
export class App {
  fruitService = inject(FRUIT_SERVICE);
}

Dzięki temu, że używamy portu jako ogólnego typu w tokenie wstrzykiwania, wstrzykiwana usługa jest automatycznie typowana jako port.

Porty i adaptery w Angularze z wykorzystaniem klasy abstrakcyjnej

Możemy uprościć powyższą implementację, wykorzystując fakt, że w TypeScript możemy użyć również klasy abstrakcyjnej jako typu, która w przeciwieństwie do interfejsu nie jest usuwana z kodu podczas kompilacji.

Zmieńmy nasz port na klasę abstrakcyjną z abstrakcyjnymi właściwościami:

Dzięki temu, że używamy portu jako ogólnego typu w tokenie wstrzykiwania, wstrzykiwana usługa jest automatycznie typowana jako port.

Porty i adaptery w Angularze z wykorzystaniem klasy abstrakcyjnej

Możemy uprościć powyższą implementację, wykorzystując fakt, że w TypeScript możemy użyć również klasy abstrakcyjnej jako typu, która w przeciwieństwie do interfejsu nie jest usuwana z kodu podczas kompilacji.

Zmieńmy nasz port na klasę abstrakcyjną z abstrakcyjnymi właściwościami:

export abstract class FruitService {
  abstract getAllFruits(): Observable<Fruit[]>;
  abstract getFruitById(id: string): Observable<Fruit>;
}

Implementacja adaptera nie wymaga modyfikacji (w TypeScript klasa może implementować inną klasę abstrakcyjną).

@Injectable()
export class FruitServiceAdapter implements FruitService { ... }

Na końcu możemy użyć klasy abstrakcyjnej również jako injection token, bez konieczności jawnego definiowania nowego tokenu.

@Component({
  ...
  providers: [
    {
      provide: FruitService,
      useClass: FruitServiceAdapter,
    },
  ],
})
export class App {
  fruitService = inject(FruitService);
}

Stackblitz: https://stackblitz.com/edit/stackblitz-starters-xwxwca?file=src%2Ffruit-service%2Ffruit-service.port.ts

Podsumowanie

Implementacja architektury Portów i Adapterów (Heksagonalnej) w Angularze pomaga zorganizować aplikację i zapewnia solidne podstawy do utrzymania i skalowania projektów w czasie. Oddzielenie logiki biznesowej od szczegółów infrastruktury zewnętrznej pozwala na większą elastyczność i testowalność. Mocne wsparcie Angulara dla TypeScript oraz mechanizm Dependency Injection sprawiają, że tę architekturę można łatwo wdrożyć, definiując przejrzyste kontrakty (porty) i konkretne implementacje (adaptery). W miarę rozwoju aplikacji korzyści płynące z tego wzorca stają się coraz bardziej widoczne, czyniąc kod bardziej modułowym, elastycznym i odpornym na zmiany. Jeśli zależy Ci na dobrze zorganizowanej, łatwej w utrzymaniu aplikacji Angular, wdrożenie architektury portów i adapterów to strategiczny wybór, który może zapewnić sukces projektu w dłuższej perspektywie.

Najczęściej zadawane pytania

Jak zaimplementować architekturę heksagonalną w Angularze?

Port definiujesz jako interfejs TypeScript lub klasę abstrakcyjną z metodami, np. FruitService, a adapter jako klasę @Injectable() implementującą ten port, która korzysta z inject(HttpClient). Następnie w providers komponentu wiążesz port z adapterem, np. { provide: FRUIT_SERVICE, useClass: FruitServiceAdapter }, a komponent pobiera zależność przez inject(FRUIT_SERVICE). Port nie powinien zależeć od infrastruktury, dzięki czemu logika biznesowa pozostaje niezależna od HTTP.

Czym różni się interfejs TypeScript od klasy abstrakcyjnej jako portu w Angularze?

Interfejs TypeScript jest usuwany podczas kompilacji, więc nie może być samodzielnie użyty jako token wstrzykiwania i wymaga InjectionToken, np. new InjectionToken<FruitService>('fruit-service'). Klasa abstrakcyjna zostaje w kodzie po kompilacji, dlatego może pełnić rolę tokenu bezpośrednio w provide: FruitService, useClass: FruitServiceAdapter. Adapter nadal implementuje ją przez implements, a komponent pobiera port przez inject(FruitService).

Jaka jest różnica między portem a adapterem w architekturze heksagonalnej?

Port to abstrakcyjny interfejs opisujący, jak rdzeń aplikacji komunikuje się ze światem zewnętrznym, np. usługa z przypadkami użycia takimi jak „utwórzElementTodo”. Adapter to konkretna implementacja portu, która tłumaczy zewnętrzne formaty danych, protokoły lub żądania na formę zrozumiałą dla logiki biznesowej. Przykładowo FruitServiceAdapter używa HttpClient, podczas gdy port FruitService nie zna żadnych szczegółów HTTP.

Jak testować logikę zależną od portu w Angularze bez prawdziwych zależności zewnętrznych?

Logikę zależną od portu testujesz, podstawiając atrapę (mock) w miejsce adaptera zamiast implementacji opartej o HttpClient. Ponieważ komponent otrzymuje zależność przez inject(FruitService), wystarczy w providers testu wskazać inną klasę dla tego samego tokenu. Dzięki temu testy jednostkowe nie wykonują żądań sieciowych, a błędy łatwiej zlokalizować.

Czy mogę wymienić adapter bazy danych bez zmiany logiki biznesowej w architekturze portów i adapterów?

Tak, o ile logika korzysta wyłącznie z portu, a nie z konkretnego adaptera. Artykuł podaje przykład zmiany adaptera z relacyjnej bazy na NoSQL bez modyfikowania logiki biznesowej. W Angularze odpowiednikiem jest zmiana wartości useClass w providers na inną implementację tego samego portu, bez zmian w komponentach korzystających z inject(FruitService).

Podziel się artykułem

Zapisz się na nasz newsletter

Dołącz do community Angular.love i bądź na bieżąco z trendami.