hexagonal architecture javahexagonal architecture java
HappyCoders Glasses

Hexagonale Architektur mit Java Tutorial

Sven Woltmann
Sven Woltmann
Aktualisiert: 27. Dezember 2023

In diesem Artikel zeige ich dir Schritt für Schritt, wie man eine Java-Anwendung mit hexagonaler Architektur implementiert – und wie man die Einhaltung der Architekturregeln mit Maven und der Library „ArchUnit” sicherstellt.

Du wirst dabei die Vorteile der hexagonalen Architektur in der Praxis sehen:

Wir werden mit der Implementierung des Datenmodells und der Geschäftslogik beginnen und für eine ganze Weile ein reines Java-Projekt entwickeln. Technische Details wie REST-Controller und Datenbank werden wir erst relativ spät anbinden. Das bedeutet auch, dass wir die Entscheidung über die einzusetzende Technologie aufschieben können, bis wir etwas Erfahrung mit der Anwendung gesammelt haben.

In drei Folgeartikeln werden wir:

  1. die Persistenzlösung austauschen (von einer In-Memory-Lösung zu MySQL),
  2. die Anwendung in das Quarkus-Framework einbetten und
  3. Quarkus durch Spring ersetzen

... und all das, ohne auch nur eine Zeile Code im Anwendungskern ändern zu müssen (OK, wir werden vielleicht einige wenige Annotationen zur Transaktionskontrolle hinzufügen).

Wenn du dir noch einmal die Grundlagen der hexagonalen Architektur in Erinnerung rufen möchtest, empfehle ich dir zunächst den ersten Teil dieser Serie, Hexagonale Architektur – Was ist das? Was sind ihre Vorteile? zu lesen.

Hexagonale Architektur – Beispielanwendung

In diesem Tutorial werden wir gemeinsam eine kleine Beispielanwendung entwickeln. Diese stellt das (stark vereinfachte) Backend für einen Online-Shop bereit, der folgende Funktionalitäten umfasst:

  1. Suche nach Produkten
  2. Hinzufügen eines Produkts zum Warenkorb
  3. Abrufen des Warenkorbs mitsamt der Produkte, ihrer jeweiligen Anzahl und des Gesamtpreises
  4. Leeren des Warenkorbs

In der Geschäftslogik wollen wir die folgenden Vor- und Nachbedingungen sicherstellen:

  • Die Menge eines hinzuzufügenden Produkts muss mindestens eins betragen.
  • Nach dem Hinzufügen eines Produkts darf die Gesamtmenge dieses Produkts im Warenkorb die im Lager verfügbare Menge des Produkts nicht übersteigen.

Das ist auch schon alles. Die Anwendung ist absichtlich einfach gehalten, da der Fokus dieses Artikels auf der Architektur und nicht auf dem Funktionsumfang liegen soll.

Eingesetzte Technologien

Wir implementieren die Anwendung mit Java 20 (Mockito unterstützt das aktuelle LTS-Release Java 21 leider noch nicht) und zunächst ganz ohne Application Framework wie Spring oder Quarkus. Warum? Das Application Framework ist ein technisches Detail – und als solches sollte es nicht das Fundament einer Anwendung sein. Das Fundament einer hexagonalen Anwendung ist dessen Geschäftslogik!

Im vierten und fünften Teil der Serie werde ich euch dennoch zeigen, wie ihr die Anwendung in ein Application Framework einbetten könnt, denn dieses liefert viele nützliche nicht-funktionale Features wie deklaratives Transaktionsmanagement, Metrik- und Health-Endpoints, und vieles mehr.

Fürs Erste verwenden wir lediglich:

  • RESTEasy als Implementierung von Jakarta RESTful Web Services – auch bekannt als JAX-RS,
  • Undertow als leichtgewichtigen Webserver,
  • ArchUnit, um die Einhaltung der Architekturgrenzen zu verifizieren,
  • Project Lombok, um uns Boilerplate-Code zu ersparen.
Falls du bisher nicht mit Lombok gearbeitet hast:

Du musst dafür das Lombok-Plugin für deine IDE installieren. Hier ist ein Link zum IntelliJ-Plugin; die Plugins für andere IDEs findest du unter dem „Install”-Menüpunkt auf der Lombok-Webseite.

Zudem werden wir testgetrieben vorgehen:

  • Für jede Domain Entity schreiben wir einen Unit-Test.
  • Für jeden Domain Service schreiben wir einen Unit-Test.
  • Für jeden Adapter schreiben wir einen Integration-Test.
  • Für die wichtigsten Anwendungsfälle schreiben wir End-to-end-Tests.

Ich werde nicht alle Tests in diesem Artikel abdrucken, sondern nur je ein Beispiel für eine Entity, einen Service, einen primären und einen sekundären Adapter. Den vollständigen Quellcode zu diesem Artikel einschließlich einer vollständigen Testsuite findest du in diesem GitHub-Repository.

Am besten checkst du den without-jpa-adapters-Branch aus, denn der main-Branch enthält bereits die JPA-Persistenzlösung, die ich erst im nächsten Teil der Serie demonstrieren werde. Sie sollte ursprünglich Teil dieses Artikels werden; ich habe mich aber, als mir bewusst wurde, wie lang dieses Tutorial wird, entschieden, diesen Schritt auf einen Folgeartikel zu verschieben.

Ich werde außerdem das CI/CD-Tool „GitHub Actions” einsetzen, um die Anwendung auf GitHub zu bauen, zu testen und eine statische Code-Analyse durchzuführen. Darauf werden ich aber in diesem Artikel nicht näher eingehen. Die eingesetzte GitHub-Actions-Konfiguration werde ich in einem zukünftigen Artikel beschreiben.

Hexagonale Architektur – Projektstruktur

Die folgende Grafik zeigt die konkrete hexagonale Architektur, mit der wir die Shop-Anwendung aufsetzen werden.

Im Zentrum werden wir die Modellklassen für unseren Shop platzieren. Das Modell habe ich nicht als Hexagon dargestellt, da es nicht durch die hexagonale Architektur definiert ist (zur Erinnerung: die hexagonale Architektur lässt offen, was innerhalb des Anwendungshexagons geschieht).

Dennoch ist das Modell ein separates Modul, da es nicht auf die Ports zugreifen können soll. Nur die Domain Services, die die Geschäftslogik in den Modellklassen koordinieren, werden auf die Ports zugreifen.

Hexagonale Architektur der Beispielanwendung
Hexagonale Architektur der Beispielanwendung

Im Application-Hexagon implementieren wir die primären (links) und sekundären Ports (rechts) sowie die Domain Services – also die Geschäftsfunktionen (in Form einzelner Use Cases), die wiederum auf die sekundären Ports sowie die Geschäftslogik der Modellklassen zugreifen.

An die Ports werden wir drei Arten von Adaptern anschließen:

  • an den primären Port: einen REST-Adapter, über den wir die Shop-Funktionen aufrufen können,
  • an den sekundären Port: einen In-Memory-Adapter, der die Shop-Daten im RAM ablegt,
  • im nächsten Teil dieser Serie, ebenfalls an den sekundären Port: einen JPA-Adapter, der die Shop-Daten via Hibernate in einer MySQL-Datenbank persistiert.

Die Bootstrap-Komponente schließlich wird die Domain Services und Adapter instanziieren, einen Webserver starten und die Anwendung auf diesem deployen.

Die schwarzen Pfeile stellen die Aufrufrichtungen dar, die weißen Pfeile die Richtung der Quellcodeabhängigkeiten („Dependency Rule”).

Wie bilden wir diese Architektur nun auf den Quellcode ab? Dazu werden wir Module (in Form von Maven-Modulen) und, innerhalb der Module, Java-Pakete definieren. Module und Pakete sowie die Verzeichnisstruktur, über die diese abgebildet werden, werde ich in den folgenden Abschnitten beschreiben.

Hexagonale Architektur – Modulstruktur

Ich werde den Quellcode in vier Maven-Module aufteilen, die in vier Unterverzeichnissen des Projektverzeichnisses liegen werden:

modelEnthält das Domänenmodell, also diejenigen Klassen, die den Warenkorb und die Produkte repräsentieren. Gemäß Domain-driven Design werden wir hier in Entities (haben eine Identität und sind veränderbar) und Value Objects (haben keine Identität und sind immutable) unterscheiden.
applicationEnthält a) die Ports und b) die Domain Services, die die Use Cases implementieren.
application- und model-Modul bilden gemeinsam den Applikationskern.
adapterEnthält die REST- und Persistenz-Adapter.
bootstrapInstanziiert Adapter und Domain Services und startet den integrierten Undertow-Webserver.

Wir werden analog zur Projektstruktur-Grafik oben die Abhängigkeiten zwischen den Maven-Modulen wie folgt definieren:

Abhängigkeiten der Maven-Module
Abhängigkeiten der Maven-Module

Dadurch haben wir schon einmal sichergestellt, dass alle Quellcodeabhängigkeiten von außen Richtung Kern zeigen.

Später werde ich dir zeigen, wie du mit der Library „ArchUnit” sicherstellen kannst, dass ein Modul nur auf bestimmte Pakete eines anderen Moduls zugreifen kann, also z. B. die Adapter nur auf die Ports des application-Moduls, nicht aber auf die im gleichen Modul liegenden Domain Services.¹

¹ Falls du an dieser Stelle an das Java-Modulsystem „Jigsaw” denkst – auch darauf werde ich im Abschnitt Überwachung der Architekturgrenzen eingehen.

Hexagonale Architektur – Paketstruktur

Das Hauptpaket der Beispielanwendung ist eu.happycoders.shop. Innerhalb der Module werden wir folgende Paketstruktur anlegen (ich habe mich hier an dem hervorragenden Buch „Get Your Hands Dirty on Clean Architecture“ von Tom Hombergs orientiert):

Model

Das model-Modul befindet sich im Paket eu.happycoders.shop.model. Darunter werden wir im Laufe des Tutorials Unterpakete erstellen, um die verschiedenen Entities und Value Objects zu gruppieren. Aber diese wollen wir nicht im Voraus planen, sondern während der Entwicklung je nach Bedarf anlegen.

eu.happycoders.shop.model

Application

Das application-Modul befindet sich im Paket eu.happycoders.shop.application – mit zwei Unterpaketen für die Ports und die Domain Services. Die Ports wiederum teile ich in eingehende und ausgehende Ports. Bei den ausgehenden Ports lege ich noch ein Unterpaket persistence an, um die Applikation für spätere Erweiterungen, z. B. um ausgehende Ports für den Zugriff auf ein externes System, vorzubereiten.

eu.happycoders.shop.application
 ├ port
 │  ├ in
 │  └ out.persistence
 └ service

Adapter

Das adapter-Modul befindet sich im Paket eu.happycoders.shop.adapter. Darunter befinden sich – analog zu den Ports – die Pakete in und out.persistence.

Da Ports von verschiedenen Arten von Adaptern implementiert werden können, legen wir dafür entsprechende Unterpakete an:

  • in.rest für die REST-Adapter und
  • out.persistence.inmemory für die In-Memory-Persistenzlösung.

Wie zu Beginn des Tutorials erwähnt, werden wir die Anwendung später um eine MySQL-Persistenzlösung erweitern – diese werden wir dann im Paket out.persistence.jpa (im Folgenden in Klammern dargestellt) implementieren.

eu.happycoders.shop.adapter
 ├ in
 │  └ rest
 └ out.persistence
    ├ inmemory
    └ (jpa) 

Bootstrap

Die Bootstrap-Logik legen wir in das Paket eu.happycoders.shop.bootstrap.

eu.happycoders.shop.bootstrap

Hexagonale Architektur – Verzeichnisstruktur

Es ergibt sich folgende Verzeichnisstruktur für unsere Beispielanwendung (du brauchst diese Verzeichnisse nicht manuell anzulegen; das lassen wir später unsere IDE für uns machen):

project
 ├ bootstrap
 │  └ src
 │     ├ main
 │     │  └ java
 │     │     └ eu/happycoders/shop
 │     │        └ bootstrap
 │     └ test
 │        └ ...
 ├ adapter
 │  └ src
 │     ├ main
 │     │  └ java
 │     │     └ eu/happycoders/shop
 │     │        └ adapter
 │     │           ├ in
 │     │           │  └ rest
 │     │           └ out
 │     │              └ persistence
 │     │                 ├ inmemory
 │     │                 └ (jpa) 
 │     └ test
 │        └ ...
 ├ application
 │  └ src
 │     ├ main
 │     │  └ java
 │     │     └ eu/happycoders/shop
 │     │        └ application
 │     │           └ port
 │     │              ├ in
 │     │              └ out
 │     │                 └ persistence
 │     └ test
 │        └ ...
 └ model
    └ src
       ├ main
       │  └ java
       │     └ eu/happycoders/shop
       │        └ model
       └ test
          └ ...Code-Sprache: Klartext (plaintext)

Und nun genug der Theorie – lasst uns mit der Praxis beginnen!

Los geht's ... Aufsetzen des Projekts

Ich werde dir im Folgenden zeigen, wie du das Projekt mit IntelliJ IDEA aufsetzen kannst. Solltest du eine andere Lieblings-IDE haben, wirst du sicherlich wissen, wo du dort die entsprechenden Funktionen findest.

Wir legen zunächst über New Project im Welcome Screen oder über File→New→Project im Menü ein neues Projekt an. Als Build System wählen wir Maven aus. Ich verwende die aktuelle Java-Version 20; für das Tutorial genügt aber auch Java 16 (eine ältere Version wird nicht funktionieren, da wir Records einsetzen werden).

Die Checkbox bei „Add sample code” kannst du deaktivieren, da ansonsten eine Main-Klasse angelegt werden würde. Das wäre aber auch nicht schlimm; du könntest sie hinterher einfach wieder löschen.

Ganz unten solltest du noch die „Advanced Settings” öffnen und eine GroupId und ArtifactId eintragen. Du kannst für dieses Tutorial eu.happycoders.shop und hexagonal-architecture-java eintragen.

Hexagonale Architektur mit Java - Erstellen eines neuen Projekts
Erstellen eines neuen Projekts

Nachdem das Projekt erstellt ist, legen wir zunächst die vier Module an. Dazu kannst du einfach mit der rechten Maustaste auf das Projekt klicken und dann New→Module auswählen:

Hexagonale Architektur mit Java - Anlegen eines neuen Moduls
Anlegen eines neuen Moduls

Im folgenden Dialogfenster gibst du als Modulname „model” ein. Die ArtifactId ganz unten sollte sich dadurch automatisch auch auf „model” ändern.

Hexagonale Architektur mit Java - Anlegen des „model”-Moduls
Anlegen des model-Moduls

Das ganze wiederholst du für die Module application, adapter und bootstrap. Danach sollte das Projekt wie folgt aussehen:

Hexagonale Architektur mit Java - Alle vier Module sind angelegt
Alle vier Module sind angelegt

Mich stört, dass IntelliJ in den pom.xml-Dateien eines jeden Moduls den <properties>-Block wiederholt, der bereits in der Parent-POM definiert ist und somit an die Module vererbt wird. Wir entfernen daher den <properties>-Block aus allen Modulen.

Wir können nun ein Terminalfenster öffnen und mvn clean compile ausführen, um sicherzustellen, dass uns dabei kein Fehler unterlaufen ist.

Nachdem das Grundgerüst steht, können wir mit der Implementierung des Domänenmodells beginnen.

Implementierung des Domänenmodells

Bevor wir mit der Implementierung beginnen, müssen wir uns ein paar Gedanken über die Modellierung der Domain machen. Ich zeige dir im Folgenden, wie ich dabei nach taktischem Design, einer Disziplin des Domain-driven Designs, vorgehe.

Modellierung der Domain

Du erinnerst dich sicher aus dem ersten Teil: Wir wollen nicht Datenbank-getrieben modellieren, d. h. wir wollen uns nicht zuerst überlegen, wie wir Einkaufswagen und Produkte in einer Datenbank speichern. Stattdessen beginnen wir mit der Planung eines Objekt-Modells.

Ich beginne in der Regel mit einer ganz groben Planung. Wir benötigen offensichtlich eine Product- und eine Cart-Klasse. Da wir von einem Produkt eine bestimmte Menge in den Warenkorb legen können, brauchen wir dazwischen noch eine Klasse, die ein Product und eine Anzahl speichert: ein CartLineItem.

Hier siehst du die erste grobe Planung als UML-Diagramm:

Hexagonale Architektur mit Java - Beispiel-Modellklassen, Iteration 1
Shop-Modellklassen, Iteration 1

Ein Cart ist eine Komposition von CartLineItems (Komposition bedeutet, dass ein CartLineItem ohne Cart nicht existieren könnte). Ein CartLineItem wiederum speichert eine Anzahl (quantity) sowie eine Referenz auf ein Product.

Cart und Product sind Entities, also Objekte mit einer Identität, die wir über ein Repository speichern und wieder laden können. Für jeden Kunden soll es genau einen Warenkorb geben, daher verwende ich für die Identifizierung des Warenkorbs eine CustomerId. Produkte identifizieren wir über eine ProductId.

CustomerId und ProductId sind Value Objects in der Domain-driven-Design-Terminologie. Hier siehst du das Klassendiagramm um die zwei IDs erweitert:

Hexagonale Architektur mit Java - Beispiel-Modellklassen, Iteration 2
Shop-Modellklassen, Iteration 2

Um ein Produkt anzuzeigen, brauchen wir dessen Namen (name), eine Beschreibung (description) und den Preis (price). Den Preis modellieren wir als ein Value Object mit dem Namen Money, das einen Betrag (amount) und eine Währung (currency) enthält:

Hexagonale Architektur mit Java - Beispiel-Modellklassen, Iteration 3
Shop-Modellklassen, Iteration 3

Als nächstes brauchen wir eine Methode, um ein Produkt zum Warenkorb hinzuzufügen: Cart.addProduct(…). Wenn wir ein Produkt mehrfach hinzufügen, müssen wir die Menge eines existierenden Eintrags erhöhen; dafür ist die Methode CartLineItem.increaseQuantityBy(…).

Um festzustellen, ob ein Produkt überhaupt verfügbar ist, benötigen wir außerdem die Anzahl der Produkte im Lager: Product.itemsInStock:

Hexagonale Architektur mit Java - Beispiel-Modellklassen, Iteration 4
Shop-Modellklassen, Iteration 4

Letztendlich wollen wir zu einem Warenkorb noch den Gesamtpreis anzeigen: Methode subTotal() in Cart und CartLineItem – und die Gesamtanzahl der Produkte: Methode Cart.numberOfItems():

Hexagonale Architektur mit Java - Beispiel-Modellklassen, Iteration 5
Shop-Modellklassen, Iteration 5

Im nächsten Abschnitt zeige ich dir die Implementierung der Modellklassen in Java.

In einem echten Projekt führe ich Modellierung und Implementierung übrigens nicht in zwei separaten Schritten durch. Ich generiere auch nicht so detaillierte Klassendiagramme, sondern skizziere in der Regel bloß einen ersten Entwurf auf Papier. Für dieses Tutorial erschien es mir übersichtlicher, Modellierung und Implementierung in zwei Schritten darzustellen.

Implementierung der Domain-Klassen

Der Übersicht halber gruppiere ich die Klassen in vier Unterpakete im model-Modul unter dem Paket eu.happycoders.shop.model (im Screenshot siehst du auch die bisher nicht erwähnte Klasse NotEnoughItemsInStockException – auf diese werde ich in Kürze zu sprechen kommen):

Hexagonale Architektur mit Java - Modellklassen

Im Folgenden zeige ich dir den Code der Produktivklassen und einen beispielhaften Unit-Test. Tatsächlich bin ich testgetrieben vorgegangen und habe zu jeder Klasse vorab Tests erstellt. Diese findest du im model/src/test/java-Verzeichnis im GitHub-Repository.

CustomerId

Beginnen wir mit der CustomerId (der Link führt zur Klasse im GitHub-Repository) – diese ist als Record implementiert und stellt einen Wrapper um einen int-Wert dar:

package eu.happycoders.shop.model.customer;

public record CustomerId(int value) {

  public CustomerId {
    if (value < 1) {
      throw new IllegalArgumentException("'value' must be a positive integer");
    }
  }
}
Code-Sprache: Java (java)

Wozu benötigen wir solch einen Wrapper? Können wir die Kundennummer nicht direkt als int-Wert im Cart ablegen? Könnten wir – das wäre allerdings ein Code Smell, der sich „Primitive Obsession” nennt. Der Wrapper hat zwei Vorteile:

  • Wir können sicherstellen, dass der Wert gültig ist. Im Beispiel muss die Kundennummer eine positive Zahl sein.
  • Wir können die Kundennummer typsicher an Methoden übergeben. Wäre die Kundennummer ein int-Primitiv, könnten wir bei einer Methode mit mehreren int-Parametern versehentlich die Parameter vertauschen.

ProductId

Die Klasse ProductId ist ein Wrapper um einen String und bietet die statische Methode randomProductId(), um eine zufällige Produkt-ID zu generieren.

Ich werde ich den folgenden Listings die Imports nicht mit abdrucken; moderne IDEs sind in der Lage, diese automatisch hinzuzufügen.

package eu.happycoders.shop.model.product;

// ... imports ...

public record ProductId(String value) {

  private static final String ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
  private static final int LENGTH_OF_NEW_PRODUCT_IDS = 8;

  public ProductId {
    Objects.requireNonNull(value, "'value' must not be null");
    if (value.isEmpty()) {
      throw new IllegalArgumentException("'value' must not be empty");
    }
  }

  public static ProductId randomProductId() {
    ThreadLocalRandom random = ThreadLocalRandom.current();
    char[] chars = new char[LENGTH_OF_NEW_PRODUCT_IDS];
    for (int i = 0; i < LENGTH_OF_NEW_PRODUCT_IDS; i++) {
      chars[i] = ALPHABET.charAt(random.nextInt(ALPHABET.length()));
    }
    return new ProductId(new String(chars));
  }
}
Code-Sprache: Java (java)

Die randomProductId()-Methode werden wir in Unit-Tests und später zur Erzeugung von Demo-Produkten verwenden.

Money

Die Money-Klasse ist ein Record mit zwei Feldern – currency und amount – sowie einer statischen Factory-Methode und Methoden, um zwei Geldbeträge zu addieren und einen Geldbetrag zu multiplizieren:

package eu.happycoders.shop.model.money;

// ... imports ...

public record Money(Currency currency, BigDecimal amount) {

  public Money {
    Objects.requireNonNull(currency, "'currency' must not be null");
    Objects.requireNonNull(amount, "'amount' must not be null");
    if (amount.scale() > currency.getDefaultFractionDigits()) {
      throw new IllegalArgumentException(
          ("Scale of amount %s is greater "
                  + "than the number of fraction digits used with currency %s")
              .formatted(amount, currency));
    }
  }

  public static Money of(Currency currency, int mayor, int minor) {
    int scale = currency.getDefaultFractionDigits();
    return new Money(
        currency, BigDecimal.valueOf(mayor).add(BigDecimal.valueOf(minor, scale)));
  }

  public Money add(Money augend) {
    if (!this.currency.equals(augend.currency())) {
      throw new IllegalArgumentException(
          "Currency %s of augend does not match this money's currency %s"
              .formatted(augend.currency(), this.currency));
    }

    return new Money(currency, amount.add(augend.amount()));
  }

  public Money multiply(int multiplicand) {
    return new Money(currency, amount.multiply(BigDecimal.valueOf(multiplicand)));
  }
}Code-Sprache: Java (java)

Im main-Branch des GitHub-Repositories wirst du noch zwei weitere Methoden, ofMinor(…) und amountInMinorUnit(), sehen. Diese benötigen wir erst im folgenden Teil der Serie, um einen Geldbetrag in der Datenbank abzuspeichern.

Product

Ein Produkt ist veränderlich: der Verkäufer soll z. B. die Beschreibung und den Preis ändern können. Es kann daher nicht als Record implementiert werden.

Um uns die Schreibarbeit für Getter und Setter zu ersparen, können wir Lombok-Annotationen einsetzen. Dazu musst du zunächst die entsprechende Dependency in die pom.xml-Datei im Root-Verzeichnis des Projekts eintragen:

<dependencies>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.30</version>
        <scope>provided</scope>
    </dependency>
</dependencies>Code-Sprache: HTML, XML (xml)

Danach können wir die Product-Klasse wie folgt implementieren:

package eu.happycoders.shop.model.product;

// ... imports ...

@Data
@Accessors(fluent = true)
@AllArgsConstructor
public class Product {

  private final ProductId id;
  private String name;
  private String description;
  private Money price;
  private int itemsInStock;
}Code-Sprache: Java (java)

CartLineItem

Weiter geht es mit dem CartLineItem. Da sich die Anzahl eines Produkts im Warenkorb ändern kann, ist auch dieses kein Record, sondern eine reguläre Klasse mit Lombok-Annotationen.

Allerdings verwende ich hier @Getter anstelle der @Data-Annotation. Ich möchte hier keine Setter haben, denn das einzige Feld, das sich ändern kann, ist quantity. Und das soll nur über die Methode increaseQuantityBy(…) geändert werden können (Kapselung!).

package eu.happycoders.shop.model.cart;

// ... imports ...

@Getter
@Accessors(fluent = true)
@RequiredArgsConstructor
@AllArgsConstructor
public class CartLineItem {

  private final Product product;
  private int quantity;

  public void increaseQuantityBy(int augend, int itemsInStock)
      throws NotEnoughItemsInStockException {
    if (augend < 1) {
      throw new IllegalArgumentException("You must add at least one item");
    }

    int newQuantity = quantity + augend;
    if (itemsInStock < newQuantity) {
      throw new NotEnoughItemsInStockException(
          ("Product %s has less items in stock (%d) "
                  + "than the requested total quantity (%d)")
              .formatted(product.id(), product.itemsInStock(), newQuantity),
          product.itemsInStock());
    }

    this.quantity = newQuantity;
  }

  public Money subTotal() {
    return product.price().multiply(quantity);
  }
}Code-Sprache: Java (java)

Hier haben wir Geschäftslogik, nämlich das Erhöhen der Anzahl eines Produkts im Warenkorb, innerhalb einer Modellklasse implementiert. Das nennt sich „Rich Domain Model” – im Gegensatz zu einem „Anemic Domain Model”, bei dem die Modellklassen lediglich Felder, Getter und Setter enthalten, und die Geschäftslogik in Service-Klassen implementiert ist.

NotEnoughItemsInStockException

Die increaseQuantityBy(…)-Methode prüft auch die Vor- und Nachbedingungen. Ist die Vorbedingung nicht erfüllt (die hinzuzufügende Anzahl ist kleiner als eins), wirft die Methode eine IllegalArgumentException (denn es sollte gar nicht erst möglich sein, die Methode mit einer zu niedrigen Anzahl aufzurufen).

Ist die Nachbedingung nicht erfüllt (der Warenkorb darf nicht mehr als die verfügbare Anzahl Artikel enthalten), wirft die Methode eine NotEnoughItemsInStockException. Diese enthält die verfügbare Menge als Parameter, sodass wir im Frontend eine entsprechende Meldung anzeigen können:

package eu.happycoders.shop.model.cart;

public class NotEnoughItemsInStockException extends Exception {

  private final int itemsInStock;

  public NotEnoughItemsInStockException(String message, int itemsInStock) {
    super(message);
    this.itemsInStock = itemsInStock;
  }

  public int itemsInStock() {
    return itemsInStock;
  }
}
Code-Sprache: Java (java)

Cart

Kommen wir zum Kern des Modells, der Cart-Klasse. Sie speichert die Warenkorbeinträge in einer Map von ProductId zu CartLineItem, sodass wir beim Hinzufügen eines Produkts – in der Methode addProduct(…) – prüfen können, ob sich dieses bereits im Warenkorb befindet.

Die Methode lineItems() gibt eine Kopie der in der Map enthaltenen Werte zurück, sodass die lineItems-Datenstruktur nicht von außerhalb der Klasse verändert werden kann.

package eu.happycoders.shop.model.cart;

// ... imports ...

@Accessors(fluent = true)
@RequiredArgsConstructor
public class Cart {

  @Getter private final CustomerId id;

  private final Map<ProductId, CartLineItem> lineItems = new LinkedHashMap<>();

  public void addProduct(Product product, int quantity)
      throws NotEnoughItemsInStockException {
    lineItems
        .computeIfAbsent(product.id(), ignored -> new CartLineItem(product))
        .increaseQuantityBy(quantity, product.itemsInStock());
  }

  public List<CartLineItem> lineItems() {
    return List.copyOf(lineItems.values());
  }

  public int numberOfItems() {
    return lineItems.values().stream().mapToInt(CartLineItem::quantity).sum();
  }

  public Money subTotal() {
    return lineItems.values().stream()
        .map(CartLineItem::subTotal)
        .reduce(Money::add)
        .orElse(null);
  }
}Code-Sprache: Java (java)

Damit ist unser Datenmodell fertig implementiert. Im folgenden Abschnitt zeige ich dir noch einen beispielhaften Unit-Test.

Falls auch du dich immer wieder fragst, wie du nicht benötigte Variablen bezeichnen sollst (wie die ignored-Variable in der computeIfAbsent()-Methode oben), dann habe ich eine gute Nachricht für dich: Ab Java 21 kannst du (zunächst als Preview-Feature) unbenötigte Variablen mit „_” bezeichnen.

CartTest

Wie angekündigt zeige ich dir hier einen beispielhaften Unit-Test. Dafür müssen wir vorab Dependencies zu JUnit und AssertJ in die pom.xml im Rootverzeichnis (wir werden diese Dependencies in allen Modulen benötigen) eintragen:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.0</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.24.2</version>
    <scope>test</scope>
</dependency>
Code-Sprache: HTML, XML (xml)

Tatsächlich habe ich das vorab gemacht und die Tests gemeinsam mit den Entity-Klassen geschrieben. Da der Fokus dieses Artikels nicht auf TDD (Test-driven Development) liegt, zeige ich die Tests erst an dieser Stelle. Hier siehst du einen Screenshot aller Modell-Tests:

Hexagonale Architektur mit Java - Modell-Testklassen

Die im vorherigen Abschnitt beschriebene Modellklasse Cart testen wir mit der CartTest-Klasse (ich drucke hier zwei statische Imports mit ab, die zumindest IntelliJ nicht selbstständig auflösen kann):

package eu.happycoders.shop.model.cart;

import static eu.happycoders.shop.model.money.TestMoneyFactory.euros;
import static org.assertj.core.api.Assertions.assertThat;
// ... more imports ...

class CartTest {

  @Test
  void givenEmptyCart_addTwoProducts_numberOfItemsAndSubTotalIsCalculatedCorrectly()
      throws NotEnoughItemsInStockException {
    Cart cart = TestCartFactory.emptyCartForRandomCustomer();

    Product product1 = TestProductFactory.createTestProduct(euros(12, 99));
    Product product2 = TestProductFactory.createTestProduct(euros(5, 97));

    cart.addProduct(product1, 3);
    cart.addProduct(product2, 5);

    assertThat(cart.numberOfItems()).isEqualTo(8);
    assertThat(cart.subTotal()).isEqualTo(euros(68, 82));
  }

  // more tests

}Code-Sprache: Java (java)

Der Test erzeugt über die TestCartFactory einen leeren Warenkorb und über die TestProductFactory und TestMoneyFactory zwei Produkte zu 12,99 € und 5,97 €, fügt dann dem Warenkorb drei mal Produkt 1 hinzu und fünf mal Produkt 2 und verifiziert anschließend, dass der Warenkorb insgesamt acht Produkte zu einem Gesamtpreis von 68,82 € enthält.

Dies ist nur ein Test von vielen. Im GitHub-Repository findest du zahlreiche weitere Modell-Tests.

Falls du das Tutorial bis hierhin nachprogrammierst und die Tests aus dem GitHub-Repository übernommen hast, kannst du in einem Terminal mit mvn clean test ausprobieren, ob alle Tests erfolgreich durchlaufen.

Implementierung des Application-Hexagons

Im Application-Hexagon implementieren wir Ports und Domain Services für die folgenden Use Cases:

  1. Suche nach Produkten
  2. Hinzufügen eines Produkts zum Warenkorb
  3. Abrufen des Warenkorbs mitsamt der Produkte, ihrer jeweiligen Anzahl und des Gesamtpreises
  4. Leeren des Warenkorbs

Das Single-Responsibility-Prinzip besagt, dass es nur einen Grund geben sollte, um eine Klasse zu ändern. Dementsprechend werde ich für jeden Use Case einen separaten primären Port und eine separate Service-Klasse anlegen. In der Beispielanwendung wird das keinen großen Unterschied machen, doch in echten Anwendung begegne ich regelmäßig unübersichtlichen und schwer wartbaren Service-Klassen mit mehreren Tausend Zeilen Code. Dem beugen wir so von Anfang an vor.

Den primären Ports gebe ich Klassennamen, die auf UseCase enden.

Die sekundären Ports werden zur Persistierung von Entities verwendet, entsprechen in der Domain-driven-Design-Terminologie also Repositories. Da DDD-Repositories sowohl zum Speichern als auch zum Laden von Entities eingesetzt werden und in der Regel nicht groß werden, werde ich diese nicht weiter unterteilen.

Das folgende UML-Klassendiagramm zeigt alle Ports (oben die primären, unten die sekundären) und Services (in der mittleren Reihe) des Application-Hexagons. Die primären Ports und Services sind von links nach rechts in derselben Reihenfolge angeordnet wie die Use Cases in der Aufzählung zu Beginn dieses Abschnitts.

Hexagonale Architektur mit Java - Ports und Services der Beispielanwendung
Ports und Services der Beispielanwendung

Der Übersicht halber habe ich unter den Paketen für die primären Ports und Services jeweils zwei Unterpakete, product und cart, angelegt. Der folgende Screenshot zeigt die Pakete und Klassen des application-Moduls in der IDE:

Hexagonale Architektur mit Java - Application-Klassen

Im Folgenden werde ich dir die Implementierungen der Ports und Services zeigen, und zwar Use Case für Use Case.

Doch zuerst müssen wir noch dafür sorgen, dass das application-Modul auf das model-Modul zugreifen kann. Das machen wir mit folgendem Eintrag in der pom.xml-Datei des application-Moduls:

<dependencies>
    <dependency>
        <groupId>eu.happycoders.shop</groupId>
        <artifactId>model</artifactId>
        <version>${project.version}</version>
    </dependency>
</dependencies>
Code-Sprache: HTML, XML (xml)

Und jetzt geht es los mit den Use Cases...

Use Case 1: Suche nach Produkten

Beschreibung des Use Case: Die Kundin soll einen Text in ein Suchfeld eingeben können. Der Suchtext soll mindestens zwei Zeichen lang sein. Es sollen alle Produkte zurückgeliefert werden, bei denen der Suchtext im Titel oder in der Beschreibung vorkommt.

Wir beginnen mit unserem ersten primären Port, FindProductsUseCase:

package eu.happycoders.shop.application.port.in.product;

// ... imports ...

public interface FindProductsUseCase {

  List<Product> findByNameOrDescription(String query);
}
Code-Sprache: Java (java)

Das ist tatsächlich schon alles. Komplizierter werden die Ports nicht.

Implementiert wird der Port durch die Klasse FindProductsService. Der Service muss auf den sekundären Port ProductRepository zugreifen (diesen findest du im Anschluss), um die Produkte in der eingesetzten Persistenzlösung zu suchen.

Den Port – oder besser gesagt: dessen Implementierung – übergeben wir später im bootstrap-Modul an den Konstruktor des Services („Constructor Depencency Injection”).

package eu.happycoders.shop.application.service.product;

// ... imports ...

public class FindProductsService implements FindProductsUseCase {

  private final ProductRepository productRepository;

  public FindProductsService(ProductRepository productRepository) {
    this.productRepository = productRepository;
  }

  @Override
  public List<Product> findByNameOrDescription(String query) {
    Objects.requireNonNull(query, "'query' must not be null");
    if (query.length() < 2) {
      throw new IllegalArgumentException("'query' must be at least two characters long");
    }

    return productRepository.findByNameOrDescription(query);
  }
}Code-Sprache: Java (java)

Und hier ist das injizierte ProductRepository (das Interface im GitHub-Repository hat bereits zwei weitere Methoden – eine für den nächsten Use Case und eine für Integration Tests):

package eu.happycoders.shop.application.port.out.persistence;

// ... imports ...

public interface ProductRepository {

  List<Product> findByNameOrDescription(String query);
}
Code-Sprache: Java (java)

Damit ist der Use Case aus Sicht des Application-Hexagons fertig implementiert. Wie die Produkte in der Persistenzlösung gesucht werden, ist später Sache desjenigen Adapters, der das ProductRepository-Interface (also den sekundären Port) implementieren wird.

Use Case 2: Hinzufügen eines Produkts zum Warenkorb

Beschreibung des Use Case: Der Kunde soll ein Produkt in einer bestimmten Anzahl zu seinem Warenkorb hinzufügen können.

Hier zunächst der primäre Port, AddToCartUseCase:

package eu.happycoders.shop.application.port.in.cart;

// ... imports ...

public interface AddToCartUseCase {

  Cart addToCart(CustomerId customerId, ProductId productId, int quantity)
      throws ProductNotFoundException, NotEnoughItemsInStockException;
}Code-Sprache: Java (java)

Die einzige Methode dieses Interfaces deklariert zwei Exceptions. Die NotEnoughItemsInStockException haben wir bereits im vorherigen Kapitel im model-Modul definiert.

Die zweite Exception, ProductNotFoundException kommt nicht aus dem Modell, sondern wird im Application-Hexagon, im gleichen Paket wie der Port, definiert. Denn ob ein Produkt existiert oder nicht, wird beim Zugriff des Services auf das Repository (also in der Applikation, nicht im Modell) ermittelt.

Der Quellcode der Exception ist trivial:

package eu.happycoders.shop.application.port.in.cart;

public class ProductNotFoundException extends Exception {}
Code-Sprache: Java (java)

Es folgt der Quellcode der Use-Case-Implementierung, AddToCartService.

Die Methode addToCart(…) validiert zunächst die Eingabeparameter, lädt Produkt und Warenkorb aus Repositories (bzw. legt einen neuen Warenkorb an), fügt dem Warenkorb das Produkt in der gewünschten Menge hinzu und speichert den Warenkorb wieder:

package eu.happycoders.shop.application.service.cart;

// ... imports ...

public class AddToCartService implements AddToCartUseCase {

  private final CartRepository cartRepository;
  private final ProductRepository productRepository;

  public AddToCartService(
      CartRepository cartRepository, ProductRepository productRepository) {
    this.cartRepository = cartRepository;
    this.productRepository = productRepository;
  }

  @Override
  public Cart addToCart(CustomerId customerId, ProductId productId, int quantity)
      throws ProductNotFoundException, NotEnoughItemsInStockException {
    Objects.requireNonNull(customerId, "'customerId' must not be null");
    Objects.requireNonNull(productId, "'productId' must not be null");
    if (quantity < 1) {
      throw new IllegalArgumentException("'quantity' must be greater than 0");
    }

    Product product =
        productRepository.findById(productId).orElseThrow(ProductNotFoundException::new);

    Cart cart =
        cartRepository
            .findByCustomerId(customerId)
            .orElseGet(() -> new Cart(customerId));

    cart.addProduct(product, quantity);

    cartRepository.save(cart);

    return cart;
  }
}
Code-Sprache: Java (java)

Bei diesem Service injizieren wir zwei Repositories (sekundäre Ports):

Erstens das bereits bekannte ProductRepository – dieses erweitern wir um die Methode findById(…), um ein konkretes Produkt zu laden – und auch gleich um die Methode save(…), die wir später brauchen werden, um Testprodukte anzulegen:

package eu.happycoders.shop.application.port.out.persistence;

// ... imports ...

public interface ProductRepository {

  void save(Product product);

  Optional<Product> findById(ProductId productId);

  List<Product> findByNameOrDescription(String query);
}
Code-Sprache: Java (java)

Und zweitens das neue CartRepository, um den Warenkorb zu speichern und zu laden (im GitHub-Repository findest du bereits eine dritte Methode, deleteById(…), die wir für den vierten Use Case hinzufügen werden):

package eu.happycoders.shop.application.port.out.persistence;

// ... imports ...

public interface CartRepository {

  void save(Cart cart);

  Optional<Cart> findByCustomerId(CustomerId customerId);
}
Code-Sprache: Java (java)

Damit ist auch der zweite Use Case fertig implementiert; kommen wir zum dritten...

Use Case 3: Abrufen des Warenkorbs

Beschreibung des Use Case: Die Kundin soll ihren Warenkorb abrufen können mitsamt der Produkte, ihrer jeweiligen Anzahl, der Gesamtanzahl an Produkten und des Gesamtpreises.

Wir beginnen wieder mit dem primären Port, GetCartUseCase:

package eu.happycoders.shop.application.port.in.cart;

// ... imports ...

public interface GetCartUseCase {

  Cart getCart(CustomerId customerId);
}Code-Sprache: Java (java)

Die Implementierung, GetCartService, leitet den Aufruf an die bereits für den vorherigen Use Case angelegte Repository-Methode findByCustomerId(…) weiter. Wenn kein Warenkorb existiert, wird ein neuer angelegt, sodass diese Methode niemals null zurückliefern wird.

package eu.happycoders.shop.application.service.cart;

// ... imports ...

public class GetCartService implements GetCartUseCase {

  private final CartRepository cartRepository;

  public GetCartService(CartRepository cartRepository) {
    this.cartRepository = cartRepository;
  }

  @Override
  public Cart getCart(CustomerId customerId) {
    Objects.requireNonNull(customerId, "'customerId' must not be null");

    return cartRepository
        .findByCustomerId(customerId)
        .orElseGet(() -> new Cart(customerId));
  }
}Code-Sprache: Java (java)

Beachte, dass wir die Berechnung der Gesamtanzahl der Produkte und des Gesamtpreises bereits in der Modellklasse Cart implementiert haben.

Kommen wir zum vierten und letzten Use Case...

Use Case 4: Leeren des Warenkorbs

Beschreibung des Use Case: Der Kunde soll seinen Warenkorb vollständig leeren können.

Zunächst wieder der primären Port, EmptyCartUseCase:

package eu.happycoders.shop.application.port.in.cart;

// ... imports ...

public interface EmptyCartUseCase {

  void emptyCart(CustomerId customerId);
}Code-Sprache: Java (java)

Und dessen Implementierung, EmptyCartService. Wir leeren einen Warenkorb, indem wir ihn löschen. Wenn der User ihn wieder abfragt, liefert der im vorherigen Abschnitt beschriebene GetCartUseCase einfach ein neues Cart-Objekt zurück.

package eu.happycoders.shop.application.service.cart;

// ... imports ...

public class EmptyCartService implements EmptyCartUseCase {

  private final CartRepository cartRepository;

  public EmptyCartService(CartRepository cartRepository) {
    this.cartRepository = cartRepository;
  }

  @Override
  public void emptyCart(CustomerId customerId) {
    Objects.requireNonNull(customerId, "'customerId' must not be null");

    cartRepository.deleteById(customerId);
  }
}Code-Sprache: Java (java)

Für diesen Use Case fügen wir dem sekundären Port CartRepository eine letzte Methode, deleteById(…) hinzu; er sieht damit wie folgt aus:

package eu.happycoders.shop.application.port.out.persistence;

// ... imports ...

public interface CartRepository {

  void save(Cart cart);

  Optional<Cart> findByCustomerId(CustomerId customerId);

  void deleteById(CustomerId customerId);
}
Code-Sprache: Java (java)

Damit ist das Application-Hexagon – und damit auch die Geschäftslogik – vollständig implementiert.

Noch ein Hinweis zur Sicherheit: In einer echten Anwendung würden wir die Kundennummer natürlich nicht einfach als Parameter übergeben, sondern die Kunden sich authentifizieren lassen.

Unit-Tests für Domain Services

Auch bei den Domain Services im application-Modul bin ich testgetrieben vorgegangen. Ich werde hier aber, wie angekündigt, nur einen beispielhaften Test abdrucken.

Der Screenshot aus meiner IDE gibt einen Überblick über alle Domain-Service-Tests, wie du sie im GitHub-Repository finden wirst:

Hexagonale Architektur mit Java - Application-Testklassen

Die Ports, mit denen die Services kommunizieren, werden wir mocken. Dafür benötigen wir eine Dependency zu Mockito in der pom.xml des Projektverzeichnisses:

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>5.5.0</version>
    <scope>test</scope>
</dependency>
Code-Sprache: HTML, XML (xml)

Im model-Modul liegen einige Factory-Klassen, z. B. TestProductFactory und TestMoneyFactory, um Test-Produkte zu erzeugen. Doch obwohl wir eine Dependency vom application-Modul auf das model-Modul definiert haben, können wir nicht ohne weiteres aus dem application-Modul auf die Testklassen des model-Moduls zugreifen.

Diese müssen wir zunächst aus dem model-Modul exportieren, indem wir eine sogenannte „Attached Test JAR” erzeugen. Das machen wir mit folgendem Eintrag in der pom.xml des model-Moduls:

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-jar-plugin</artifactId>
            <version>3.3.0</version>
            <executions>
                <execution>
                    <goals>
                        <goal>test-jar</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>Code-Sprache: HTML, XML (xml)

Als nächstes müssen wir die „Attached Test JAR” im application-Modul importieren. Dazu tragen wir folgende Dependency in die pom.xml des application-Moduls ein:

<dependency>
    <groupId>eu.happycoders.shop</groupId>
    <artifactId>model</artifactId>
    <version>${project.version}</version>
    <classifier>tests</classifier>
    <type>test-jar</type>
    <scope>test</scope>
</dependency>
Code-Sprache: HTML, XML (xml)

Damit ist alles vorbereitet für die eigentlichen Tests.

Test für Use Case 2: Hinzufügen eines Produkts zum Warenkorb

In den Domainklassen-Tests habe ich gezeigt, wie wir die Cart.addProduct(…)-Methode testen können. Passend dazu zeige ich dir hier, wie wir AddToCartService.addToCart(…) testen können.

Du findest im Folgenden einen auszugsweisen Abdruck der Klasse AddToCartServiceTest. Ich habe wieder nur diejenigen statischen Imports mit abgedruckt, die meine IDE nicht automatisch erkennen kann.

Im ersten Code-Block erstellen wir eine Test-Kundennnummer und zwei Testprodukte mit Hilfe der aus dem model-Modul importieren Test-Factories. Kundennummer und Testprodukte speichern wir in statischen Variablen, um sie in allen Tests verwenden zu können.

Im zweiten Code-Block erzeugen wir mit Mockito Test-Doubles für die sekundären Ports, cartRepository und productRepository. Diese injizieren wir über den Konstruktor in den AddToCartService. Test-Doubles und Service sind nicht statisch, da diese für jeden Test frisch initialisiert sein sollen (JUnit legt für jeden Test eine neue Instanz der Testklasse an).

In der mit @BeforeEach annotierten initTestDoubles()-Methode definieren wir via Mockito.when(…), dass die findById(…)-Methode des sekundäre Ports productRepository die Testprodukte zurückliefert.

package eu.happycoders.shop.application.service.cart;

import static eu.happycoders.shop.model.money.TestMoneyFactory.euros;
import static eu.happycoders.shop.model.product.TestProductFactory.createTestProduct;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
// ... more imports ...

class AddToCartServiceTest {

  private static final CustomerId TEST_CUSTOMER_ID = new CustomerId(61157);
  private static final Product TEST_PRODUCT_1 = createTestProduct(euros(19, 99));
  private static final Product TEST_PRODUCT_2 = createTestProduct(euros(25, 99));

  private final CartRepository cartRepository = mock(CartRepository.class);
  private final ProductRepository productRepository = mock(ProductRepository.class);
  private final AddToCartService addToCartService =
      new AddToCartService(cartRepository, productRepository);

  @BeforeEach
  void initTestDoubles() {
    when(productRepository.findById(TEST_PRODUCT_1.id()))
        .thenReturn(Optional.of(TEST_PRODUCT_1));

    when(productRepository.findById(TEST_PRODUCT_2.id()))
        .thenReturn(Optional.of(TEST_PRODUCT_2));
  }

  @Test
  void givenExistingCart_addToCart_cartWithAddedProductIsSavedAndReturned()
      throws NotEnoughItemsInStockException, ProductNotFoundException {
    // Arrange
    Cart persistedCart = new Cart(TEST_CUSTOMER_ID);
    persistedCart.addProduct(TEST_PRODUCT_1, 1);

    when(cartRepository.findByCustomerId(TEST_CUSTOMER_ID))
        .thenReturn(Optional.of(persistedCart));

    // Act
    Cart cart = addToCartService.addToCart(TEST_CUSTOMER_ID, TEST_PRODUCT_2.id(), 3);

    // Assert
    verify(cartRepository).save(cart);

    assertThat(cart.lineItems()).hasSize(2);
    assertThat(cart.lineItems().get(0).product()).isEqualTo(TEST_PRODUCT_1);
    assertThat(cart.lineItems().get(0).quantity()).isEqualTo(1);
    assertThat(cart.lineItems().get(1).product()).isEqualTo(TEST_PRODUCT_2);
    assertThat(cart.lineItems().get(1).quantity()).isEqualTo(3);
  }

  // more tests

}Code-Sprache: Java (java)

In der eigentlichen Testmethode legen wir zunächst einen Warenkorb an und fügen diesem das erste Produkt hinzu. Mittels Mockito.when(…) legen wir fest, dass cartRepository.findByCustomerId(TEST_CUSTOMER_ID) genau diesen Warenkorb zurückliefern soll.

Dann rufen wir die zu testende Methode AddToCartService.addToCart(…) auf, um dem Warenkorb ein zweites Produkt hinzuzufügen. Diese Methode liefert den aktualisierten Warenkorb zurück.

Schließlich prüfen wir, zum einen via Mockito.verify(…), dass der aktualisierte Warenkorb über CartRepository.save(…) persistiert wurde, und zum anderen, dass der Warenkorb beide Produkte in der erwarteten Menge enthält.

Dieser Test zeigt eindrucksvoll, wie wir die Geschäftslogik der Anwendung ganz ohne REST-Controller und Datenbank testen können.

Du findest alle weiteren Tests des application-Moduls im GitHub-Repository.

Implementierung der Adapter

Lauffähig ist unsere Anwendung noch nicht. Wir brauchen noch Adapter:

  • REST-Adapter, um die Use Cases aufzurufen, und
  • Persistenz-Adapter, um Warenkörbe und Produkte zu speichern.

Wir erstellen für alle primären Ports (Use Cases) je einen REST-Adapter (obere Reihe im folgenden Klassendiagramm) und für alle sekundären Ports (Repositories) je einen In-Memory-Persistenz-Adapter (untere Reihe):

Hexagonale Architektur mit Java - Ports, Services und Adapter der Beispielanwendung
Ports, Services und Adapter der Beispielanwendung

Technisch notwendig ist diese Eins-zu-Eins-Zuordnung nicht. Wir könnten auch z. B. einen Cart-REST-Adapter, einen Product-REST-Adapter und einen einzigen Persistenz-Adapter anlegen:

Hexagonale Architektur mit Java - Alternative Adapter
Alternative Adapter

Das würde allerdings das Single-Responsibility-Prinzip verletzen. Bei zwei oder drei implementierten Methoden pro Klasse ist das noch übersichtlich. Aber ich habe schon gesehen, wie daraus schnell zehn oder sogar hundert Methoden pro Klasse werden.

Der folgende Screenshot zeigt alle Pakete des adapter-Moduls in der IDE. Wie du siehst, sind es noch einige Klassen mehr als die im Klassendiagramm dargestellten. Ich werde sie alle in den nächsten Abschnitten beschreiben.

Hexagonale Architektur mit Java - Adapter-Klassen

Beginnen wir mit der Implementierung der REST-Adapter...

Implementierung der REST-Adapter

Bisher haben wir reinen Java-Code geschrieben. Wenn wir von Lombok absehen (was die Arbeit vereinfacht hat, aber nicht zwingend notwendig war) und den Test-Libraries, haben wir noch keine zusätzliche Library verwendet. Das wird sich jetzt ändern, denn einen REST-Adapter wollen wir nicht ohne Hilfe implementieren.

Wir verwenden dafür den Standard „Jakarta RESTful Web Services” (vor der Übergabe von Java EE an die Eclipse Foundation bekannt als „JAX-RS”). Als Implementierung dieses Standards verwenden wir das weit verbreitete RESTEasy aus dem Hause JBoss bzw. Red Hat.

Wir tragen dazu folgende Abhängigkeiten in die pom.xml-Datei des adapter-Moduls ein:

<dependencies>
    <dependency>
        <groupId>eu.happycoders.shop</groupId>
        <artifactId>application</artifactId>
        <version>${project.version}</version>
    </dependency>

    <dependency>
        <groupId>jakarta.ws.rs</groupId>
        <artifactId>jakarta.ws.rs-api</artifactId>
        <version>3.1.0</version>
    </dependency>
</dependencies>
Code-Sprache: HTML, XML (xml)

Beachte, dass hier lediglich eine Abhängigkeit auf die „Jakarta RESTful Web Services”-API benötigt wird – keine auf die RESTEasy-Implementierung dieser API! Diese benötigen wir erst später für Integration-Tests sowie im bootstrap-Modul, um die Anwendung zu starten.

REST-Adapter 1: Suche nach Produkten

Den REST-Adapter für die Produktsuche implementieren wir im FindProductsController. Da dieser Controller den FindProductsUseCase (also den primären Port) aufrufen muss, injizieren wir diesen über den Konstruktor.

package eu.happycoders.shop.adapter.in.rest.product;

import static eu.happycoders.shop.adapter.in.rest.common.ControllerCommons.clientErrorException;
// ... more imports ... 

@Path("/products")
@Produces(MediaType.APPLICATION_JSON)
public class FindProductsController {

  private final FindProductsUseCase findProductsUseCase;

  public FindProductsController(FindProductsUseCase findProductsUseCase) {
    this.findProductsUseCase = findProductsUseCase;
  }

  @GET
  public List<ProductInListWebModel> findProducts(@QueryParam("query") String query) {
    if (query == null) {
      throw clientErrorException(Response.Status.BAD_REQUEST, "Missing 'query'");
    }

    List<Product> products;

    try {
      products = findProductsUseCase.findByNameOrDescription(query);
    } catch (IllegalArgumentException e) {
      throw clientErrorException(Response.Status.BAD_REQUEST, "Invalid 'query'");
    }

    return products.stream().map(ProductInListWebModel::fromDomainModel).toList();
  }
}Code-Sprache: Java (java)

In der findProducts(…)-Methode laden wir die Produkte über die findByNameOrDescription(…)-Methode des FindProductsUseCase-Ports. Diese Methode wirft eine IllegalArgumentException, wenn der Suchbegriff zu kurz ist.

Die Exception fangen wir ab und werfen stattdessen eine ClientErrorException, die wir über die in ControllerCommons implementierte Hilfsmethode clientErrorException(…) erzeugen:

package eu.happycoders.shop.adapter.in.rest.common;

// ... imports ...

public final class ControllerCommons {

  private ControllerCommons() {}

  public static ClientErrorException clientErrorException(
      Response.Status status, String message) {
    return new ClientErrorException(errorResponse(status, message));
  }

  public static Response errorResponse(Response.Status status, String message) {
    ErrorEntity errorEntity = new ErrorEntity(status.getStatusCode(), message);
    return Response.status(status).entity(errorEntity).build();
  }
}Code-Sprache: Java (java)

Die ClientErrorException ist in der „Jakarta RESTful Web Services”-API definiert. Wenn eine Controller-Methode diese Exception wirft, gibt der Controller einen HTTP-Fehlercode und die der Exception übergebenen ErrorEntity als JSON-String zurück.

ErrorEntity ist ein simpler Record:

package eu.happycoders.shop.adapter.in.rest.common;

public record ErrorEntity(int httpStatus, String errorMessage) {}
Code-Sprache: Java (java)

Die Fehlermeldung würde später bei einem ungültigen Aufruf via curl z. B. so aussehen:

$ curl http://localhost:8081/products?query=x -i
HTTP/1.1 400 Bad Request
[...]

{"httpStatus":400,"errorMessage":"Invalid 'query'"}Code-Sprache: Shell Session (shell)

Kommen wir noch einmal zurück zur findProducts(…)-Methode im FindProductsController. Sollte keine IllegalArgumentException aufgetreten zu sein, wird die folgende, letzte Zeile der Methode ausgeführt:

return products.stream().map(ProductInListWebModel::fromDomainModel).toList();Code-Sprache: Java (java)

Die FindProductsUseCase.findByNameOrDescription(…)-Methode hat eine Liste von Product-Entities zurückgeliefert. Diese sind im model-Modul definiert und enthalten alle Informationen eines Produkts, einschließlich derer, die wir dem Benutzer gar nicht anzeigen wollen.

Wir mappen daher (s. Abschnitt „Mapping” im ersten Teil der Serie) die Domain-Modellklasse Product auf eine Adapter-spezifische Modellklasse ProductInListWebModel. Diese ist als Record implementiert und enthält die statische Factory-Methode fromDomainModel(…), die wir oben als Methodenreferenz an die Stream.map(…)-Methode übergeben haben.

package eu.happycoders.shop.adapter.in.rest.product;

// ... imports ...

public record ProductInListWebModel(
    String id, String name, Money price, int itemsInStock) {

  public static ProductInListWebModel fromDomainModel(Product product) {
    return new ProductInListWebModel(
        product.id().value(), product.name(), product.price(), product.itemsInStock());
  }
}
Code-Sprache: Java (java)

Damit ist unser Controller fertig implementiert. Den Rest erledigen später RESTEasy und der Undertow-Webserver. Wir werden, wenn die Anwendung fertig ist, den Controller über folgende URL aufrufen können:

http://localhost:8081/products/?query=monitorCode-Sprache: Klartext (plaintext)

... und daraufhin folgende Antwort erhalten:

[
  {
    "id": "K3SR7PBX",
    "name": "27-Inch Curved Computer Monitor",
    "price": {
      "currency": "EUR",
      "amount": 159.99
    },
    "itemsInStock": 24081
  },
  {
    "id": "Q3W43CNC",
    "name": "Dual Monitor Desk Mount",
    "price": {
      "currency": "EUR",
      "amount": 119.9
    },
    "itemsInStock": 1079
  }
]Code-Sprache: JSON / JSON mit Kommentaren (json)

Unser erster REST-Adapter ist fertig.

REST-Adapter 2: Hinzufügen eines Produkts zum Warenkorb

Nach dem gleichen Muster erstellen wir den REST-Adapter, um dem Warenkorb ein Produkt hinzuzufügen (Klasse AddToCartController):

package eu.happycoders.shop.adapter.in.rest.cart;

import static eu.happycoders.shop.adapter.in.rest.common.ControllerCommons.clientErrorException;
import static eu.happycoders.shop.adapter.in.rest.common.CustomerIdParser.parseCustomerId;
import static eu.happycoders.shop.adapter.in.rest.common.ProductIdParser.parseProductId;
// ... more imports ...

@Path("/carts")
@Produces(MediaType.APPLICATION_JSON)
public class AddToCartController {

  private final AddToCartUseCase addToCartUseCase;

  public AddToCartController(AddToCartUseCase addToCartUseCase) {
    this.addToCartUseCase = addToCartUseCase;
  }

  @POST
  @Path("/{customerId}/line-items")
  public CartWebModel addLineItem(
      @PathParam("customerId") String customerIdString,
      @QueryParam("productId") String productIdString,
      @QueryParam("quantity") int quantity) {
    CustomerId customerId = parseCustomerId(customerIdString);
    ProductId productId = parseProductId(productIdString);

    try {
      Cart cart = addToCartUseCase.addToCart(customerId, productId, quantity);
      return CartWebModel.fromDomainModel(cart);
    } catch (ProductNotFoundException e) {
      throw clientErrorException(
          Response.Status.BAD_REQUEST, "The requested product does not exist");
    } catch (NotEnoughItemsInStockException e) {
      throw clientErrorException(
          Response.Status.BAD_REQUEST, 
          "Only %d items in stock".formatted(e.itemsInStock()));
    }
  }
}Code-Sprache: Java (java)

Wir parsen zunächst Kunden- und Produktnummer über zwei Hilfsmethoden der Klassen CustomerIdParser und ProductIdParser (hier nicht abgedruckt). Beide werfen bei ungültiger Eingabe die bereits oben besprochene ClientErrorException, die dann von RESTEasy in einen entsprechenden HTTP-Fehler umgewandelt wird.

Wir rufen dann die Use-CaseMethode AddToCartUseCase.addToCart(…) auf. Diese gibt ein Objekt der Domainmodell-Klasse Cart zurück, welche wir über die statische Factory-Methode CartWebModel.fromDomainModel(…) in die Adapter-spezifische Modellklasse CartWebModel mappen:

package eu.happycoders.shop.adapter.in.rest.cart;

// ... imports ...

public record CartWebModel(
    List<CartLineItemWebModel> lineItems, int numberOfItems, Money subTotal) {

  static CartWebModel fromDomainModel(Cart cart) {
    return new CartWebModel(
        cart.lineItems().stream().map(CartLineItemWebModel::fromDomainModel).toList(),
        cart.numberOfItems(),
        cart.subTotal());
  }
}
Code-Sprache: Java (java)

CartWebModel ist ein Record, der neben der Gesamtanzahl der Produkte und des Gesamtpreises eine Liste von CartLineItemWebModel-Objekten enthält. Diese werden über die statische Factory-Methode CartLineItemWebModel.fromDomainModel(…) aus den CartLineItem-Einträgen des Cart-Modells erzeugt:

package eu.happycoders.shop.adapter.in.rest.cart;

// ... imports ...

public record CartLineItemWebModel(
    String productId, String productName, Money price, int quantity) {

  public static CartLineItemWebModel fromDomainModel(CartLineItem lineItem) {
    Product product = lineItem.product();
    return new CartLineItemWebModel(
        product.id().value(), product.name(), product.price(), lineItem.quantity());
  }
}Code-Sprache: Java (java)

Hier sehen wir, dass das Web-Modell auch ganz anders aussehen kann als das Domain-Modell: Während CartLineItem eine Referenz auf ein Product enthält, speichert CartLineItemWebModel die relevanten Produktdaten (ID, Name, Preis) direkt.

Sollte der Port-Aufruf addToCartUseCase.addToCart(…) eine ProductNotFoundException oder eine NotEnoughItemsInStockException werfen, werden diese in eine entsprechende ClientErrorException umgewandelt.

REST-Adapter 3: Abrufen des Warenkorbs

Der dritte REST-Adapter, GetCartController gestaltet sich recht einfach, da wir bereits alle benötigten Bausteine haben:

package eu.happycoders.shop.adapter.in.rest.cart;

// ... imports ...

@Path("/carts")
@Produces(MediaType.APPLICATION_JSON)
public class GetCartController {

  private final GetCartUseCase getCartUseCase;

  public GetCartController(GetCartUseCase getCartUseCase) {
    this.getCartUseCase = getCartUseCase;
  }

  @GET
  @Path("/{customerId}")
  public CartWebModel getCart(@PathParam("customerId") String customerIdString) {
    CustomerId customerId = CustomerIdParser.parseCustomerId(customerIdString);
    Cart cart = getCartUseCase.getCart(customerId);
    return CartWebModel.fromDomainModel(cart);
  }
}Code-Sprache: Java (java)

REST-Adapter 4: Leeren des Warenkorbs

Das gleiche gilt für den vierten und letzten REST-Adapter, EmptyCartController:

package eu.happycoders.shop.adapter.in.rest.cart;

// ... imports ...

@Path("/carts")
@Produces(MediaType.APPLICATION_JSON)
public class EmptyCartController {

  private final EmptyCartUseCase emptyCartUseCase;

  public EmptyCartController(EmptyCartUseCase emptyCartUseCase) {
    this.emptyCartUseCase = emptyCartUseCase;
  }

  @DELETE
  @Path("/{customerId}")
  public void deleteCart(@PathParam("customerId") String customerIdString) {
    CustomerId customerId = CustomerIdParser.parseCustomerId(customerIdString);
    emptyCartUseCase.emptyCart(customerId);
  }
}Code-Sprache: Java (java)

Da die deleteCart(…)-Methode keinen Rückgabewert hat, brauchen wir hier auch keinen Modell-Konverter aufzurufen, sodass die Methode sogar nur zwei statt drei Zeilen lang ist.

Damit haben wir alle vier REST-Adapter implementiert.

Integration-Tests für REST-Adapter

Wir könnten die REST-Adapter wieder mit Unit-Tests testen – also z. B. einen AddToCartController mit einem gemockten AddToCartUseCase instanziieren und dann prüfen, ob ein Aufruf der addLineItem(…) korrekt an addToCartUseCase.addToCart(…) übergeben wird.

Dabei würden wir uns dann allerdings darauf verlassen müssen, dass alle Annotationen korrekt sind, dass wir später RESTEasy und Untertow korrekt konfigurieren und auch, dass eine Library für JSON-Serialisierung auf dem Classpath liegt. Sich auf solche Tests zu verlassen, ist äußerst riskant.

Ich würde gerne sicherstellen, dass die Adapter wirklich funktionieren; und das geht nur mit einem Integration-Test, bei dem wir einen Webserver starten und die Controller über HTTP aufrufen.

Dafür brauchen wir ein paar zusätzliche Dependencies:

  • resteasy-undertow – der Undertow-Webserver kombiniert mit der RESTEasy-Library
  • resteasy-jackson2-provider – ein RESTEasy-Modul zur Umwandlung von Java-Objekten in JSON (und umgekehrt)
  • rest-assured – eine Library, mit der wir aus Integration-Tests heraus HTTP-Aufrufe ausführen können

Wir machen dazu folgende Einträge in der pom.xml-Datei des adapter-Moduls (falls du dich über die umgedrehte Reihenfolge wunderst – ich ordne die Dependencies gerne alphabetisch sortiert an):

<dependency>
    <groupId>io.rest-assured</groupId>
    <artifactId>rest-assured</artifactId>
    <version>5.3.2</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.jboss.resteasy</groupId>
    <artifactId>resteasy-jackson2-provider</artifactId>
    <version>6.2.5.Final</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.jboss.resteasy</groupId>
    <artifactId>resteasy-undertow</artifactId>
    <version>6.2.5.Final</version>
    <scope>test</scope>
</dependency>Code-Sprache: HTML, XML (xml)

Darüber, dass ich die RESTEasy-Libraries im test-Scope importiere, lässt sich streiten. Technisch reichen sie uns hier im test-Scope. Allerdings brauchen wir sie spätestens im bootstrap-Modul im compile- bzw. runtime-Scope. Wir könnten den Scope daher auch schon hier auf compile (für resteasy-undertow) und auf runtime (für resteasy-jackson2-provider) setzen und uns damit einen weiteren Import im bootstrap-Modul sparen.

Wir brauchen, wie im application-Modul, auch hier wieder eine Dependency auf das „Attached Test JAR” des model-Moduls, um die dort implementierten Test-Factories wie z. B. TestProductFactory.createTestProduct(…) verwenden zu können:

<dependency>
    <groupId>eu.happycoders.shop</groupId>
    <artifactId>model</artifactId>
    <version>${project.version}</version>
    <classifier>tests</classifier>
    <type>test-jar</type>
    <scope>test</scope>
</dependency>Code-Sprache: HTML, XML (xml)

Hier ein Screenshot aus meiner IDE mit einer Übersicht aller REST-Adapter-Integration-Tests und zugehöriger Hilfsklassen:

Hexagonale Architektur mit Java - REST-Adapter-Testklassen

Im nächsten Abschnitt findest du einen beispielhaften REST-Adapter-Test. Alle weitere Tests findest du hier im GitHub-Repository.

Integration-Test für REST-Adapter 2: Hinzufügen eines Produkts zum Warenkorb

Vielleicht ist es dir im letzten Screenshot schon aufgefallen: Es gibt nur einen CartsControllerTest – keine separaten Tests für jeden Use Case. Das hat einen praktischen Grund: Das Hochfahren des Undertow-Servers dauert eine Weile. Wenn wir den Test auf drei Tests aufteilen würden, würde das Testen dreimal so lange dauern.

Wenn wir die Anwendung später in ein Application Framework wie Spring oder Quarkus einbetten, können wir die Tests aufteilen, denn diese Frameworks machen es uns leicht, eine einmal gestartete Anwendung aus mehreren Tests aufzurufen.

Im Folgenden siehst du einen Ausschnitt der Klasse CartsControllerTest für den Use Case „Hinzufügen eines Produkts zum Warenkorb”.

Die erste zwei Code-Blöcke sollten dir aus dem vorherigen Test bekannt vorkommen: Im ersten legen wir Testdaten an, im zweiten die Test-Doubles.

In der mit @BeforeAll annotierten init()-Methode konfigurieren wir unseren Server (die TEST_PORT-Konstante ist in der Klasse HttpTestCommons definiert), starten ihn und deployen unsere Test-Anwendung. Dazu übergeben wir der deploy(…)-Methode eine Instanz einer anonymen inneren Klasse. Diese erweitert jakarta.ws.rs.core.Application und überschreibt deren getSingletons()-Methode, die ein Set aller REST-Controller zurückliefert, denen wir die Test-Doubles für die Ports injizieren.

Die mit @AfterAll annotierte stop()-Methode fährt den Server wieder herunter.

Die eigentliche Testmethode beschreibe ich unter dem Quellcode-Auszug.

package eu.happycoders.shop.adapter.in.rest.cart;

import static eu.happycoders.shop.adapter.in.rest.HttpTestCommons.TEST_PORT;
import static eu.happycoders.shop.adapter.in.rest.cart.CartsControllerAssertions.assertThatResponseIsCart;
import static eu.happycoders.shop.model.money.TestMoneyFactory.euros;
import static eu.happycoders.shop.model.product.TestProductFactory.createTestProduct;
import static io.restassured.RestAssured.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
// ... more imports ...

class CartsControllerTest {

  private static final CustomerId TEST_CUSTOMER_ID = new CustomerId(61157);
  private static final Product TEST_PRODUCT_1 = createTestProduct(euros(19, 99));

  private static final AddToCartUseCase addToCartUseCase = mock(AddToCartUseCase.class);
  private static final GetCartUseCase getCartUseCase = mock(GetCartUseCase.class);
  private static final EmptyCartUseCase emptyCartUseCase = mock(EmptyCartUseCase.class);

  private static UndertowJaxrsServer server;

  @BeforeAll
  static void init() {
    server =
        new UndertowJaxrsServer()
            .setPort(TEST_PORT)
            .start()
            .deploy(
                new Application() {
                  @Override
                  public Set<Object> getSingletons() {
                    return Set.of(
                        new AddToCartController(addToCartUseCase),
                        new GetCartController(getCartUseCase),
                        new EmptyCartController(emptyCartUseCase));
                  }
                });
  }

  @AfterAll
  static void stop() {
    server.stop();
  }

  @Test
  void givenSomeTestData_addLineItem_invokesAddToCartUseCaseAndReturnsUpdatedCart()
      throws NotEnoughItemsInStockException, ProductNotFoundException {
    // Arrange
    CustomerId customerId = TEST_CUSTOMER_ID;
    ProductId productId = TEST_PRODUCT_1.id();
    int quantity = 5;

    Cart cart = new Cart(customerId);
    cart.addProduct(TEST_PRODUCT_1, quantity);

    when(addToCartUseCase.addToCart(customerId, productId, quantity)).thenReturn(cart);

    // Act
    Response response =
        given()
            .port(TEST_PORT)
            .queryParam("productId", productId.value())
            .queryParam("quantity", quantity)
            .post("/carts/" + customerId.value() + "/line-items")
            .then()
            .extract()
            .response();

    // Assert
    assertThatResponseIsCart(response, cart);
  }

  // more tests

}Code-Sprache: Java (java)

In der Testmethode legen wir einen Warenkorb mit einem Produkt an und definieren über Mockito.when(…), dass die AddToCartUseCase.addToCart(…)-Methode diesen Warenkorb zurückliefern soll, wenn sie mit der entsprechenden Kundennummer, Produkt-ID und Anzahl aufgerufen wird.

Danach benutzen wir die Library REST Assured, um einen echten HTTP-Aufruf mit eben diesen Parametern an den Server zu schicken.

Mit assertThatResponseIsCart(…) prüfen wir, ob der HTTP-Aufruf den erwarteten Warenkorb im JSON-Format zurückgeliefert hat. Diese Methode ist in der Klasse CartsControllerAssertions definiert (hier nicht abgedruckt).

Alle weiteren Tests der REST-Adapter sehen ähnlich aus. Du findest sie hier im GitHub-Repository.

Um unsere Anwendung endlich in Betrieb nehmen zu können, fehlen noch die Persistenz-Adapter. Diese implementieren wir als nächstes.

Implementierung der Persistence-Adapter mit einem In-Memory-Store

Um ein erstes Gefühl für die laufende Anwendung zu bekommen, ist es nicht nötig, dass wir die Daten in einer Datenbank persistieren. Wir können es uns fürs Erste einfach machen und sie zunächst im Arbeitsspeicher halten. Wir nutzen hier einer der großen Vorteile der hexagonalen Architektur: Die Entscheidung über technische Belange, wie z. B. die Datenbank, lässt sich aufschieben, und sie lassen sich später einfach austauschen (wie du noch sehen wirst).

Wir beginnen mit der Implementierung des sekundären Ports CartRepository durch die Klasse InMemoryCartRepository:

package eu.happycoders.shop.adapter.out.persistence.inmemory;

// ... imports ...

public class InMemoryCartRepository implements CartRepository {

  private final Map<CustomerId, Cart> carts = new ConcurrentHashMap<>();

  @Override
  public void save(Cart cart) {
    carts.put(cart.id(), cart);
  }

  @Override
  public Optional<Cart> findByCustomerId(CustomerId customerId) {
    return Optional.ofNullable(carts.get(customerId));
  }

  @Override
  public void deleteById(CustomerId customerId) {
    carts.remove(customerId);
  }
}Code-Sprache: Java (java)

Die save(…)-Methode speichert den Warenkorb in einer Map, die findByCustomerId(…)-Methode lädt sie aus der Map, und die deleteByCustomerId(…)-Methode löscht den Eintrag aus der Map.

Fast genauso einfach implementiert ist auch das InMemoryProductRepository. Um die Anwendung sinnvoll nutzen zu können, legen wir im Konstruktor des Repositories ein paar Demo-Produkte an. Dazu übergen wir jedes in der Klasse DemoProducts gelistete Produkte an die safe(…)-Methode.

package eu.happycoders.shop.adapter.out.persistence.inmemory;

// ... imports ...

public class InMemoryProductRepository implements ProductRepository {

  private final Map<ProductId, Product> products = new ConcurrentHashMap<>();

  public InMemoryProductRepository() {
    createDemoProducts();
  }

  private void createDemoProducts() {
    DemoProducts.DEMO_PRODUCTS.forEach(this::save);
  }

  @Override
  public void save(Product product) {
    products.put(product.id(), product);
  }

  @Override
  public Optional<Product> findById(ProductId productId) {
    return Optional.ofNullable(products.get(productId));
  }

  @Override
  public List<Product> findByNameOrDescription(String query) {
    String queryLowerCase = query.toLowerCase(Locale.ROOT);
    return products.values().stream()
        .filter(product -> matchesQuery(product, queryLowerCase))
        .toList();
  }

  private boolean matchesQuery(Product product, String query) {
    return product.name().toLowerCase(Locale.ROOT).contains(query)
        || product.description().toLowerCase(Locale.ROOT).contains(query);
  }
}
Code-Sprache: Java (java)

Neben dem Speichern und Laden eines Produkts ist das Repository auch für die Suche nach Produkten zuständig. Diese implementieren wir in der findByNameOrDescription(…), indem wir über alle gespeicherten Produkte iterieren und prüfen, ob deren Name oder Beschreibung den Suchbegriff enthält.

Im nächsten Teil der Artikelserie zeige ich dir, wie du den In-Memory-Adapter durch einen JPA-Adapter ersetzen kannst, der die Daten in einer MySQL-Datenbank speichert.

Unit-Tests für Persistenz-Adapter

Solange die Persistenz-Adapter noch nicht mit einem externen System wie einer Datenbank kommunizieren, können wir mit einfachen Unit-Tests arbeiten.

Da wir jetzt schon wissen, dass wir später zusätzliche JPA-Adapter implementieren werden – dann also die gleiche Funktionalität mit zwei verschiedenen Adaptern testen müssen – schreibe ich die Tests je in einer abstrakten Klasse, die dann von einer konkreten Klasse erweitert wird, die den jeweils zu testenden Adapter erzeugt.

Das hört sich komplizierter an, als es ist. Das folgende Klassendiagramm sollte zum Verständnis beitragen:

Hexagonale Architektur mit Java - Persistenz-Adapter-Testklassen-Hierarchie
Persistenz-Adapter-Testklassen-Hierarchie

Die abstrakte Klasse AbstractProductRepositoryTest enthält eine Instanz von ProductRepository, die über die abstrakte Methode createProductRepository() instanziiert wird. Auf dieser Instanz werden alle Tests ausgeführt.

In den konkreten Klassen InMemoryProductRepositoryTest und, im nächsten Teil der Serie, JpaProductRepositoryTest, wird die Methode createProductRepository() implementiert und liefert den konkreten zu testenden Adapter zurück, also das InMemoryProductRepository bzw. das JpaProductRepository. Der JpaProductRepositoryTest wird darüberhinaus noch eine echte MySQL-Datenbank starten und stoppen – dazu aber mehr im nächsten Teil der Serie.

Mit dieser Struktur können wir für beide Adapter-Implementierungen die gleichen Tests ausführen, ohne alle Tests doppelt schreiben zu müssen.

Hier siehst du der Vollständigkeit halber noch einen Screenshot der Test-Klassen in meiner IDE (ohne die JPA-Repository-Tests):

Hexagonale Architektur mit Java - Persistenz-Adapter-Testklassen

Im nächsten Abschnitt zeige ich dir einen beispielhaften ProductRepository-Adapter-Test. Alle weitere Tests findest du hier im GitHub-Repository.

Unit-Test für ProductRepository-Adapter

Der folgende Code zeigt einen Ausschnitt der AbstractProductRepositoryTest-Klasse. Im oberen Teil siehst du die im vorherigen Abschnitt beschriebene Initialisierung des Repositories über die abstrakte Methode createProductRepository().

Die eigentliche Testmethode ruft die ProductRepository.findByNameOrDescription(…)-Methode auf und prüft, ob die erwarteten Produkte, also die zum Suchbegriff passenden, im InMemoryProductRepository-Konstruktor angelegten Testprodukte, zurückgeliefert werden.

package eu.happycoders.shop.adapter.out.persistence;

import static org.assertj.core.api.Assertions.assertThat;
// ... more imports ...

public abstract class AbstractProductRepositoryTest<T extends ProductRepository> {

  private T productRepository;

  @BeforeEach
  void initRepository() {
    productRepository = createProductRepository();
  }

  protected abstract T createProductRepository();

  @Test
  void givenTestProducts_findByNameOrDescription_returnsMatchingProducts() {
    String query = "monitor";

    List<Product> products = productRepository.findByNameOrDescription(query);

    assertThat(products)
        .containsExactlyInAnyOrder(
            DemoProducts.COMPUTER_MONITOR, DemoProducts.MONITOR_DESK_MOUNT);
  }

  // more tests

}Code-Sprache: Java (java)

Bei einer echten Anwendung, die nicht automatisch Demo-Produkte anlegt, würden wir entweder in der mit @BeforeEach annotierten initRepository()-Methode oder direkt in der Testmethode die Test-Produkte anlegen (je nachdem, ob wir sie für alle Tests oder nur für diesen einen Test benötigen).

Die konkrete Testklasse, InMemoryProductRepositoryTest, implementiert lediglich die createProductRepository()-Methode:

package eu.happycoders.shop.adapter.out.persistence.inmemory;

// ... imports ...

class InMemoryProductRepositoryTest
    extends AbstractProductRepositoryTest<InMemoryProductRepository> {

  @Override
  protected InMemoryProductRepository createProductRepository() {
    return new InMemoryProductRepository();
  }
}Code-Sprache: Java (java)

Alle Persistenz-Adapter-Tests findest du hier im GitHub-Repository.

OK, wir haben alle Komponenten für die Anwendung fertig: unser Modell, die Ports und Services, primäre und sekundäre Adapter. Mit folgendem Terminal-Kommando können wir alles testen:

mvn clean test

Aber wie starten wir die Anwendung jetzt? Das erfährst du im nächsten Kapitel.

Implementierung des Bootstrap-Moduls

Ein bisschen Code müssen wir noch schreiben, um die Anwendung endlich starten zu können. Aber nicht viel, versprochen! Der folgende Screenshot beweist es:

Hexagonale Architektur mit Java - Bootstrap-Klassen

Zunächst müssen wir ein paar Dependencies definieren – zum einen zum adapter-Modul, zum anderen zu Undertow und dem RESTEasy-JSON-Modul (beide kennst du bereits aus den Adapter-Integration-Tests).

Trage dazu Folgendes in die pom.xml des bootstrap-Moduls ein:

<dependencies>
    <dependency>
        <groupId>eu.happycoders.shop</groupId>
        <artifactId>adapter</artifactId>
        <version>${project.version}</version>
    </dependency>
    <dependency>
        <groupId>org.jboss.resteasy</groupId>
        <artifactId>resteasy-undertow</artifactId>
        <version>6.2.5.Final</version>
    </dependency>
    <dependency>
        <groupId>org.jboss.resteasy</groupId>
        <artifactId>resteasy-jackson2-provider</artifactId>
        <version>6.2.5.Final</version>
        <scope>runtime</scope>
    </dependency>
</dependencies>Code-Sprache: HTML, XML (xml)

Als nächstes müssen wir – so wie im REST-Adapter-Integration-Test – die Application-Klasse erweitern. Statt einer anonymen inneren Klasse verwenden wir dieses Mal eine reguläre Klasse, da wir mehr Objekte initialisieren und verdrahten müssen als in den Tests:

  • Startpunkt ist die überschriebene getSingletons()-Methode. Diese ruft zunächst initPersistenceAdapters() auf, um die In-Memory-Repositories zu initialisieren.
  • Danach werden die vier Controller initialisiert und pro Controller jeweils ein Service.
  • Die vier Controller werden dann in einem Set zurückgegeben; den Rest erledigt der Undertow-Webserver.

Du findest den Code in der RestEasyUndertowShopApplication-Klasse im without-jpa-adapters-Branch des GitHub-Repositories. (Falls du den main-Branch ausgecheckt hast, ist diese Klasse bereits um die Option zur Initialisierung der JPA-/MySQL-Adapter erweitert.)

package eu.happycoders.shop.bootstrap;

// ... imports ...

public class RestEasyUndertowShopApplication extends Application {

  private CartRepository cartRepository;
  private ProductRepository productRepository;

  @Override
  public Set<Object> getSingletons() {
    initPersistenceAdapters();

    return Set.of(
        addToCartController(),
        getCartController(),
        emptyCartController(),
        findProductsController());
  }

  private void initPersistenceAdapters() {
    cartRepository = new InMemoryCartRepository();
    productRepository = new InMemoryProductRepository();
  }

  private AddToCartController addToCartController() {
    AddToCartUseCase addToCartUseCase =
        new AddToCartService(cartRepository, productRepository);
    return new AddToCartController(addToCartUseCase);
  }

  private GetCartController getCartController() {
    GetCartUseCase getCartUseCase = new GetCartService(cartRepository);
    return new GetCartController(getCartUseCase);
  }

  private EmptyCartController emptyCartController() {
    EmptyCartUseCase emptyCartUseCase = new EmptyCartService(cartRepository);
    return new EmptyCartController(emptyCartUseCase);
  }

  private FindProductsController findProductsController() {
    FindProductsUseCase findProductsUseCase = new FindProductsService(productRepository);
    return new FindProductsController(findProductsUseCase);
  }
}
Code-Sprache: Java (java)

Als nächstes brauchen wir noch eine main()-Methode, um die Anwendung und den Undertow-Server zu starten.

Dieser Start-Code befindet sich in der Launcher-Klasse (im GitHub-Repository findest du noch zwei weitere Methoden, startOnPort(…) und stop() – diese benötigen wir für die End-to-End-Tests).

package eu.happycoders.shop.bootstrap;

import org.jboss.resteasy.plugins.server.undertow.UndertowJaxrsServer;

public class Launcher {

  private UndertowJaxrsServer server;

  public static void main(String[] args) {
    new Launcher().startOnDefaultPort();
  }

  public void startOnDefaultPort() {
    server = new UndertowJaxrsServer();
    startServer();
  }

  private void startServer() {
    server.start();
    server.deploy(RestEasyUndertowShopApplication.class);
  }
}Code-Sprache: Java (java)

Und das war's – unsere Anwendung ist fertig!

Du findest hier im GitHub-Repository noch einige End-to-End-Tests. Diese benutzen die Launcher-Klasse, um die Anwendung zu starten und senden dann – wie bereits bei den REST-Adapter-Tests gezeigt, HTTP-Aufrufe an die REST-Controller und verifizieren die Antwort.

Die End-to-End-Tests verwenden dazu REST Assured und einige der Hilfsfunktionen der Adapter-Tests. Deshalb muss das adapter-Modul die Testklassen exportieren und das bootstrap-Modul sie importieren. Du findest die entsprechenden pom.xml-Einträge hier, hier und hier.

Start der Anwendung

Am einfachsten startest du die Anwendung direkt aus deiner IDE.

Danach kannst du die REST-Endpunkte im Terminal mit curl oder mit einem anderen Tool, wie z. B. Postman, oder direkt aus IntelliJ aufrufen (ich weiß nicht, inwieweit andere IDEs dies unterstützen).

In IntelliJ öffnest du dazu die Datei sample-requests.http und klickst auf einen der grünen Pfeile. Im Fenster rechts unten siehst du dann die Antwort des Controllers, hier z. B. das Suchergebnis für den Begriff „monitor” (zweite Beispiel-Query in Zeile 5):

Testen von REST-Controllern in IntelliJ
Testen von REST-Controllern in IntelliJ

Spiel ein bisschen mit den Requests herum. Füge ein paar Produkte zum Warenkorb hinzu, und beobachte, wie sich der Gesamtpreis erhöht.

Vom ersten Demo-Produkt, „Plastic Sheeting”, sind übrigens nur 55 vorhanden, und der Beispiel-Request in Zeile 14 legt 20 davon in den Warenkorb. Wenn du diese Anfrage also ein drittes Mal ausführst, solltest du folgende Fehlermeldung sehen:

Testen von REST-Controllern in IntelliJ - Bad Request

Herzlichen Glückwunsch! Du hast erfolgreich eine voll funktionsfähige Anwendung nach hexagonaler Architektur entwickelt.

Überwachung der Architekturgrenzen

Ein letztes Kapitel gibt es noch. Ich würde dir gerne noch zeigen, wie du die Architekturgrenzen überwachst und sicherstellst, dass niemand (also weder du selbst noch jemand anders) die Regeln der hexagonalen Architektur verletzt.

Von der ersten Möglichkeit haben wir bereits intensiv Gebrauch gemacht:

Maven-Module

Dadurch, dass wir unsere Anwendung in Maven-Module unterteilt und Abhängkeiten entsprechend der „Dependency Rule” definiert haben, haben wir schon ein wichtiges Ziel erreicht: nämlich, dass alle Quellcode-Abhängigkeiten Richtung Kern zeigen.

Durch die strikte Aufteilung sind wir außerdem dazu gezwungen, bei jeder neuen Klasse gut darüber nachzudenken, in welchem Modul wir sie anlegen. Das verhindert ein Arbeiten nach dem Motto „ich lege die Klasse mal eben schnell hier ab und verschiebe sie später an die richtige Stelle”.

Maven-Module sind allerdings recht grobgranular, und doppelt hält ja bekanntlich besser – daher zeige ich dir im nächsten Abschnitt noch eine weitere Methode.

ArchUnit

Die Library ArchUnit ermöglicht es, Tests zu schreiben, die fehlschlagen, wenn definierte Architekturregeln verletzt werden. Dadurch könnte z. B. eine CI/CD-Pipeline zum Abbruch gebracht werden.

Darüberhinaus können wir feingranularere Regeln definieren, z. B. auf Paket-Ebene, und damit z. B. sicherstellen, dass Adapter nur auf Ports zugreifen und nicht direkt auf die Domain-Services.

Um ArchUnit nutzen zu können, müssen wir zunächst folgende Dependency in die pom.xml des bootstrap-Moduls eintragen (das bootstrap-Modul ist der geeignete Ort, da wir von hier aus Sicht auf alle anderen Module haben):

<dependency>
    <groupId>com.tngtech.archunit</groupId>
    <artifactId>archunit-junit5</artifactId>
    <version>1.1.0</version>
    <scope>test</scope>
</dependency>Code-Sprache: HTML, XML (xml)

In der Testklasse DependencyRuleTest können wir dann die Architekturregeln verifizieren lassen. Ich habe dazu die Hilfsmethode checkNoDependencyFromTo(…) geschrieben, die prüft, dass keine Dependencies von einem Paket zu einem anderen existieren und rufe diese in der Testmethode checkDependencyRule() für alle nicht erlaubten Abhängigkeiten auf:

package eu.happycoders.shop.bootstrap.archunit;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
// ... more imports ...

class DependencyRuleTest {

  private static final String ROOT_PACKAGE = "eu.happycoders.shop";
  private static final String MODEL_PACKAGE = "model";
  private static final String APPLICATION_PACKAGE = "application";
  private static final String PORT_PACKAGE = "application.port";
  private static final String SERVICE_PACKAGE = "application.service";
  private static final String ADAPTER_PACKAGE = "adapter";
  private static final String BOOTSTRAP_PACKAGE = "bootstrap";

  @Test
  void checkDependencyRule() {
    String importPackages = ROOT_PACKAGE + "..";
    JavaClasses classesToCheck = new ClassFileImporter().importPackages(importPackages);

    checkNoDependencyFromTo(MODEL_PACKAGE, APPLICATION_PACKAGE, classesToCheck);
    checkNoDependencyFromTo(MODEL_PACKAGE, ADAPTER_PACKAGE, classesToCheck);
    checkNoDependencyFromTo(MODEL_PACKAGE, BOOTSTRAP_PACKAGE, classesToCheck);

    checkNoDependencyFromTo(APPLICATION_PACKAGE, ADAPTER_PACKAGE, classesToCheck);
    checkNoDependencyFromTo(APPLICATION_PACKAGE, BOOTSTRAP_PACKAGE, classesToCheck);

    checkNoDependencyFromTo(PORT_PACKAGE, SERVICE_PACKAGE, classesToCheck);

    checkNoDependencyFromTo(ADAPTER_PACKAGE, SERVICE_PACKAGE, classesToCheck);
    checkNoDependencyFromTo(ADAPTER_PACKAGE, BOOTSTRAP_PACKAGE, classesToCheck);
  }

  private void checkNoDependencyFromTo(
      String fromPackage, String toPackage, JavaClasses classesToCheck) {
    noClasses()
        .that()
        .resideInAPackage(fullyQualified(fromPackage))
        .should()
        .dependOnClassesThat()
        .resideInAPackage(fullyQualified(toPackage))
        .check(classesToCheck);
  }

  private String fullyQualified(String packageName) {
    return ROOT_PACKAGE + '.' + packageName + "..";
  }
}Code-Sprache: Java (java)

Wenn wir jetzt im Code eine nicht erlaubte Abhängigkeit einfügen und den Test ausführen, bekommen wir eine Fehlermeldung wie die folgende:

Architecture Violation [Priority: MEDIUM] - Rule 'no classes that reside in a package 'eu.happycoders.shop.application.port..' should depend on classes that reside in a package 'eu.happycoders.shop.application.service..'' was violated (1 times)

Java-Module

Seit Java 9 haben wir die Möglichkeit, Java-Module zu definieren. Dazu müssten wir in jedes der Maven-Module eine module-info.java-Datei legen und in dieser definieren, welche Pakete ein Modul exportiert und welche es importiert.

Für die Pakete unserer Anwendung ist das einfach. Wir müssten aber auch Imports für alle Third-Party-Dependencies definieren und teilweise sogar für transitive Dependencies. Darüberhinaus müssen wir auch festlegen, auf welche Teile unseres Codes Third-Party-Dependencies per Reflection zugreifen können.

Ich habe das interessehalber einmal ausprobiert, rate aber davon ab, es einzusetzen, da die Konfiguration sehr komplex ist (insbesondere in Kombination mit Unit- und Integration-Tests) und wenig Mehrwert gegenüber Maven-Modulen und ArchUnit liefert.

Fazit und Ausblick

Du hast in diesem Tutorial gelernt, wie man eine Java-Anwendung nach hexagonaler Architektur implementiert und die Einhaltung der Architekturregeln mit Maven und ArchUnit sicherstellt.

Wir haben die gesamte Geschäftslogik in den ersten zwei Modulen, model und application (zusammen bilden diese den Anwendungskern) implementiert und diese durch die Ports von den technischen Belangen, wie REST-Controllern und Persistenzlösung, isoliert. Diese haben wir erst später, im adapter-Modul hinzugefügt.

Diese Isolierung erlaubt es uns zudem, Entscheidungen über technische Details hinauszuzögern. Während wir bei der herkömmlichen Schichtenarchitektur mit der Planung einer Datenbank beginnen, auf der dann die ganze Anwendung basiert, haben wir unsere hexagonale Anwendung bis jetzt noch immer nicht mit einer Datenbank verbunden.

Das werden wir im nächsten Teil dieser Serie nachholen – und zwar ohne auch nur eine Zeile Code im Anwendungskern ändern zu müssen. Wir werden lediglich neue Adapter hinzufügen und diese im bootstrap-Modul initialisieren müssen.

Sollten wir also von nun an alle Anwendungen auf Grundlage der hexagonalen Architektur implementieren?

Nein, denn diese Architektur erfordert eine Menge Overhead. Ob sich dieser Aufwand lohnt, hängt von der Art und Größe der Anwendung ab. Eine einfache CRUD-Anwendung, die einen einzigen Resource-Typ in einer Datenbank speichert und per REST-Schnittstelle abrufbar/änderbar macht, ist mit drei Klassen (Entity, Controller, Repository) implementierbar – oder sogar mit zwei, wenn man das Active-Record-Pattern anwendet. Dafür wäre eine hexagonale Architektur überdimensioniert.

Sieh die hexagonale Architektur als ein weiteres Werkzeug an, das dir zur Verfügung steht. In diesem Tutorial hast du gelernt, wie man dieses Werzeug einsetzt.

Muss eine Anwendung nach hexagonaler Architektur immer der Struktur dieses Tutorials folgen?

Auch das nicht. Du kannst auch ohne Maven-Module arbeiten, oder mit weniger oder mehr als in diesem Tutorial. Du kannst die Pakete anders anordnen oder benennen.

Ich habe mit dieser Struktur, auch in großen Projekten, sehr gute Erfahrung gemacht. Und wenn die Struktur einmal steht, steht sie. Den Aufwand dafür hat man nur einmal. Wir könnten jetzt zahlreiche zusätzliche Modell-Klassen, Ports, Services und Adapter hinzufügen, ohne an der Modul-Struktur, der Maven-Konfiguration oder dem ArchUnit-Test irgendetwas ändern zu müssen.

Schreibe mir einen Kommentar, falls du mit dieser oder einer anderen Struktur gute Erfahrung gemacht hast oder andere Anmerkungen zu diesem Tutorial hast!

Wenn dir der Artikel gefallen hat, würde ich mich riesig über eine kurze Bewertung auf ProvenExpert freuen. Möchtest du auf dem Laufenden bleiben und informiert werden, wenn neue Artikel auf HappyCoders.eu veröffentlicht werden? Dann klicke hier, um dich für den kostenlosen HappyCoders-Newsletter anzumelden.