Hexagonal arkitektur | En praktisk guide för moderna applikationer
Lär dig vad hexagonal arkitektur är, varför det spelar roll och hur du implementerar det. En komplett guide med verkliga exempel för att bygga underhållbar, testbar programvara.
Hexagonal arkitektur är ett designmönster för programvara som isolerar din kärnaffärslogik från externa system som databaser, API:er och användargränssnitt. Idén är enkel: din domänkod ska aldrig bero på infrastruktur. Infrastruktur beror på domänen.
Mönstret introducerades av Alistair Cockburn 2005. Du kanske också hör det kallas “Ports and Adapters.” Båda namnen beskriver samma koncept.
Varför det finns
De flesta applikationer börjar på samma sätt. Affärslogik trasslas ihop med databasfrågor, HTTP-hanterare och anrop till tredjepartstjänster. Det fungerar till en början. Sedan växer kodbasen och tre saker händer:
- Testning blir smärtsamt. Du kan inte testa affärsregler utan att starta en databas eller mocka halva ramverket.
- Att byta infrastruktur är riskabelt. Att byta betaltjänstleverantör eller migrera från REST till GraphQL innebär att skriva om affärslogik.
- Koden är svår att förstå. Ingen kan se var domänen slutar och infrastrukturen börjar.
Hexagonal arkitektur löser alla tre problemen genom att upprätthålla en enda regel: beroenden pekar alltid inåt.
Kärnkoncepten
Tänk på din applikation som tre koncentriska lager.
Domänen (mitten)
Det här är din affärslogik. Rena funktioner, entiteter, värdeobjekt och domäntjänster. Den har noll beroenden på ramverk, databaser eller externa bibliotek. Den talar sitt eget språk.
För ett e-handelssystem innehåller detta lager koncept som Order, Product, PricingRule och InventoryPolicy. Dessa objekt vet ingenting om SQL, HTTP eller JSON.
Portar (mellanlager)
Portar är gränssnitt som definierar hur omvärlden interagerar med domänen. De är kontrakt, inte implementationer.
Det finns två typer:
- Inkommande portar (drivande). Definierar vad applikationen kan göra. Tänk på dem som användningsfall. “Lägg en beställning.” “Avbryt en prenumeration.” “Generera en faktura.”
- Utgående portar (drivna). Definierar vad applikationen behöver från omvärlden. “Spara en beställning.” “Skicka en notifikation.” “Hämta växelkursen.”
Portar är en del av domänlagret. De är skrivna i domänspråk, inte infrastrukturspråk. Du skriver OrderRepository, inte PostgresOrderDAO.
Adaptrar (yttersta lagret)
Adaptrar är de konkreta implementationer som kopplar den verkliga världen till dina portar.
- Inkommande adaptrar översätter externa begäranden till domänanrop. En REST-kontroller, en GraphQL-resolver, ett CLI-kommando, en meddelandekökonsument. Alla dessa är inkommande adaptrar.
- Utgående adaptrar implementerar de utgående portgränssnitten. Ett PostgreSQL-repository, en SMTP-e-postsändare, en Stripe-betalningsgateway. Alla dessa är utgående adaptrar.
Den centrala insikten: adaptrar beror på portar. Portar beror aldrig på adaptrar. Det är detta som får hela mönstret att fungera.
Ett konkret exempel
Låt oss säga att du bygger en beställningsfunktion. Så här ser lagren ut.
Domän (entiteter och regler):
// 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" });
}
}
Inkommande port (användningsfallsgränssnitt):
// ports/inbound/PlaceOrder.ts
interface PlaceOrder {
execute(command: PlaceOrderCommand): Promise<OrderConfirmation>;
}
Utgående portar (vad användningsfallet behöver):
// 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>;
}
Applikationstjänst (implementerar den inkommande porten):
// 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);
}
}
Adaptrar (infrastruktur):
// 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;
}
}
Notera hur PlaceOrderService aldrig nämner PostgreSQL, HTTP eller något ramverk. Den arbetar enbart med domänkoncept och portgränssnitt.
Beroenderegeln
Det här är den viktigaste regeln. Om du inte tar med dig något annat från den här artikeln, ta med dig detta:
Källkodsberoenden måste alltid peka inåt, mot domänen.
- Adaptrar beror på portar. Aldrig tvärtom.
- Portar beror på domänentiteter. Aldrig på adaptrar.
- Domänen beror på ingenting externt.
Detta upprätthålls genom beroendeinjektion. Din sammansättningsrot (applikationens startpunkt) kopplar ihop allt:
// 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);
Domänlagret importerar aldrig från adapterlagret. Om du ser en import från adapters/ inuti domain/ eller ports/ är något fel.
Projektstruktur
En ren mappstruktur gör gränserna synliga:
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
Varje utvecklare i teamet kan titta på det här trädet och förstå var ny kod ska placeras.
Testfördelar
Det är här hexagonal arkitektur verkligen lönar sig.
Enhetstestning av domänen:
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));
});
Ingen databas. Ingen HTTP-server. Inget mockningsramverk. Ren logik, rena tester.
Testning av användningsfall med fejkade adaptrar:
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);
});
Du testar hela användningsfallet utan att röra verklig infrastruktur. De fejkade adaptrarna implementerar samma portgränssnitt, så de är utbytbara med de riktiga.
Integrationstester enbart för adaptrar:
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());
});
Varje adapter får sitt eget integrationstest. Om adaptern klarar sitt test, och användningsfallet klarar med fejkade adaptrar, fungerar hela systemet.
När du ska använda hexagonal arkitektur
Hexagonal arkitektur lägger till struktur och indirektion. Det har en kostnad. Här är när det är värt det:
- Långlivade applikationer. Om projektet underhålls i åratal lönar sig investeringen snabbt.
- Komplexa affärsregler. Om din domän har icke-trivial logik som behöver testas grundligt.
- Multipla ingångspunkter. Om samma logik anropas från ett webb-API, en CLI, en meddelandekö och ett cron-jobb.
- Infrastrukturen kan ändras. Om du inte är säker på om du stannar med din nuvarande databas, molnleverantör eller tredjepartstjänster.
- Multipla utvecklare. Tydliga gränser minskar sammanslagningskonflikter och gör kodgranskningar snabbare.
När du ska hoppa över det
Inte varje projekt behöver den här nivån av struktur:
- Enkla CRUD-applikationer. Om appen mestadels läser och skriver data med minimal affärslogik lägger indirektionen till kostnad utan nytta.
- Prototyper och MVP:er. Hastighet är viktigare än arkitektur när du validerar en idé. Refaktorera senare.
- Små skript och verktyg. Ett 200-raders CLI-verktyg behöver inte portar och adaptrar.
Rätt mängd arkitektur beror på problemets komplexitet. Börja enkelt. Inför hexagonala gränser när smärtan av att inte ha dem blir verklig.
Vanliga misstag
Läcka infrastruktur in i domänen. Om din Order-entitet har en @Column-dekoratör eller en toJSON()-metod har infrastruktur läckt in. Håll dina domänobjekt rena.
Skapa portar som speglar infrastruktur. En port kallad SqlQuery motverkar syftet. Portar bör beskriva vad domänen behöver i domänspråk, inte hur infrastrukturen fungerar.
Överabstrahera. Inte allt behöver en port. Om du har en hjälpfunktion som formaterar datum, använd den direkt. Reservera portar för faktiska infrastrukturgränser.
Hoppa över sammansättningsroten. Utan en tydlig plats där beroenden kopplas ihop bryts mönstret ner. Varje beroende bör injiceras, inte importeras direkt.
Hexagonal vs andra mönster
Du kanske undrar hur hexagonal arkitektur förhåller sig till andra välkända mönster:
- Clean Architecture (Robert C. Martin): Mycket liknande. Clean Architecture lägger till fler lager (entiteter, användningsfall, gränssnittsadaptrar, ramverk) men kärnidén är densamma: beroenden pekar inåt.
- Onion Architecture (Jeffrey Palermo): Också mycket liknande. Onion Architecture använder koncentriska ringar med domänen i mitten. Terminologin skiljer sig, men beroenderegeln är identisk.
- Lagerarkitektur (N-tier): Det traditionella tillvägagångssättet där lager staplas vertikalt (presentation, affärslogik, data). Den avgörande skillnaden: i lagerarkitektur beror affärslagret typiskt på datalagret. I hexagonal arkitektur är det beroendet inverterat.
Alla tre moderna mönstren (hexagonal, clean, onion) delar samma grundläggande princip. Domänen beror inte på infrastruktur. De skiljer sig mestadels i namnkonventioner och antalet lager de föreskriver.
Komma igång
Om du vill tillämpa hexagonal arkitektur på ett befintligt projekt, försök inte refaktorera allt på en gång. Börja med en avgränsad kontext eller en funktion:
- Identifiera kärnaffärslogiken i den funktionen. Extrahera den till rena funktioner eller klasser utan ramverksberoenden.
- Definiera portar för de externa beroenden som funktionen använder. Skapa gränssnitt i domänspråk.
- Flytta den befintliga infrastrukturkoden till adapterklasser som implementerar dessa gränssnitt.
- Koppla ihop allt i en sammansättningsrot med beroendeinjektion.
- Skriv tester för domänen och användningsfallen med fejkade adaptrar.
Upprepa för nästa funktion. Över tid sprids de hexagonala gränserna naturligt över kodbasen.
Avslutande tankar
Hexagonal arkitektur handlar inte om att följa en rigid mall. Det handlar om en enkel idé: skydda din affärslogik från omvärldens kaos. Databaser ändras. Ramverk går ur mode. API:er fasas ut. Dina affärsregler bör överleva allt detta.
Mönstret fungerar för att det överensstämmer med hur programvara faktiskt utvecklas. Krav ändras konstant. Infrastruktur ändras periodiskt. Men kärnreglerna i din verksamhet tenderar att vara den mest stabila delen av systemet. Bygg kring den stabiliteten.
Om du bygger en produkt som behöver hålla är hexagonal arkitektur en av de bästa investeringarna du kan göra i din kodbas.
Behöver du hjälp med att designa eller refaktorera din applikations arkitektur? Kontakta oss. Vi hjälper team att bygga programvara som förblir underhållbar när den växer.