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.

Podziel się artykułem

Zapisz się na nasz newsletter

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