← Blog
tutorials

Heksagonal arkitektur | En praktisk guide til moderne applikationer

Lær hvad heksagonal arkitektur er, hvorfor det er vigtigt, og hvordan du implementerer det. En komplet guide med reelle eksempler til at bygge vedligeholdelsesvenlig, testbar software.

Ryveris Team ·
Heksagonal arkitektur | En praktisk guide til moderne applikationer

Heksagonal arkitektur er et softwaredesignmønster, der isolerer din kerne forretningslogik fra eksterne systemer som databaser, API’er og brugergrænseflader. Ideen er simpel: din domænekode bør aldrig afhænge af infrastruktur. Infrastruktur afhænger af domænet.

Mønsteret blev introduceret af Alistair Cockburn i 2005. Du hører måske også navnet “Ports and Adapters.” Begge navne beskriver det samme koncept.

Hvorfor det eksisterer

De fleste applikationer starter på samme måde. Forretningslogik bliver sammenfiltret med databaseforespørgsler, HTTP-handlers og tredjeparts-servicekald. Det virker i starten. Så vokser kodebasen, og tre ting sker:

  1. Test bliver smertefuldt. Du kan ikke teste forretningsregler uden at starte en database op eller mocke halvdelen af frameworket.
  2. At ændre infrastruktur er risikabelt. At skifte betalingsudbyder eller migrere fra REST til GraphQL betyder omskrivning af forretningslogik.
  3. Koden er svær at forstå. Ingen kan se, hvor domænet slutter, og infrastrukturen begynder.

Heksagonal arkitektur løser alle tre problemer ved at håndhæve en enkelt regel: afhængigheder peger altid indad.

Kernekoncepterne

Tænk på din applikation som tre koncentriske lag.

Domænet (center)

Det er din forretningslogik. Rene funktioner, entiteter, value objects og domænetjenester. Det har nul afhængigheder af frameworks, databaser eller eksterne biblioteker. Det taler sit eget sprog.

For et e-commerce system indeholder dette lag koncepter som Order, Product, PricingRule og InventoryPolicy. Disse objekter ved intet om SQL, HTTP eller JSON.

Ports (mellemste lag)

Ports er interfaces, der definerer, hvordan omverdenen interagerer med domænet. De er kontrakter, ikke implementeringer.

Der er to typer:

  • Inbound ports (driving). Definerer hvad applikationen kan gøre. Tænk på dem som use cases. “Placer en ordre.” “Annuller et abonnement.” “Generer en faktura.”
  • Outbound ports (driven). Definerer hvad applikationen har brug for fra omverdenen. “Gem en ordre.” “Send en notifikation.” “Hent valutakursen.”

Ports er en del af domænelaget. De er skrevet i domænesprog, ikke infrastruktursprog. Du skriver OrderRepository, ikke PostgresOrderDAO.

Adapters (yderste lag)

Adapters er de konkrete implementeringer, der forbinder den virkelige verden til dine ports.

  • Inbound adapters oversætter eksterne forespørgsler til domænekald. En REST controller, en GraphQL resolver, en CLI-kommando, en beskedkø-consumer. Alle disse er inbound adapters.
  • Outbound adapters implementerer outbound port-interfacene. Et PostgreSQL repository, en SMTP e-mail-afsender, en Stripe betalingsgateway. Alle disse er outbound adapters.

Den vigtigste indsigt: adapters afhænger af ports. Ports afhænger aldrig af adapters. Det er det, der får hele mønsteret til at virke.

Et konkret eksempel

Lad os sige, du bygger en ordreplacerings-funktion. Sådan fordeler lagene sig.

Domæne (entiteter og 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" });
  }
}

Inbound port (use case-interface):

// ports/inbound/PlaceOrder.ts
interface PlaceOrder {
  execute(command: PlaceOrderCommand): Promise<OrderConfirmation>;
}

Outbound ports (hvad use casen har brug for):

// 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>;
}

Applikationstjeneste (implementerer inbound port):

// 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);
  }
}

Adapters (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;
  }
}

Bemærk, hvordan PlaceOrderService aldrig nævner PostgreSQL, HTTP eller noget framework. Den arbejder udelukkende med domænekoncepter og port-interfaces.

Afhængighedsreglen

Dette er den vigtigste regel. Hvis du ikke får andet med fra denne artikel, så få dette:

Kildekodeafhængigheder skal altid pege indad, mod domænet.

  • Adapters afhænger af ports. Aldrig omvendt.
  • Ports afhænger af domæneentiteter. Aldrig af adapters.
  • Domænet afhænger af intet eksternt.

Dette håndhæves gennem dependency injection. Din composition root (applikationens indgangspunkt) samler alt:

// 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ænelaget importerer aldrig fra adapters-laget. Hvis du ser en import fra adapters/ inde i domain/ eller ports/, er der noget galt.

Projektstruktur

En ren mappestruktur gør grænserne synlige:

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

Enhver udvikler på teamet kan se på dette træ og forstå, hvor ny kode skal placeres.

Testfordele

Det er her, heksagonal arkitektur virkelig betaler sig.

Unit test af domænet:

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 database. Ingen HTTP-server. Intet mocking-framework. Ren logik, rene tests.

Test af use cases med falske adapters:

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 tester hele use casen uden at røre reel infrastruktur. De falske adapters implementerer de samme port-interfaces, så de er udskiftelige med de rigtige.

Integrationstests kun for adapters:

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());
});

Hver adapter får sin egen integrationstest. Hvis adapteren består sin test, og use casen består med falske adapters, virker hele systemet.

Hvornår du bør bruge heksagonal arkitektur

Heksagonal arkitektur tilføjer struktur og indirektion. Det har en omkostning. Her er hvornår det er det værd:

  • Langvarige applikationer. Hvis projektet skal vedligeholdes i år, betaler investeringen sig hurtigt.
  • Komplekse forretningsregler. Hvis dit domæne har ikke-triviel logik, der skal testes grundigt.
  • Flere indgangspunkter. Hvis den samme logik kaldes fra en web API, en CLI, en beskedkø og et cron job.
  • Infrastruktur kan ændre sig. Hvis du ikke er sikker på, om du beholder din nuværende database, cloud-udbyder eller tredjepartstjenester.
  • Flere udviklere. Klare grænser reducerer merge-konflikter og gør kodegennemgang hurtigere.

Hvornår du bør springe det over

Ikke hvert projekt har brug for dette struktureringsniveau:

  • Simple CRUD-applikationer. Hvis appen primært læser og skriver data med minimal forretningslogik, tilføjer indirektionen omkostninger uden fordel.
  • Prototyper og MVP’er. Hastighed er vigtigere end arkitektur, når du validerer en idé. Refaktorer senere.
  • Små scripts og værktøjer. Et CLI-værktøj på 200 linjer har ikke brug for ports and adapters.

Den rigtige mængde arkitektur afhænger af problemets kompleksitet. Start simpelt. Introducer heksagonale grænser, når smerten ved ikke at have dem bliver reel.

Almindelige fejl

Infrastruktur lækker ind i domænet. Hvis din Order-entitet har en @Column-dekorator eller en toJSON()-metode, er infrastruktur lækket ind. Hold dine domæneobjekter rene.

At skabe ports, der spejler infrastruktur. En port kaldet SqlQuery modvirker formålet. Ports bør beskrive, hvad domænet har brug for i domænesprog, ikke hvordan infrastrukturen fungerer.

Over-abstraktion. Ikke alt har brug for en port. Hvis du har en hjælpefunktion, der formaterer datoer, brug den direkte. Reserver ports til faktiske infrastrukturgrænser.

At springe composition root over. Uden et klart sted, hvor afhængigheder kobles sammen, bryder mønsteret sammen. Hver afhængighed bør injiceres, ikke importeres direkte.

Heksagonal vs. andre mønstre

Du undrer dig måske over, hvordan heksagonal arkitektur relaterer sig til andre velkendte mønstre:

  • Clean Architecture (Robert C. Martin): Meget ens. Clean Architecture tilføjer flere lag (entities, use cases, interface adapters, frameworks), men kerneideen er den samme: afhængigheder peger indad.
  • Onion Architecture (Jeffrey Palermo): Også meget ens. Onion Architecture bruger koncentriske ringe med domænet i centrum. Terminologien er forskellig, men afhængighedsreglen er identisk.
  • Lagdelt arkitektur (N-tier): Den traditionelle tilgang, hvor lag stables vertikalt (præsentation, forretning, data). Den afgørende forskel: i lagdelt arkitektur afhænger forretningslaget typisk af datalaget. I heksagonal arkitektur er den afhængighed inverteret.

Alle tre moderne mønstre (heksagonal, clean, onion) deler det samme grundlæggende princip. Domænet afhænger ikke af infrastruktur. De adskiller sig primært i navnekonventioner og antallet af lag, de foreskriver.

Kom i gang

Hvis du vil anvende heksagonal arkitektur på et eksisterende projekt, prøv ikke at refaktorere alt på én gang. Start med én bounded context eller én funktion:

  1. Identificer kerne forretningslogikken i den funktion. Udtræk den til rene funktioner eller klasser uden framework-afhængigheder.
  2. Definer ports for de eksterne afhængigheder, den funktion bruger. Opret interfaces i domænesprog.
  3. Flyt den eksisterende infrastrukturkode til adapterklasser, der implementerer de interfaces.
  4. Saml alt i en composition root ved hjælp af dependency injection.
  5. Skriv tests for domænet og use cases ved brug af falske adapters.

Gentag for den næste funktion. Over tid vil de heksagonale grænser sprede sig naturligt på tværs af kodebasen.

Afsluttende tanker

Heksagonal arkitektur handler ikke om at følge en rigid skabelon. Det handler om én simpel idé: beskyt din forretningslogik mod den ydre verdens kaos. Databaser ændrer sig. Frameworks falder i unåde. API’er udfases. Dine forretningsregler bør overleve alt det.

Mønsteret virker, fordi det stemmer overens med, hvordan software faktisk udvikler sig. Krav ændrer sig konstant. Infrastruktur ændrer sig periodisk. Men kernereglerne i din forretning har en tendens til at være den mest stabile del af systemet. Byg omkring den stabilitet.

Hvis du bygger et produkt, der skal holde, er heksagonal arkitektur en af de bedste investeringer, du kan foretage i din kodebase.


Har du brug for hjælp til at designe eller refaktorere din applikations arkitektur? Kontakt os. Vi hjælper teams med at bygge software, der forbliver vedligeholdelsesvenlig, efterhånden som den vokser.

architecturehexagonalports and adaptersclean codesoftware design

Lad os bygge dit næste projekt.

Book et gratis 30-minutters opkald. Vi drøfter dine mål, tidsplan og den bedste tilgang. Ingen forpligtelser.

Book et introduktionsmøde hello@ryveris.com