Architektura heksagonalna | Praktyczny przewodnik po nowoczesnych aplikacjach
Dowiedz się, czym jest architektura heksagonalna, dlaczego ma znaczenie i jak ją wdrożyć. Kompletny przewodnik z realnymi przykładami budowania utrzymywalnego, testowalnego oprogramowania.
Architektura heksagonalna to wzorzec projektowy, który izoluje Twoją podstawową logikę biznesową od systemów zewnętrznych takich jak bazy danych, API i interfejsy użytkownika. Idea jest prosta: Twój kod domenowy nigdy nie powinien zależeć od infrastruktury. Infrastruktura zależy od domeny.
Wzorzec został wprowadzony przez Alistaira Cockburna w 2005 roku. Możesz też spotkać nazwę “Ports and Adapters.” Obie nazwy opisują tę samą koncepcję.
Dlaczego istnieje
Większość aplikacji zaczyna tak samo. Logika biznesowa splata się z zapytaniami do bazy danych, handlerami HTTP i wywołaniami usług zewnętrznych. Na początku działa. Potem baza kodu rośnie i dzieją się trzy rzeczy:
- Testowanie staje się bolesne. Nie możesz przetestować reguł biznesowych bez uruchamiania bazy danych lub mockowania połowy frameworka.
- Zmiana infrastruktury jest ryzykowna. Zamiana dostawcy płatności lub migracja z REST na GraphQL oznacza przepisanie logiki biznesowej.
- Kod jest trudny do zrozumienia. Nikt nie potrafi powiedzieć, gdzie kończy się domena, a zaczyna infrastruktura.
Architektura heksagonalna rozwiązuje wszystkie trzy problemy, wymuszając jedną regułę: zależności zawsze wskazują do wewnątrz.
Kluczowe koncepcje
Pomyśl o swojej aplikacji jako o trzech koncentrycznych warstwach.
Domena (centrum)
To Twoja logika biznesowa. Czyste funkcje, encje, obiekty wartości i usługi domenowe. Ma zero zależności od frameworków, baz danych czy zewnętrznych bibliotek. Mówi swoim własnym językiem.
Dla systemu e-commerce ta warstwa zawiera koncepcje takie jak Order, Product, PricingRule i InventoryPolicy. Te obiekty nie wiedzą nic o SQL, HTTP czy JSON.
Porty (warstwa środkowa)
Porty to interfejsy definiujące sposób interakcji świata zewnętrznego z domeną. To kontrakty, nie implementacje.
Istnieją dwa typy:
- Porty wejściowe (driving). Definiują, co aplikacja potrafi robić. Pomyśl o nich jako o przypadkach użycia. “Złóż zamówienie.” “Anuluj subskrypcję.” “Wygeneruj fakturę.”
- Porty wyjściowe (driven). Definiują, czego aplikacja potrzebuje od świata zewnętrznego. “Zapisz zamówienie.” “Wyślij powiadomienie.” “Pobierz kurs wymiany.”
Porty należą do warstwy domenowej. Są napisane w języku domeny, nie infrastruktury. Piszesz OrderRepository, nie PostgresOrderDAO.
Adaptery (warstwa zewnętrzna)
Adaptery to konkretne implementacje łączące realny świat z portami.
- Adaptery wejściowe tłumaczą zewnętrzne żądania na wywołania domeny. Kontroler REST, resolver GraphQL, komenda CLI, konsumer kolejki wiadomości. Wszystkie to adaptery wejściowe.
- Adaptery wyjściowe implementują interfejsy portów wyjściowych. Repozytorium PostgreSQL, nadawca e-mail SMTP, bramka płatności Stripe. Wszystkie to adaptery wyjściowe.
Kluczowa obserwacja: adaptery zależą od portów. Porty nigdy nie zależą od adapterów. To sprawia, że cały wzorzec działa.
Konkretny przykład
Załóżmy, że budujesz funkcję składania zamówień. Oto jak rozkładają się warstwy.
Domena (encje i reguły):
// domain/Order.ts
class Order {
readonly id: string;
readonly items: OrderItem[];
readonly status: OrderStatus;
calculateTotal(): Money {
return this.items.reduce(
(sum, item) => sum.add(item.price.multiply(item.quantity)),
Money.zero()
);
}
confirm(): Order {
if (this.items.length === 0) {
throw new EmptyOrderError();
}
return new Order({ ...this, status: "confirmed" });
}
}
Port wejściowy (interfejs przypadku użycia):
// ports/inbound/PlaceOrder.ts
interface PlaceOrder {
execute(command: PlaceOrderCommand): Promise<OrderConfirmation>;
}
Porty wyjściowe (czego potrzebuje przypadek użycia):
// ports/outbound/OrderRepository.ts
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
}
// ports/outbound/PaymentGateway.ts
interface PaymentGateway {
charge(amount: Money, method: PaymentMethod): Promise<PaymentResult>;
}
// ports/outbound/NotificationSender.ts
interface NotificationSender {
sendOrderConfirmation(order: Order): Promise<void>;
}
Usługa aplikacyjna (implementuje port wejściowy):
// application/PlaceOrderService.ts
class PlaceOrderService implements PlaceOrder {
constructor(
private orders: OrderRepository,
private payments: PaymentGateway,
private notifications: NotificationSender
) {}
async execute(command: PlaceOrderCommand): Promise<OrderConfirmation> {
const order = Order.create(command.items);
const confirmed = order.confirm();
const payment = await this.payments.charge(
confirmed.calculateTotal(),
command.paymentMethod
);
if (!payment.successful) {
throw new PaymentFailedError(payment.reason);
}
await this.orders.save(confirmed);
await this.notifications.sendOrderConfirmation(confirmed);
return OrderConfirmation.from(confirmed, payment);
}
}
Adaptery (infrastruktura):
// adapters/inbound/OrderController.ts
class OrderController {
constructor(private placeOrder: PlaceOrder) {}
async handlePost(req: Request): Promise<Response> {
const command = PlaceOrderCommand.fromRequest(req.body);
const confirmation = await this.placeOrder.execute(command);
return Response.json(confirmation.toResponse(), { status: 201 });
}
}
// adapters/outbound/PostgresOrderRepository.ts
class PostgresOrderRepository implements OrderRepository {
async save(order: Order): Promise<void> {
await this.db.query(
"INSERT INTO orders (id, items, status) VALUES ($1, $2, $3)",
[order.id, JSON.stringify(order.items), order.status]
);
}
async findById(id: string): Promise<Order | null> {
const row = await this.db.query("SELECT * FROM orders WHERE id = $1", [id]);
return row ? Order.fromPersistence(row) : null;
}
}
Zauważ, że PlaceOrderService nigdy nie wspomina o PostgreSQL, HTTP ani żadnym frameworku. Działa wyłącznie z koncepcjami domeny i interfejsami portów.
Reguła zależności
To najważniejsza reguła. Jeśli wyniesiesz z tego artykułu tylko jedną rzecz, niech to będzie ta:
Zależności w kodzie źródłowym muszą zawsze wskazywać do wewnątrz, ku domenie.
- Adaptery zależą od portów. Nigdy odwrotnie.
- Porty zależą od encji domeny. Nigdy od adapterów.
- Domena nie zależy od niczego zewnętrznego.
Jest to egzekwowane przez wstrzykiwanie zależności. Twój composition root (punkt wejścia aplikacji) łączy wszystko razem:
// main.ts (composition root)
const orderRepo = new PostgresOrderRepository(db);
const payments = new StripePaymentGateway(stripeClient);
const notifications = new EmailNotificationSender(mailer);
const placeOrder = new PlaceOrderService(orderRepo, payments, notifications);
const controller = new OrderController(placeOrder);
Warstwa domeny nigdy nie importuje z warstwy adapterów. Jeśli widzisz import z adapters/ wewnątrz domain/ lub ports/, coś jest nie tak.
Struktura projektu
Czysta struktura folderów czyni granice widocznymi:
src/
domain/
Order.ts
OrderItem.ts
Money.ts
errors/
EmptyOrderError.ts
PaymentFailedError.ts
ports/
inbound/
PlaceOrder.ts
CancelOrder.ts
outbound/
OrderRepository.ts
PaymentGateway.ts
NotificationSender.ts
application/
PlaceOrderService.ts
CancelOrderService.ts
adapters/
inbound/
http/
OrderController.ts
cli/
PlaceOrderCommand.ts
outbound/
persistence/
PostgresOrderRepository.ts
payment/
StripePaymentGateway.ts
notification/
EmailNotificationSender.ts
main.ts
Każdy programista w zespole może spojrzeć na to drzewo i zrozumieć, gdzie umieścić nowy kod.
Korzyści z testowania
Tutaj architektura heksagonalna naprawdę się opłaca.
Testy jednostkowe domeny:
test("order calculates total correctly", () => {
const order = Order.create([
{ product: "Widget", price: Money.eur(10), quantity: 3 },
{ product: "Gadget", price: Money.eur(25), quantity: 1 },
]);
expect(order.calculateTotal()).toEqual(Money.eur(55));
});
Bez bazy danych. Bez serwera HTTP. Bez frameworka do mockowania. Czysta logika, czyste testy.
Testowanie przypadków użycia z fałszywymi adapterami:
test("place order charges payment and saves", async () => {
const orders = new InMemoryOrderRepository();
const payments = new FakePaymentGateway({ alwaysSucceeds: true });
const notifications = new FakeNotificationSender();
const service = new PlaceOrderService(orders, payments, notifications);
const result = await service.execute({
items: [{ product: "Widget", price: 10, quantity: 2 }],
paymentMethod: { type: "card", token: "tok_test" },
});
expect(result.status).toBe("confirmed");
expect(orders.savedOrders).toHaveLength(1);
expect(payments.charges).toHaveLength(1);
expect(notifications.sent).toHaveLength(1);
});
Testujesz cały przypadek użycia bez dotykania realnej infrastruktury. Fałszywe adaptery implementują te same interfejsy portów, więc są wymienne z realnymi.
Testy integracyjne tylko dla adapterów:
test("PostgresOrderRepository saves and retrieves", async () => {
const repo = new PostgresOrderRepository(testDb);
const order = Order.create([
{ product: "Widget", price: Money.eur(10), quantity: 1 },
]);
await repo.save(order.confirm());
const found = await repo.findById(order.id);
expect(found).toEqual(order.confirm());
});
Każdy adapter ma swój test integracyjny. Jeśli adapter przechodzi swój test, a przypadek użycia przechodzi z fałszywymi adapterami, cały system działa.
Kiedy używać architektury heksagonalnej
Architektura heksagonalna dodaje strukturę i pośredniość. To ma swój koszt. Oto kiedy jest to opłacalne:
- Długotrwałe aplikacje. Jeśli projekt będzie utrzymywany przez lata, inwestycja szybko się zwraca.
- Złożone reguły biznesowe. Jeśli Twoja domena ma nietrywialną logikę wymagającą dokładnego testowania.
- Wiele punktów wejścia. Jeśli ta sama logika jest wywoływana z web API, CLI, kolejki wiadomości i zadania cron.
- Infrastruktura może się zmienić. Jeśli nie jesteś pewien, czy pozostaniesz przy obecnej bazie danych, dostawcy chmury lub usługach zewnętrznych.
- Wielu programistów. Jasne granice zmniejszają konflikty merge i przyspieszają code review.
Kiedy pominąć
Nie każdy projekt potrzebuje tego poziomu struktury:
- Proste aplikacje CRUD. Jeśli aplikacja głównie czyta i zapisuje dane z minimalną logiką biznesową, pośredniość dodaje koszt bez korzyści.
- Prototypy i MVP. Szybkość jest ważniejsza niż architektura, gdy walidujecie pomysł. Refaktoryzuj później.
- Małe skrypty i narzędzia. 200-liniowe narzędzie CLI nie potrzebuje portów i adapterów.
Odpowiednia ilość architektury zależy od złożoności problemu. Zacznij prosto. Wprowadź granice heksagonalne, gdy ból z ich braku stanie się realny.
Częste błędy
Wyciek infrastruktury do domeny. Jeśli Twoja encja Order ma dekorator @Column lub metodę toJSON(), infrastruktura wyciekła. Utrzymuj obiekty domeny w czystości.
Tworzenie portów odzwierciedlających infrastrukturę. Port o nazwie SqlQuery mija się z celem. Porty powinny opisywać, czego domena potrzebuje w języku domeny, a nie jak działa infrastruktura.
Nadmierna abstrakcja. Nie wszystko potrzebuje portu. Jeśli masz funkcję narzędziową formatującą daty, po prostu jej użyj. Rezerwuj porty dla faktycznych granic infrastrukturalnych.
Pomijanie composition root. Bez jasnego miejsca, gdzie zależności są ze sobą połączone, wzorzec się rozpada. Każda zależność powinna być wstrzyknięta, nie importowana bezpośrednio.
Architektura heksagonalna vs inne wzorce
Możesz się zastanawiać, jak architektura heksagonalna ma się do innych znanych wzorców:
- Clean Architecture (Robert C. Martin): Bardzo podobna. Clean Architecture dodaje więcej warstw (encje, przypadki użycia, adaptery interfejsu, frameworki), ale podstawowa idea jest ta sama: zależności wskazują do wewnątrz.
- Onion Architecture (Jeffrey Palermo): Również bardzo podobna. Onion Architecture używa koncentrycznych pierścieni z domeną w centrum. Terminologia się różni, ale reguła zależności jest identyczna.
- Architektura warstwowa (N-tier): Tradycyjne podejście, gdzie warstwy układają się pionowo (prezentacja, biznes, dane). Kluczowa różnica: w architekturze warstwowej warstwa biznesowa zazwyczaj zależy od warstwy danych. W architekturze heksagonalnej ta zależność jest odwrócona.
Wszystkie trzy nowoczesne wzorce (heksagonalny, clean, onion) dzielą tę samą fundamentalną zasadę. Domena nie zależy od infrastruktury. Różnią się głównie konwencjami nazewnictwa i liczbą warstw, które przepisują.
Jak zacząć
Jeśli chcesz zastosować architekturę heksagonalną w istniejącym projekcie, nie próbuj refaktoryzować wszystkiego naraz. Zacznij od jednego bounded context lub jednej funkcji:
- Zidentyfikuj podstawową logikę biznesową w tej funkcji. Wyodrębnij ją do czystych funkcji lub klas bez zależności od frameworków.
- Zdefiniuj porty dla zewnętrznych zależności, których ta funkcja używa. Stwórz interfejsy w języku domeny.
- Przenieś istniejący kod infrastruktury do klas adapterów implementujących te interfejsy.
- Połącz wszystko w composition root za pomocą wstrzykiwania zależności.
- Napisz testy dla domeny i przypadków użycia używając fałszywych adapterów.
Powtórz dla kolejnej funkcji. Z czasem granice heksagonalne rozprzestrzenią się po bazie kodu w naturalny sposób.
Podsumowanie
Architektura heksagonalna nie polega na podążaniu za sztywnym szablonem. Polega na jednej prostej idei: chroń swoją logikę biznesową przed chaosem świata zewnętrznego. Bazy danych się zmieniają. Frameworki wychodzą z mody. API są deprecjonowane. Twoje reguły biznesowe powinny to wszystko przetrwać.
Wzorzec działa, bo jest zgodny z tym, jak oprogramowanie faktycznie ewoluuje. Wymagania zmieniają się ciągle. Infrastruktura zmienia się okresowo. Ale podstawowe reguły Twojego biznesu to zazwyczaj najbardziej stabilna część systemu. Buduj wokół tej stabilności.
Jeśli budujesz produkt, który ma przetrwać, architektura heksagonalna jest jedną z najlepszych inwestycji, jakich możesz dokonać w swoją bazę kodu.
Potrzebujesz pomocy przy projektowaniu lub refaktoryzacji architektury swojej aplikacji? Skontaktuj się z nami. Pomagamy zespołom budować oprogramowanie, które pozostaje utrzymywalne w miarę rozwoju.