← Blog
tutorials

Hexagonale architectuur | Een praktische gids voor moderne applicaties

Leer wat hexagonale architectuur is, waarom het ertoe doet en hoe je het implementeert. Een complete gids met echte voorbeelden voor het bouwen van onderhoudbare, testbare software.

Ryveris Team ·
Hexagonale architectuur | Een praktische gids voor moderne applicaties

Hexagonale architectuur is een software-ontwerppatroon dat je kernbedrijfslogica isoleert van externe systemen zoals databases, API’s en gebruikersinterfaces. Het idee is eenvoudig: je domeincode mag nooit afhankelijk zijn van infrastructuur. Infrastructuur is afhankelijk van het domein.

Het patroon werd geïntroduceerd door Alistair Cockburn in 2005. Je hoort het ook wel “Ports and Adapters” noemen. Beide namen beschrijven hetzelfde concept.

Waarom het bestaat

De meeste applicaties beginnen op dezelfde manier. Bedrijfslogica raakt verweven met databasequery’s, HTTP-handlers en aanroepen naar diensten van derden. Het werkt in het begin. Dan groeit de codebase, en er gebeuren drie dingen:

  1. Testen wordt pijnlijk. Je kunt bedrijfsregels niet testen zonder een database op te starten of de helft van het framework te mocken.
  2. Infrastructuur wijzigen is risicovol. Een betalingsprovider wisselen of migreren van REST naar GraphQL betekent bedrijfslogica herschrijven.
  3. De code is moeilijk te doorgronden. Niemand kan zien waar het domein eindigt en de infrastructuur begint.

Hexagonale architectuur lost alle drie de problemen op door één regel af te dwingen: afhankelijkheden wijzen altijd naar binnen.

De kernconcepten

Stel je je applicatie voor als drie concentrische lagen.

Het domein (centrum)

Dit is je bedrijfslogica. Pure functies, entiteiten, value objects en domeinservices. Het heeft nul afhankelijkheden van frameworks, databases of externe bibliotheken. Het spreekt zijn eigen taal.

Voor een e-commercesysteem bevat deze laag concepten zoals Order, Product, PricingRule en InventoryPolicy. Deze objecten weten niets over SQL, HTTP of JSON.

Ports (middelste laag)

Ports zijn interfaces die definiëren hoe de buitenwereld interactie heeft met het domein. Het zijn contracten, geen implementaties.

Er zijn twee typen:

  • Inbound ports (aansturend). Definiëren wat de applicatie kan doen. Zie ze als use cases. “Plaats een bestelling.” “Annuleer een abonnement.” “Genereer een factuur.”
  • Outbound ports (aangestuurd). Definiëren wat de applicatie nodig heeft van de buitenwereld. “Sla een bestelling op.” “Stuur een notificatie.” “Haal de wisselkoers op.”

Ports maken deel uit van de domeinlaag. Ze zijn geschreven in domeintaal, niet infrastructuurtaal. Je schrijft OrderRepository, niet PostgresOrderDAO.

Adapters (buitenste laag)

Adapters zijn de concrete implementaties die de echte wereld verbinden met je ports.

  • Inbound adapters vertalen externe verzoeken naar domeinaanroepen. Een REST-controller, een GraphQL-resolver, een CLI-commando, een message queue-consumer. Dit zijn allemaal inbound adapters.
  • Outbound adapters implementeren de outbound port-interfaces. Een PostgreSQL-repository, een SMTP-e-mailverzender, een Stripe payment gateway. Dit zijn allemaal outbound adapters.

Het kerninsicht: adapters zijn afhankelijk van ports. Ports zijn nooit afhankelijk van adapters. Dit is wat het hele patroon laat werken.

Een concreet voorbeeld

Stel dat je een bestelplaatsingsfunctie bouwt. Zo zijn de lagen opgebouwd.

Domein (entiteiten en regels):

// 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 (wat de use case nodig heeft):

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

Application service (implementeert de 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 (infrastructuur):

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

Merk op hoe PlaceOrderService nooit PostgreSQL, HTTP of enig framework noemt. Het werkt puur met domeinconcepten en port-interfaces.

De afhankelijkheidsregel

Dit is de belangrijkste regel. Als je niets anders onthoudt van dit artikel, onthoud dan dit:

Broncodeafhankelijkheden moeten altijd naar binnen wijzen, richting het domein.

  • Adapters zijn afhankelijk van ports. Nooit andersom.
  • Ports zijn afhankelijk van domeinentiteiten. Nooit van adapters.
  • Het domein is van niets extern afhankelijk.

Dit wordt afgedwongen door dependency injection. Je composition root (het startpunt van de applicatie) verbindt alles met elkaar:

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

De domeinlaag importeert nooit uit de adapterlaag. Als je een import ziet vanuit adapters/ in domain/ of ports/, is er iets mis.

Projectstructuur

Een duidelijke mappenstructuur maakt de grenzen zichtbaar:

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

Elke ontwikkelaar in het team kan naar deze structuur kijken en begrijpen waar nieuwe code hoort.

Testvoordelen

Dit is waar hexagonale architectuur echt rendeert.

Unit testen van het domein:

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

Geen database. Geen HTTP-server. Geen mockingframework. Pure logica, pure tests.

Use cases testen met nep-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);
});

Je test de volledige use case zonder echte infrastructuur aan te raken. De nep-adapters implementeren dezelfde port-interfaces, dus ze zijn uitwisselbaar met de echte.

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

Elke adapter krijgt zijn eigen integratietest. Als de adapter zijn test doorstaat, en de use case slaagt met nep-adapters, werkt het hele systeem.

Wanneer hexagonale architectuur gebruiken

Hexagonale architectuur voegt structuur en indirectie toe. Dat heeft een prijs. Hier is wanneer het de moeite waard is:

  • Langlevende applicaties. Als het project jarenlang wordt onderhouden, verdient de investering zich snel terug.
  • Complexe bedrijfsregels. Als je domein niet-triviale logica bevat die grondig moet worden getest.
  • Meerdere ingangspunten. Als dezelfde logica wordt aangeroepen vanuit een web API, een CLI, een message queue en een cronjob.
  • Infrastructuur kan veranderen. Als je niet zeker weet of je bij je huidige database, cloudprovider of diensten van derden blijft.
  • Meerdere ontwikkelaars. Duidelijke grenzen verminderen merge-conflicten en maken code reviews sneller.

Wanneer het overslaan

Niet elk project heeft dit niveau van structuur nodig:

  • Eenvoudige CRUD-applicaties. Als de app voornamelijk data leest en schrijft met minimale bedrijfslogica, voegt de indirectie kosten toe zonder voordeel.
  • Prototypes en MVP’s. Snelheid is belangrijker dan architectuur wanneer je een idee valideert. Refactor later.
  • Kleine scripts en tools. Een CLI-tool van 200 regels heeft geen ports en adapters nodig.

De juiste hoeveelheid architectuur hangt af van de complexiteit van het probleem. Begin simpel. Introduceer hexagonale grenzen wanneer de pijn van het niet hebben ervan reëel wordt.

Veelgemaakte fouten

Infrastructuur lekken naar het domein. Als je Order-entiteit een @Column-decorator of een toJSON()-methode heeft, is er infrastructuur ingelekt. Houd je domeinobjecten schoon.

Ports creëren die infrastructuur spiegelen. Een port genaamd SqlQuery verslaat het doel. Ports moeten beschrijven wat het domein nodig heeft in domeintaal, niet hoe de infrastructuur werkt.

Overmatig abstraheren. Niet alles heeft een port nodig. Als je een hulpfunctie hebt die datums formatteert, gebruik die gewoon direct. Reserveer ports voor daadwerkelijke infrastructuurgrenzen.

De composition root overslaan. Zonder een duidelijke plek waar afhankelijkheden aan elkaar worden verbonden, valt het patroon uiteen. Elke afhankelijkheid moet worden geïnjecteerd, niet direct geïmporteerd.

Hexagonaal vs. andere patronen

Je vraagt je misschien af hoe hexagonale architectuur zich verhoudt tot andere bekende patronen:

  • Clean Architecture (Robert C. Martin): Zeer vergelijkbaar. Clean Architecture voegt meer lagen toe (entiteiten, use cases, interface adapters, frameworks), maar het kernidee is hetzelfde: afhankelijkheden wijzen naar binnen.
  • Onion Architecture (Jeffrey Palermo): Ook zeer vergelijkbaar. Onion Architecture gebruikt concentrische ringen met het domein in het centrum. De terminologie verschilt, maar de afhankelijkheidsregel is identiek.
  • Layered Architecture (N-tier): De traditionele aanpak waarbij lagen verticaal gestapeld worden (presentatie, business, data). Het cruciale verschil: in gelaagde architectuur is de businesslaag doorgaans afhankelijk van de datalaag. In hexagonale architectuur is die afhankelijkheid omgekeerd.

Alle drie de moderne patronen (hexagonaal, clean, onion) delen hetzelfde fundamentele principe. Het domein is niet afhankelijk van infrastructuur. Ze verschillen vooral in naamgevingsconventies en het aantal lagen dat ze voorschrijven.

Aan de slag

Als je hexagonale architectuur wilt toepassen op een bestaand project, probeer dan niet alles tegelijk te refactoren. Begin met één bounded context of één feature:

  1. Identificeer de kernbedrijfslogica in die feature. Extraheer deze naar pure functies of klassen zonder frameworkafhankelijkheden.
  2. Definieer ports voor de externe afhankelijkheden die die feature gebruikt. Creëer interfaces in domeintaal.
  3. Verplaats de bestaande infrastructuurcode naar adapterklassen die die interfaces implementeren.
  4. Verbind alles in een composition root met dependency injection.
  5. Schrijf tests voor het domein en de use cases met nep-adapters.

Herhaal voor de volgende feature. Na verloop van tijd zullen de hexagonale grenzen zich op natuurlijke wijze door de codebase verspreiden.

Slotgedachten

Hexagonale architectuur gaat niet over het volgen van een rigide sjabloon. Het gaat over één simpel idee: bescherm je bedrijfslogica tegen de chaos van de buitenwereld. Databases veranderen. Frameworks raken uit de mode. API’s worden verouderd. Je bedrijfsregels moeten dat allemaal overleven.

Het patroon werkt omdat het aansluit bij hoe software werkelijk evolueert. Vereisten veranderen voortdurend. Infrastructuur verandert periodiek. Maar de kernregels van je bedrijf zijn doorgaans het meest stabiele deel van het systeem. Bouw rond die stabiliteit.

Als je een product bouwt dat moet meegaan, is hexagonale architectuur een van de beste investeringen die je kunt doen in je codebase.


Hulp nodig bij het ontwerpen of refactoren van de architectuur van je applicatie? Neem contact op. We helpen teams software te bouwen die onderhoudbaar blijft naarmate het groeit.

architecturehexagonalports and adaptersclean codesoftware design

Laten we uw volgende project bouwen.

Boek een gratis gesprek van 30 minuten. We bespreken uw doelen, planning en de beste aanpak. Vrijblijvend.

Boek een kennismakingsgesprek hello@ryveris.com