Hexagonal Architecture with QuarkusHexagonal Architecture with Quarkus
HappyCoders Glasses

Hexagonale Architektur mit Quarkus
[Tutorial]

Sven Woltmann
Sven Woltmann
Aktualisiert: 27. Dezember 2023

In diesem Artikel zeige ich dir, wie wir die Demo-Anwendung, die wir in den beiden vorangegangenen Teilen dieser Tutorial-Serie unter Verwendung der hexagonalen Architektur implementiert haben, in eine Quarkus-Anwendung migrieren – und das alles, ohne eine einzige Zeile Code im Anwendungskern zu ändern.

Rückblick

In Teil zwei dieser Artikelserie haben wir eine Java-Anwendung nach hexagonaler Architektur implementiert, und in Teil drei haben wir diese um einen zusätzlichen Datenbank-Adapter erweitert, um die Daten in einer relationalen Datenbank zu persistieren, anstatt sie flüchtig im Arbeitsspeicher zu halten.

Bisher haben wir noch kein Application Framework eingesetzt. Stattdessen haben wir einen simplen Dependency-Injection-Mechanismus selbst implementiert und alle APIs wie Jakarta Persistence und Jakarta RESTful Web Services sowie deren Implementierungen wie Hibernate und RESTEasy direkt angebunden. Dafür war einiges an Boilerplate-Code nötig (z. B. für das Bootstrapping und das Transaktionsmanagement).

Die folgende Grafik zeigt die Architektur unserer Anwendung:

Hexagonale Architektur der Beispiel-Anwendung

Falls du dir noch einmal die Grundlagen der hexagonalen Architetur in Erinnerung rufen willst, kannst du dies im soeben verlinkten ersten Teil der Serie tun.

Das Ergebnis der bisherigen Arbeit findest du im main-Branch dieses GitHub-Repositories.

Worum geht es in diesem Teil?

In diesem vierten Teil werden wir die einzelnen Anbindungen der Libraries durch ein Application Framework ersetzen und einiges an Boilerplate-Code entfernen können.

Wir setzen in diesem Teil Quarkus ein, da auch Quarkus auf Standard-Libraries wie Jakarta RESTful Web Services setzt, anstatt auf eigene Implementierungen wie es das Spring-Framework tut. Das wird die Migration vereinfachen. Spring werden wir im nächsten Teil dieser Serie behandeln.

Auch in diesem Teil werden wir, wie schon im vorherigen Teil, nicht eine einzige Zeile Code im Kern der Anwendung – also in den model- und application-Modulen – verändern müssen.

Wir werden Schritt für Schritt vorgehen, wobei die Anwendung nach jedem Schritt lauffähig sein wird:

  1. Zunächst werden wir die Quarkus-Dependencies einfügen und die direkten Dependencies zu denjenigen Libraries, die von Quarkus mitgebracht werden, entfernen.
  2. Dann werden wir das adapter-Modul an Quarkus anpassen, indem wir den Dependency-Injection-Mechanismus von Quarkus verwenden werden anstatt die Abhängigkeiten manuell zu injizieren.
  3. Im dritten Schritt werden wir das gleiche für das bootstrap-Modul machen.
  4. Und im letzten Schritt werden wir die selbst implementierte Transaktionsverwaltung und den Zugriff auf die Datenbank durch die @Transactional-Annotation und die Verwendung von Panache-Repositories umstellen.

Starten wir mit Schritt 1...

Schritt 1: Ersetzen der Dependencies

Im ersten Schritt werden wir die in den pom.xml-Dateien definierten Dependencies anpassen, ohne etwas am Java-Code zu ändern. So bleibt unsere Anwendung zunächst ohne das Application Framework lauffähig, hat aber schon einmal ein aufgeräumtes Set an Dependencies.

Anpassung der Eltern-pom.xml

Wir definieren zunächst im <properties>-Block der Eltern-pom.xml-Datei die Quarkus-Version, da wir diese später an zwei Stellen benötigen werden (dem Import der "Bill of Materials" und der Integration des Quarkus-Maven-Plugins). An welcher Stelle du die Quarkus-Version einfügst, ist technisch egal – der Übersichtlichkeit halber füge ich die Version vor der PMD-Version ein:

<properties>
    . . .
    <quarkus.platform.version>3.4.3</quarkus.platform.version>
    <pmd.version>6.55.0</pmd.version>
    . . .
</properties>Code-Sprache: HTML, XML (xml)

Als nächstes müssen wir die eben genannte "Bill of Materials" integrieren. Eine "Bill of Materials" ist im Grunde eine Liste, die definiert, welche Versionen von welchen Libraries zusammenpassen. In der Bill of Materials von Quarkus, Version 3.4.3 steht z. B. dass Hibernate in der Version 6.2.13.Final verwendet werden soll und JUnit in der Version 5.10.0.

Jetzt kommt es darauf an, wo du startest:

Wenn dein Startpunkt der Code ist, den wir in den vorherigen Teilen des Tutorials gemeinsam entwickelt haben, dann hast du noch keinen <dependencyManagement>-Block in der pom.xml.

Wenn du das Projekt hingegen aus meinem GitHub-Repository geklont hast, dann findest du solch einen Block, der die Versionsnummern aller Dependencies zentral verwaltet.

Warum dieser Unterschied? Im Tutorial habe ich die Versionsnummern immer direkt bei den Dependencies angegeben, da ich das Tutorial auf diese Weise für verständlicher hielt, als wenn du für jede Abhängigkeit einen Eintrag in den <dependencies>-Block des jeweiligen Moduls und einen weiteren Eintrag in den <dependencyManagement>-Block des übergeordneten POMs hättest einfügen müssen.

Falls du schon einen <dependencyManagement>-Block hast, dann ersetze ihn durch den folgenden Block. Und wenn du noch keinen hast, dann füge den folgenden Block einfach hinzu – z. B. vor den bestehenden <dependencies>-Block:

<dependencyManagement>
    <dependencies>
        <!-- Quarkus "Bill of Materials" -->
        <dependency>
            <groupId>io.quarkus.platform</groupId>
            <artifactId>quarkus-bom</artifactId>
            <version>${quarkus.platform.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>

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

Der erste Eintrag fügt dem Projekt die oben erwähnte Bill of Materials (BoM) hinzu, und der zweite definiert die ArchUnit-Version, welche in der Quarkus-BoM nicht definiert ist.

Im <dependencies>-Block können wir jetzt bei JUnit und Mockito die Versionsnummern entfernen, da diese Versionen in der Bill of Materials bereits definiert sind. Die Versionen von Lombok und AssertJ hingegen sind in der BoM nicht definiert, wir müssen sie also explizit angeben.

Hier siehst du den vollständigen <dependencies>-Block:

<dependencies>
    <!-- Provided scope (shared by all modules) -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.30</version>
        <scope>provided</scope>
    </dependency>

    <!-- Test scope (shared by all modules) -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <!-- JUnit version comes from Quarkus BoM -->
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.24.2</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.mockito</groupId>
        <artifactId>mockito-core</artifactId>
        <!-- Mockito version comes from Quarkus BoM -->
        <scope>test</scope>
    </dependency>
</dependencies>Code-Sprache: HTML, XML (xml)

Anpassung der adapter/pom.xml

Als nächstes ändern wir die Dependencies in der adapter/pom.xml. Wir haben dort bisher z. B. Dependencies zu Jakarta RESTful Web Services, Jakarta Persistence, RESTEasy, Hibernate und dem MySQL-Treiber angegeben. Wir ersetzen jetzt all diese Libraries durch die entsprechenden Quarkus Extensions (ich zeige dir gleich, wie die pom.xml danach aussehen wird).

Quarkus Extensions sind kleine Wrapper um die eigentlichen Libraries, die diese um einige Informationen anreichern, die es ermöglichen, dass eine Quarkus-Anwendung mit GraalVM in eine native Anwendung kompiliert werden kann.

Wir fügen auch einige Extensions hinzu, die wir aktuell noch nicht brauchen, aber im weiteren Zuge der Migration brauchen werden:

Wir müssen zudem vorübergehend zwei der alten Libraries im Test-Scope bestehen lassen:

  • RESTEasy Undertow – damit starten wir den Undertow-Webserver, solange wir diesen noch nicht durch Quarkus ersetzt haben.
  • Testcontainers/MySQL – damit starten wir MySQL in den Integrationstests. Quarkus verwendet zwar später auch Testcontainers, benötigt dafür aber diese spezifische Dependency nicht.

Hier findest du den vollständigen <dependencies>-Block der adapter/pom.xml:

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

    <!-- External -->
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-arc</artifactId>
    </dependency>
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-hibernate-orm</artifactId>
    </dependency>
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-hibernate-orm-panache</artifactId>
    </dependency>
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-jdbc-mysql</artifactId>
    </dependency>
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-resteasy</artifactId>
    </dependency>
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-resteasy-jackson</artifactId>
    </dependency>

    <!-- Test scope -->
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-junit5</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-junit5-mockito</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>io.rest-assured</groupId>
        <artifactId>rest-assured</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- Required temporarily during migration -->
    <dependency>
        <groupId>org.jboss.resteasy</groupId>
        <artifactId>resteasy-undertow</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>mysql</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- To use the "attached test JAR" from the "model" module -->
    <dependency>
        <groupId>eu.happycoders.shop</groupId>
        <artifactId>model</artifactId>
        <version>${project.version}</version>
        <classifier>tests</classifier>
        <type>test-jar</type>
        <scope>test</scope>
    </dependency>
</dependencies>
Code-Sprache: HTML, XML (xml)

APIs oder Implementierungen importieren?

Falls du das komplette Tutorials bis hierhin durchgearbeitet hast, fällt dir vielleicht auf, dass ich eine Design-Entscheidung revidiert habe:

Bisher hatten wir im adapter-Modul lediglich die Schnittstellen (wie Jakarta RESTful Web Services und Jakarta Persistence) im compile-Scope importiert und deren Implementierungen (wie RESTEasy und Hibernate) im test-Scope. Dementsprechend mussten wir dann die Implementierungen im bootstrap-Modul noch einmal im runtime-Scope importieren.

Die folgende Grafik demonstriert dies beispielhaft für Hibernate und Jakarta Persistence:

Abhängigkeiten der Module zu Hibernate und Jakarta Persistence – bisheriges Modell
Abhängigkeiten der Module zu Hibernate und Jakarta Persistence – bisheriges Modell

Im Zuge der Migration auf Quarkus haben wir nun im adapter-Modul direkt die Implementierungen im compile-Scope importiert, so dass a) explizite Imports der Schnittstellen nicht mehr nötig sind – diese bekommen wir nun als transitive Dependencies über die Implementierungen – und b) wir die Implementierungen nicht zusätzlich im bootstrap-Modul importieren müssen:

Abhängigkeiten der Module zu Hibernate und Jakarta Persistence – neues Modell
Abhängigkeiten der Module zu Hibernate und Jakarta Persistence – neues Modell

Welcher Ansatz ist besser?

Beide haben ihre Vor- und Nachteile. Im ursprünglichen Ansatz haben wir im Adapter keine unnötigen Dependencies im compile-Scope und hätten damit im bootstrap-Modul noch die Möglichkeit, eine andere Implementierung zu wählen (z. B. EclipseLink anstelle von Hibernate oder Jersey anstelle von RESTEasy).

Anderseits – wie wahrscheinlich ist es, dass wir innerhalb eines Projekts unterschiedliche Implementierungen einer API verwenden wollen? Ziemlich unwahrscheinlich! Und somit können wir hier getrost die zweite Variante einsetzen, die weniger Code benötigt und damit übersichtlicher und weniger fehleranfällig ist.

Die erste Variante könnte dann sinnvoll sein, wenn wir eine Library veröffentlichen wollen, die z. B. auf Jakarta Persistence basiert, die für Integrationstests eine bestimmte JPA-Implementierung verwendet, es aber dem Benutzer der Library offen lässt, welche JPA-Implementierung er letztendlich einsetzt.

Anpassung der bootstrap/pom.xml

Kommen wir nun zu den Dependencies in der bootstrap/pom.xml. Hier können wir ersatzlos alle Dependencies im runtime-Scope streichen. Diese benötigen wir nicht mehr, da wir alles Nötige bereits im adapter-Modul im compile-Scope importiert haben.

Im test-Scope müssen wir die Quarkus-Extensions für JUnit und Mockito hinzufügen:

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-junit5</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-junit5-mockito</artifactId>
    <scope>test</scope>
</dependency>Code-Sprache: HTML, XML (xml)

Die Dependency zu RESTEasy Undertow lassen wir vorübergehend bestehen, so dass unsere Anwendung weiterhin lauffähig ist.

Der gesamte <dependencies>-Block der bootstrap/pom.xml sieht damit wie folgt aus:

<dependencies>
    <!-- Internal -->
    <dependency>
        <groupId>eu.happycoders.shop</groupId>
        <artifactId>adapter</artifactId>
        <version>${project.version}</version>
    </dependency>
    <!-- The "application" and "model" modules are transitively included already;
         but we need to include them *explicitly* so that the aggregated JaCoCo report
         will cover them. -->
    <dependency>
        <groupId>eu.happycoders.shop</groupId>
        <artifactId>application</artifactId>
        <version>${project.version}</version>
    </dependency>
    <dependency>
        <groupId>eu.happycoders.shop</groupId>
        <artifactId>model</artifactId>
        <version>${project.version}</version>
    </dependency>

    <!-- External -->
    <dependency>
        <groupId>org.jboss.resteasy</groupId>
        <artifactId>resteasy-undertow</artifactId>
        <exclusions>
            <!-- Conflicts with io.smallrye:jandex, a dependency from Hibernate -->
            <exclusion>
                <groupId>org.jboss</groupId>
                <artifactId>jandex</artifactId>
            </exclusion>
        </exclusions>
    </dependency>

    <!-- Test scope -->
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-junit5</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-junit5-mockito</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>com.tngtech.archunit</groupId>
        <artifactId>archunit-junit5</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>io.rest-assured</groupId>
        <artifactId>rest-assured</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- To use the "attached test JAR" from the "adapter" module -->
    <dependency>
        <groupId>eu.happycoders.shop</groupId>
        <artifactId>adapter</artifactId>
        <version>${project.version}</version>
        <classifier>tests</classifier>
        <type>test-jar</type>
        <scope>test</scope>
    </dependency>
</dependencies>
Code-Sprache: HTML, XML (xml)

Deine pom-xml-Dateien sollten jetzt wie folgt aussehen (die Links führen zum Zwischenstand nach Schritt 1 im GitHub-Repository):

Über den GitHub-Commit von Schritt 1 kannst du alle Änderungen noch mal im Einzelnen nachverfolgen.

Das Projekt sollte sich jetzt compilieren lassen, und alle Tests sollten grün sein:

mvn clean verifyCode-Sprache: Klartext (plaintext)

Beachte, dass wir noch immer unseren eigenen Dependency-Injection-Mechanismus und unser eigenes Transaktionsmanagement verwenden. Im nächsten Schritt werden wir den Code anpassen, um die entsprechenden Funktionalitäten des Application Frameworks zu benutzen.

Schritt 2: Quarkus Dependency Injection im Adapter-Modul

Ein Aufgabe, die ein Application Framework übernimmt, ist: Dependency Injection. Bisher haben wir die Anwendung manuell über Konstruktor-Injektion verdrahtet. Im adapter-Modul war das noch recht übersichtlich, da wir immer nur diejenigen Adapter konstruieren mussten, die wir testen wollten, und alle Use Cases gemockt haben.

Im bootstrap-Modul hingegen mussten wir einen kompletten Launcher schreiben, der alle Services, Controller und Repositories erstellt und miteinander verbindet und dann den Undertow-Webserver startet. Für diese Demo-Anwendung waren das etwa hundert Zeilen Code. Für eine Unternehmensanwendung könnten es aber durchaus auch Tausende bis Zehntausende Zeilen sein.

Dependency Injection mit Quarkus

Der Dependency-Injection-Mechanismus von Quarkus basiert auf dem CDI-Standard, d. h, wir annotieren Klassen, die das Framework instanziieren soll, mit @ApplicationScoped (es gibt noch andere Annotationen, doch die verwenden wir in diesem Tutorial nicht).

In unserer Demo-Anwendung müssen wir das für die vier Repository-Klassen aus dem Paket eu.happycoders.shop.adapter.out.persistence machen (ich zeige dir gleich, wie genau wir das machen):

  • InMemoryCartRepository
  • InMemoryProductRepository
  • JpaCartRepository
  • JpaProductRepository

Unsere Controller-Klassen aus dem eu.happycoders.shop.adapter.in.rest-Paket sind bereits mit Jakarta-RESTful-Web-Services-Annotationen (@Path, @GET, @POST) versehen und werden damit automatisch auch von Quarkus instanziiert.

Konfiguration In-Memory vs. MySQL-Adapter

Einer der Vorteile von Quarkus ist, dass es bereits zur Build-Zeit (und nicht wie Spring und die meisten Jakarta-EE-Frameworks erst zur Laufzeit) die Abhängigkeiten zwischen den Komponenten auflöst und Proxies generiert.

Dementsprechend bietet Quarkus verschiedene Optionen, um Komponenten konfigurationsbedingt zu erstellen oder eben nicht zu erstellen, wie wir es für die In-Memory- bzw. MySQL-Adapter benötigen:

  • Option 1: zur Build-Zeit über ein Build-Profil und @IfBuildProfile-Annotationen an den Repository-Implementierungen.
  • Option 2: zur Build-Zeit über eine Build-Property und @IfBuildProperty-Annotationen an den Repository-Implementierungen.
  • Option 3: zur Laufzeit über @LookupIfProperty-Annotationen an den Repository-Implementierungen.

Da die Datenbank-Verbindungseinstellungen letztlich auch erst zur Laufzeit geladen werden, entscheide ich mich für die dritte Variante.

Dementsprechend annotieren wir unsere vier Repository-Klassen nun wie folgt:

@LookupIfProperty(name = "persistence", stringValue = "inmemory", lookupIfMissing = true)
@ApplicationScoped
public class InMemoryCartRepository implements CartRepository {
  . . .
}

@LookupIfProperty(name = "persistence", stringValue = "inmemory", lookupIfMissing = true)
@ApplicationScoped
public class InMemoryProductRepository implements ProductRepository {
  . . .
}

@LookupIfProperty(name = "persistence", stringValue = "mysql")
@ApplicationScoped
public class JpaCartRepository implements CartRepository {
  . . .
}

@LookupIfProperty(name = "persistence", stringValue = "mysql")
@ApplicationScoped
public class JpaProductRepository implements ProductRepository {
  . . .
}
Code-Sprache: Java (java)

Was bedeutet das genau?

  • Wenn wir unsere Anwendung mit dem Konfigurationseintrag persistence=inmemory oder ohne persistence-Eintrag starten, werden die In-Memory-Adapter instanziiert.
  • Wenn wir die Anwendung mit dem Konfigurationseintrag persistence=mysql starten, werden die MySQL-Adapter instanziiert.

Konfigurationseinträge können wir in Quarkus entsprechend dem MicroProfile-Config-Standard definieren. Wir können sie also in die application.properties eintragen, über Umgebungsvariablen festlegen oder als System Properties angeben.

Damit können wir unsere Anwendung also genau so konfigurieren, wie wir es auch vor der Umstellung auf Quarkus konnten (z. B. beim Start mit dem Paramter -Dpersistence=mysql).

Persistenz-Konfiguration entfernen

Quarkus findet automatisch alle mit @Entity annotierten JPA-Entities. Wir können daher die Datei persistence.xml im Verzeichnis resources/META-INF des adapter-Moduls ersatzlos löschen – und damit auch das gesamte resources-Verzeichnis, das nun keine Dateien mehr enthält.

Das war auch schon alles, was wir am eigentlichen Adapter-Code ändern mussten. Als nächstes müssen wir die Integrationstests anpassen.

Quarkus-Testprofile

Durch die Angabe „lookupIfMissing = true” in den @LookupIfProperty-Annotationen der In-Memory-Adapter haben wir festgelegt, dass die In-Memory-Adpater auch dann geladen werden, wenn die persistence-Property nicht gesetzt ist. In-Memory ist also der Standard (so wie es auch vor der Umstellung auf Quarkus der Fall war).

Um nun auch die MySQL-Adapter testen zu können, müssen wir ein Quarkus-Testprofil erstellen, das die persistence-Property auf „mysql” setzt.

Dazu legen wir im Paket eu.happycoders.shop.adapter des Testverzeichnisses die Klasse TestProfileWithMySQL an, mit folgendem Inhalt:

public class TestProfileWithMySQL implements QuarkusTestProfile {

  @Override
  public Map<String, String> getConfigOverrides() {
    return Map.of("persistence", "mysql");
  }
}
Code-Sprache: Java (java)

Im Folgenden können wir dann Integrationstests mit @TestProfile(TestProfileWithMySQL.class) annotieren, um sie mit der Einstellung persistence=mysql zu starten.

Anpassung der Repository-Tests

Da wir gerade bei den Testprofilen sind, beginnen wir damit, die Integrationstests für die Repositories anzupassen. Zu den Controller-Tests kommen wir im nächsten Abschnitt.

Zur Erinnerung: Für die Repository-Tests haben wir eine Klassenhierarchie angelegt, in der wir die eigentlichen Tests in einer abstrakten Basisklasse definiert haben und in jeweils zwei konkreten abgeleiteten Implementierungen die In-Memory- bzw. JPA-Adapter erzeugt haben:

Hierarchie der JPA-Adapter-Testklassen
Hierarchie der JPA-Adapter-Testklassen

Diese Struktur lassen wir bestehen, allerdings können wir einiges an Code entfernen, da wir das Erzeugen der Adapter-Instanzen jetzt dem Framework überlassen können. Ich zeige dir im Folgenden beispielhaft die Änderungen an den Produkt-Repository-Tests. Die Cart-Repository-Tests werden analog angepasst, und ich werde am Ende des Abschnitts die entsprechenden Klassen im GitHub-Repository verlinken.

AbstractProductRepositoryTest

Beginnen wir mit der abstrakten Basisklasse, AbstractProductRepositoryTest. So sieht die Klasse bisher aus:

public abstract class AbstractProductRepositoryTest<T extends ProductRepository> {

  private T productRepository;

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

  protected abstract T createProductRepository();

  . . .
}Code-Sprache: Java (java)

Wir ändern die Zeilen bis zu den drei Punkten wie folgt ab:

public abstract class AbstractProductRepositoryTest {

  @Inject Instance<ProductRepository> productRepositoryInstance;

  private ProductRepository productRepository;

  @BeforeEach
  void initRepository() {
    productRepository = productRepositoryInstance.get();
  }

  . . .
}Code-Sprache: Java (java)

Was haben wir hier im Einzelnen getan?

  • Über die Zeile @Inject Instance<ProductRepository> productRepositoryInstance lassen wir das Framework eine Instanz des ProductRepository-Interfaces injizieren. Der Umweg über Instance ist nötig, da die konkrete Klasse zur Compile-Zeit noch nicht bekannt ist – zur Laufzeit könnte das ein InMemoryProductRepository oder ein JpaProductRepository sein.
  • In der @BeforeEach-Methode laden wir die konkrete Instanz über die Instance.get()-Methode.
  • Die bisher benutzte abstrakte Methode createProductRepository() benötigen wir nicht mehr und können sie dementsprechend löschen.
  • Den Typparamter der Klasse, <T extends ProductRepository>, benötigen wir ebenfalls nicht mehr – auch diesen können wir entfernen.

Du findest die angepasste Klasse hier im GitHub-Repository: AbstractProductRepositoryTest

JpaProductRepositoryTest

Kommen wir zur JPA-Implementierung des Tests, JpaProductRepositoryTest. So sieht die Klasse bisher aus:

class JpaProductRepositoryTest
    extends AbstractProductRepositoryTest<JpaProductRepository> {

  private static MySQLContainer<?> mysql;
  private static EntityManagerFactory entityManagerFactory;

  @BeforeAll
  static void startDatabase() {
    mysql = new MySQLContainer<>(DockerImageName.parse("mysql:8.1"));
    mysql.start();

    entityManagerFactory =
        EntityManagerFactoryFactory.createMySqlEntityManagerFactory(
            mysql.getJdbcUrl(), "root", "test");
  }

  @Override
  protected JpaProductRepository createProductRepository() {
    return new JpaProductRepository(entityManagerFactory);
  }

  @AfterAll
  static void stopDatabase() {
    entityManagerFactory.close();
    mysql.stop();
  }
}Code-Sprache: Java (java)

All den Boilerplate-Code können wir rausschmeißen und durch lediglich zwei Annotationen ersetzen. So sieht die Klasse nach der Umstellung aus:

@QuarkusTest
@TestProfile(TestProfileWithMySQL.class)
class JpaProductRepositoryTest extends AbstractProductRepositoryTest {}Code-Sprache: Java (java)

Ja, du siehst richtig, die Methode enthält keine einzige Methode mehr! Wie haben wir das im Einzelnen erreicht?

  • Die createProductRepository()-Methode haben wir in der abstrakten Basisklasse nicht mehr benötigt, da wir mit dem Dependency-Injection-Mechanismus des Frameworks arbeiten. Sie kann also auch aus der Implementierung entfernt werden.
  • Um das Framework zu starten, müssen wir die Testklasse stattdessen mit @QuarkusTest annotieren.
  • Durch die Annotation @TestProfile(TestProfileWithMySQL.class) legen wir fest, dass Quarkus im entsprechenden Testprofil eine Instanz von JpaProductRepository anstelle von InMemoryProductRepository erzeugt (s. Abschnitt Konfiguration In-Memory vs. MySQL-Adapter).
  • Und da Quarkus erkennt, dass unser Integrationstest eine Datenbank benötigt, fährt es vollautomatisch per Testcontainers und Docker eine MySQL-Datenbank hoch¹. Damit können wir auch die Methoden startDatabase() und startDatabase() entfernen.

Du findest die angepasst Klasse hier im GitHub-Repository: JpaProductRepositoryTest

¹ Dass Quarkus MySQL und nicht eine andere Datenbank starten soll, erkennt es am Import von quarkus-jdbc-mysql in der pom.xml. Quarkus startet standardmäßig MySQL 8.0. Wenn du eine andere Version starten möchtest, kannst du das über den Konfigurationseintrag quarkus.datasource.devservices.image-name realisieren, z. B. über den Eintrag quarkus.datasource.devservices.image-name=mysql:8.2.0 in der Datei test/resources/application.properties im adapter-Modul.

InMemoryRepositoryTest

Kommen wir zur In-Memory-Implementierung, InMemoryRepositoryTest, die bisher so aussieht:

class InMemoryProductRepositoryTest
    extends AbstractProductRepositoryTest<InMemoryProductRepository> {

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

Wir entfernen wieder die createProductRepository()-Methode und fügen die @QuarkusTest-Annotation hinzu:

@QuarkusTest
class InMemoryProductRepositoryTest extends AbstractProductRepositoryTest {}Code-Sprache: Java (java)

Eine @TestProfile-Annotation benötigen wir nicht, da das In-Memory-Repository standardmäßig instanziiert wird.

Du findest die angepasst Klasse hier im GitHub-Repository: InMemoryProductRepositoryTest

CartRepository-Tests

Ich ermutige dich, die entsprechenden Änderungen für die CartRepository-Tests zu Übungszwecken einmal selbst durchzuführen. Wenn du damit fertig bist, kannst du deine Änderungen hier mit meinen vergleichen:

Anpassung der Controller-Tests

Kommen wir zu den Controller-Tests. Auch hier werde ich dir die Änderungen wieder nur am Beispiel des Produkt-Controllers zeigen. Der Test für den Cart-Controller wird analog geändert.

So sieht die Klasse ProductsControllerTest bisher aus:

class ProductsControllerTest {

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

  private static final FindProductsUseCase findProductsUseCase = 
      mock(FindProductsUseCase.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 FindProductsController(findProductsUseCase));
                  }
                });
  }

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

  @BeforeEach
  void resetMocks() {
    Mockito.reset(findProductsUseCase);
  }

  . . .
}Code-Sprache: Java (java)

Wir ändern die Zeilen bis zu den drei Punkten wie folgt ab:

@QuarkusTest
class ProductsControllerTest {

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

  @InjectMock FindProductsUseCase findProductsUseCase;

  . . .
}Code-Sprache: Java (java)

Wir konnten erneut eine ganze Menge Boilerplate-Code entfernen. Im Einzelnen haben wir folgendes getan:

  • Wir annotieren die Testklasse mit @QuarkusTest, um den Integrationstest mit einer laufenden Quarkus-Anwendung zu starten.
  • Den FindProductsUseCase mocken wir nicht mehr explizit über Mockito.mock(...), sondern durch die Annotation @InjectMock. Diese Annotation erzeugt den Mock und weist Quarkus an, die gemockte Use-Case-Instanz überall dort zu injizieren, wo ein FindProductsUseCase benötigt wird.
  • Die init()- und stop()-Methoden können wir entfernen, da wir zum einen den Undertow-Webserver nicht mehr benötigen und zum anderen den FindProductsController nicht mehr manuell erzeugen müssen – durch die Jakarta-RESTful-Web-Services-Annotationen wird dieser vom Framework erkannt und automatisch instanziiert. Und über den FindProductsController-Konstruktor wird automatisch der gemockte FindProductsUseCase injiziert.
  • Auch die resetMocks()-Methode können wir entfernen. Quarkus erzeugt für jeden Test einen frischen Mock.

Zudem entfernen wir alle Aufrufe von port(TEST_PORT) aus den einzelnen Tests. Quarkus sorgt automatisch dafür, dass REST Assured die Anwendung auf dem richtigen Port aufruft.

Versuche gerne wieder, den CartsControllerTest selbst anzupassen. Hier findest du die angepassten Testklassen im GitHub-Repository:

Damit sollten wir fertig mit den Umstellungen des adapter-Modus sein. Allerdings führt ein Aufruf von mvn clean verify an dieser Stelle noch zu folgender Fehlermeldung (ich habe sie auf das Wesentliche gekürzt):

Found 4 deployment problems:
[1] Unsatisfied dependency for type AddToCartUseCase
     - java member: AddToCartController():addToCartUseCase
[2] Unsatisfied dependency for type EmptyCartUseCase
     - java member: EmptyCartController():emptyCartUseCase
[3] Unsatisfied dependency for type FindProductsUseCase
     - java member: FindProductsController():findProductsUseCase
[4] Unsatisfied dependency for type GetCartUseCase
     - java member: GetCartController():getCartUseCase Code-Sprache: Klartext (plaintext)

Was bedeutet das, und was müssen wir tun, um dieses Problem zu beheben? Das erkläre ich dir im nächsten Abschnitt.

Definition der Service-Komponenten

Die Klassen AddToCartController, EmptyCartController, FindProductsController und GetCartController haben jeweils einen Konstruktur, über den eine Instanz von AddToCartUseCase, EmptyCartUseCase, FindProductsUseCase und GetCartUseCase injiziert werden muss.

Wir benötigen zwar in all unseren Integrationstests nur die jeweils gemockten Komponenten, das kann Quarkus aber nicht erkennen – um die Anwendung hochzufahren, benötigt es daher alle Komponenten.

Wir müssen daher an dieser Stelle definieren, wie Quarkus Instanzen der Use-Case-Interfaces erzeugen kann.

Eine Möglichkeit wäre es, im application-Modul die Klassen AddToCartUseService, EmptyCartUseService, FindProductsService und GetCartUseService jeweils mit @ApplicationScoped zu annotieren. Dazu müssten wir das application-Modul aber mit einer Dependency auf Quarkus „verschmutzen”. Das wollen wir aber nicht tun – das application-Modul soll frei von jeglichen technischen Details bleiben!

Wir legen daher im adapter-Modul im Paket eu.happycoders.shop eine Klasse QuarkusAppConfig an (der Name spielt keine Rolle), die folgenden Inhalt hat:

class QuarkusAppConfig {

  @Inject Instance<CartRepository> cartRepository;
  @Inject Instance<ProductRepository> productRepository;

  @Produces
  @ApplicationScoped
  GetCartUseCase getCartUseCase() {
    return new GetCartService(cartRepository.get());
  }

  @Produces
  @ApplicationScoped
  EmptyCartUseCase emptyCartUseCase() {
    return new EmptyCartService(cartRepository.get());
  }

  @Produces
  @ApplicationScoped
  FindProductsUseCase findProductsUseCase() {
    return new FindProductsService(productRepository.get());
  }

  @Produces
  @ApplicationScoped
  AddToCartUseCase addToCartUseCase() {
    return new AddToCartService(cartRepository.get(), productRepository.get());
  }
}
Code-Sprache: Java (java)

Die jeweils mit @Produces und @ApplicationScope annotierten Methoden erzeugen unsere vier Services. Die Repositories, die diese über den Konstruktor injiziert bekommen möchten, lassen wir uns über die zwei mit @Inject-annotierten Felder von Quarkus vorab injizieren.

Quarkus erkennt alle Abhängigkeiten und erzeugt die Komponenten entsprechend in folgender Reihenfolge:

  1. Zuerst die mit @ApplicationScope annotierten In-Memory- und JPA-Repositories.
  2. Dann die über die Producer-Methoden definierten Services.
  3. Und zuletzt die mit @Path, @POST und @GET annotierten Controller.

Jetzt sollte sich das Projekt wieder fehlerfrei kompilieren und testen lassen:

mvn clean verifyCode-Sprache: Klartext (plaintext)

Zum Abschluss können wir noch die pom.xml des adapter-Moduls aufräumen und die Dependencies zu resteasy-undertow und org.testcontainers:mysql entfernen.

Über den GitHub-Commit von Schritt 2 kannst du alle Änderungen im Detail nachverfolgen.

Es lässt sich darüber streiten, ob die Definition der Service-Klassen in das adapter-Modul oder das bootstrap-Modul gehört. Da das adapter-Modul die Service-Implementierungen nicht zwingend benötigt (sondern nur Mocks für die Integrationstests), könnten wir die Producer-Methoden auch Dummy-Implementierungen der Use-Cases zurückgeben lassen und dann im bootstrap-Modul die finalen Producer-Methoden definieren, die die Services erzeugen.

Das hätte allerdings einige unschöne Konsequenzen: Da das bootstrap-Modul das adapter-Modul importiert und die bootstrap-Tests die adapter-Tests, wären die Producer-Methoden des adapter-Moduls auch im bootstrap-Modul sichtbar. Im bootstrap-Modul gäbe es dann jeweils zwei Komponenten desselben Interfaces, und Quarkus ließe sich nicht starten.

Um das aufzulösen, hätten wir zwei Optionen: Wir könnten auf den Import der adapter-Tests in die bootstrap-Tests verzichten – dann müssten wir allerdings eine Menge Hilfsmethoden aus den adapter-Tests in den bootstrap-Tests duplizieren. Alternativ könnten wir zwei separate Test-Profile für die adapter-Tests anlegen und die QuarkusAppConfig-Klasse im adapter-Modul mit einer @IfBuildProfile-Annotation versehen.

Beide Optionen erfordern eine Menge Overhead, so dass ich mich letztlich für die einfachere Lösung entschieden und die Services im adapter-Modul definiert habe.

Schritt 3: Quarkus im Boostrap-Modul

Wir haben zu diesem Stand eine Anwendung, deren Adapter-Klassen innerhalb von Integrationstests mit Quarkus lauffähig sind, die aber im bootstrap-Modul noch mit einem Undertow-Webserver gestartet wird. Das ist möglich, da wir ausschließlich mit Jakarta-Standards gearbeitet haben.

Trotzdem wollen wir nun auch das bootstrap-Modul auf Quarkus umstellen. Das geht zum Glück viel schneller als die Umstellung des adapter-Moduls.

Beginnen wir mit den Tests...

Anpassung der End-to-End-Tests

Ich zeige dir die notwendigen Anpassungen wieder am Beispiel des Produkt-Tests, Klasse FindProductsTest. So sieht die Klassendefinition aktuell aus:

class FindProductsTest extends EndToEndTest {
  . . .
}Code-Sprache: Java (java)

Wir ersetzen den Header durch:

@QuarkusTest
@TestProfile(TestProfileWithMySQL.class)
class FindProductsTest {
  . . .
}Code-Sprache: Java (java)

Was haben wir im Detail geändert?

  • Wir haben die @QuarkusTest-Annotation hinzugefügt, um den Test mit einer laufenden Quarkus-Anwendung auszuführen.
  • Dementsprechend braucht die Testklasse nicht mehr von der Elternklasse EndToEndTest erben, die bisher für das Verdrahten der Komponenten und den Start der Anwendung zuständig war.
  • Mit der Annotation @TestProfile(TestProfileWithMySQL.class) starten wir die Anwendung im MySQL-Modus, so dass der End-to-End-Tests auch die Persistierung der Daten in der Datenbank umfasst.

Zudem entfernen wir wieder alle Aufrufe von port(TEST_PORT) aus den einzelnen Tests.

Versuche gerne wieder einmal, den CartTest selbst anzupassen. Hier findest du die angepassten Testklassen im GitHub-Repository:

Folgende Klassen brauchen wir nun nicht mehr, wir können sie alle löschen:

  • die ehemalige Elternklasse der Tests, EndToEndTest,
  • die Starterklassen Launcher und RestEasyUndertowShopApplication, die die Anwendung mit dem Undertow-Webserver starten (damit enthält das bootstrap-Modul keinerlei Java-Code mehr),
  • die EntityManagerFactoryFactory aus dem adapter-Modul,
  • die Konstante TEST_PORT aus der Klasse HttpTestCommons des adapter-Moduls.

Zudem können wir die Dependency zu resteasy-undertow aus der pom.xml des bootstrap-Moduls entfernen.

Ein Aufruf von mvn clean verify zeigt uns, dass die angepassten Tests erfolgreich durchlaufen.

Doch wie können wir unsere Quarkus-Anwendung jetzt starten – ohne die soeben gelöschten Starterklassen?

Starten der Quarkus-Anwendung

Das machen wir über das Quarkus-Maven-Plugin. Dieses erzeugt eine startbare Anwendung und bietet uns darüber hinaus die Möglichkeit, Quarkus im sogenannten Dev-Modus zu starten.

Als erstes fügen wir das Plugin wie folgt in den <plugins>-Block der Eltern-pom.xml ein:

<plugin>
    <groupId>io.quarkus.platform</groupId>
    <artifactId>quarkus-maven-plugin</artifactId>
    <version>${quarkus.platform.version}</version>
</plugin>
Code-Sprache: HTML, XML (xml)

Als zweites fügen wir den folgenden <build>-Block in die pom.xml des bootstrap-Moduls ein (z. B. hinter dem <dependencies>-Block):

<build>
    <plugins>
        <plugin>
            <groupId>io.quarkus.platform</groupId>
            <artifactId>quarkus-maven-plugin</artifactId>
            <extensions>true</extensions>
            <executions>
                <execution>
                    <goals>
                        <goal>build</goal>
                        <goal>generate-code</goal>
                        <goal>generate-code-tests</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

Mit dem <executions>-Block sagen wir Maven, dass es das Plugin in den drei aufgelisteten Phasen ausführen soll.

Jetzt können wir die Anwendung über das folgende Kommando im Dev-Modus starten:

mvn quarkus:devCode-Sprache: Klartext (plaintext)

Nach wenigen Sekunden solltest du folgendes sehen:

hexagonal architecture quarkus demo

Du kannst jetzt die Endpunkte der Anwendung ausprobieren, so wie es im Abschnitt „Start der Anwendung” des zweiten Teils der Serie beschrieben ist.

Für diejenigen Leser, die mit Quarkus nicht vertraut sind, möchte ich auf ein bemerkenswertes Feature des Dev-Modus hinweisen: Änderungen, die du jetzt am Code vornimmst, wirken sich sofort auf die laufende Anwendung aus – ohne dass du sie manuell neu kompilieren und neu starten musst – probier es einmal aus!

Alle Änderungen aus Schritt 3 kannst du über den entsprechenden GitHub-Commit nachverfolgen.

Schritt 4: @Transactional und Panache

Wir haben jetzt zwar eine lauffähige Anwendung, doch eine Baustelle haben wir noch:

In den JPA-Repositories im adapter-Modul haben wir noch einiges an Boilerplate-Code zurückgelassen – und zwar den für die Transaktionsverwaltung und das Laden und Speichern der Entities. Auch hier kann uns Quarkus einiges an Overhead abnehmen.

Panache Repositories für CRUD-Operationen

Wir erstellen zunächst für unsere zwei Entity-Typen jeweils ein Panache Repository. Ein Panache Repository implementiert grundlegende CRUD-Operationen (analog zum CrudRepository von Spring Data JPA).

Wir legen folgende zwei Klassen im Paket eu.happycoders.shop.adapter.out.persistence.jpa des adapter-Moduls an:

@ApplicationScoped
public class JpaCartPanacheRepository
    implements PanacheRepositoryBase<CartJpaEntity, Integer> {}

@ApplicationScoped
public class JpaProductPanacheRepository
    implements PanacheRepositoryBase<ProductJpaEntity, String> {}Code-Sprache: Java (java)

Beide Klassen implementieren das Interface PanacheRepositoryBase und geben als Typparameter die Typen der zu speichernden Entities und deren Primärschlüssel an.

Anpassung von JpaCartRepository

In der Klasse JpaCartRepository ersetzen wir nun die folgenden Zeilen:

private final EntityManagerFactory entityManagerFactory;

public JpaCartRepository(EntityManagerFactory entityManagerFactory) {
  this.entityManagerFactory = entityManagerFactory;
}
Code-Sprache: Java (java)

durch:

private final JpaCartPanacheRepository panacheRepository;

public JpaCartRepository(JpaCartPanacheRepository panacheRepository) {
  this.panacheRepository = panacheRepository;
}
Code-Sprache: Java (java)

und die folgenden drei Methoden:

@Override
public void save(Cart cart) {
  try (EntityManager entityManager = entityManagerFactory.createEntityManager()) {
    entityManager.getTransaction().begin();
    entityManager.merge(CartMapper.toJpaEntity(cart));
    entityManager.getTransaction().commit();
  }
}

@Override
public Optional<Cart> findByCustomerId(CustomerId customerId) {
  try (EntityManager entityManager = entityManagerFactory.createEntityManager()) {
    CartJpaEntity cartJpaEntity = 
        entityManager.find(CartJpaEntity.class, customerId.value());
    return CartMapper.toModelEntityOptional(cartJpaEntity);
  }
}

@Override
public void deleteByCustomerId(CustomerId customerId) {
  try (EntityManager entityManager = entityManagerFactory.createEntityManager()) {
    entityManager.getTransaction().begin();

    CartJpaEntity cartJpaEntity = 
        entityManager.find(CartJpaEntity.class, customerId.value());

    if (cartJpaEntity != null) {
      entityManager.remove(cartJpaEntity);
    }

    entityManager.getTransaction().commit();
  }
}Code-Sprache: Java (java)

durch:

@Override
@Transactional
public void save(Cart cart) {
  panacheRepository.getEntityManager().merge(CartMapper.toJpaEntity(cart));
}

@Override
@Transactional
public Optional<Cart> findByCustomerId(CustomerId customerId) {
  CartJpaEntity cartJpaEntity = panacheRepository.findById(customerId.value());
  return CartMapper.toModelEntityOptional(cartJpaEntity);
}

@Override
@Transactional
public void deleteByCustomerId(CustomerId customerId) {
  panacheRepository.deleteById(customerId.value());
}Code-Sprache: Java (java)

Wir haben das manuelle Starten und Committen der Transaktionen durch eine @Transactional-Annotation ersetzt und die umständliche Verwendung des Entity Managers durch eine deutlich komfortablere Verwendung des Panache Repositories.

Anpassung von JpaProductRepository

In der Klasse JpaProductRepository gehen wir analog vor – wir ersetzen die folgenden Zeilen:

private final EntityManagerFactory entityManagerFactory;

public JpaProductRepository(EntityManagerFactory entityManagerFactory) {
  this.entityManagerFactory = entityManagerFactory;
  createDemoProducts();
}

private void createDemoProducts() {
  DemoProducts.DEMO_PRODUCTS.forEach(this::save);
}
Code-Sprache: Java (java)

durch

private final JpaProductPanacheRepository panacheRepository;

public JpaProductRepository(JpaProductPanacheRepository panacheRepository) {
  this.panacheRepository = panacheRepository;
}

@PostConstruct
void createDemoProducts() {
  DemoProducts.DEMO_PRODUCTS.forEach(this::save);
}Code-Sprache: Java (java)

Beachte, dass wir createDemoProducts() nicht mehr im Konstruktor aufrufen dürfen, da die Anwendung beim Aufruf des Konstruktors noch nicht in einem Zustand ist, in dem sie über die Panache Repositories auf die Datenbank zugreifen kann.

Stattdessen annotieren wir die createDemoProducts()-Methode mit @PostConstruct, was Quarkus anweist, die Methode aufzurufen, sobald alle Komponenten der Anwendung erzeugt und verdrahtet wurden (genauso wie man es auch in Spring machen würde).

Und zuletzt ersetzen wir die folgenden drei Methoden:

@Override
public void save(Product product) {
  try (EntityManager entityManager = entityManagerFactory.createEntityManager()) {
    entityManager.getTransaction().begin();
    entityManager.merge(ProductMapper.toJpaEntity(product));
    entityManager.getTransaction().commit();
  }
}

@Override
public Optional<Product> findById(ProductId productId) {
  try (EntityManager entityManager = entityManagerFactory.createEntityManager()) {
    ProductJpaEntity jpaEntity =
        entityManager.find(ProductJpaEntity.class, productId.value());
    return ProductMapper.toModelEntityOptional(jpaEntity);
  }
}

@Override
public List<Product> findByNameOrDescription(String queryString) {
  try (EntityManager entityManager = entityManagerFactory.createEntityManager()) {
    TypedQuery<ProductJpaEntity> query =
        entityManager
            .createQuery(
                "from ProductJpaEntity "
                        + "where name like :query or description like :query",
                ProductJpaEntity.class)
            .setParameter("query", "%" + queryString + "%");

    List<ProductJpaEntity> entities = query.getResultList();

    return ProductMapper.toModelEntities(entities);
  }
}Code-Sprache: Java (java)

durch

@Override
@Transactional
public void save(Product product) {
  panacheRepository.getEntityManager().merge(ProductMapper.toJpaEntity(product));
}

@Override
@Transactional
public Optional<Product> findById(ProductId productId) {
  ProductJpaEntity jpaEntity = panacheRepository.findById(productId.value());
  return ProductMapper.toModelEntityOptional(jpaEntity);
}

@Override
@Transactional
public List<Product> findByNameOrDescription(String queryString) {
  List<ProductJpaEntity> entities =
      panacheRepository
          .find("name like ?1 or description like ?1", "%" + queryString + "%")
          .list();

  return ProductMapper.toModelEntities(entities);
}Code-Sprache: Java (java)

Auch hier konnten wir durch die Verwendung der @Transactional-Annotation und des Panache Repositories eine Menge Boilerplate-Code entfernen.

Mit einem Aufruf von mvn clean verify können wir uns bestätigen lassen, dass unsere Repositories weiterhin das tun, was sie tun sollen.

Hier findest du alle in diesem Schritt hinzugefügten bzw. geänderten Klassen im GitHub-Repository:

Alle Änderungen aus Schritt 4 findest du auch in diesem GitHub-Commit.

Bauen und Starten der Anwendung im Produktivmodus

Bisher haben wir die Anwendung nur über mvn quarkus:dev im Quarkus-Dev-Mode gestartet. Doch wie können wir die Anwendung im Produktivmodus bauen und starten?

Konfiguration des Produktivmodus

Um unsere Anwendung im Produktivmodus zu starten, müssen wir die Datenbankverbindung konfigurieren. Da diese von der Umgebung abhängen, machen wir das in der Regel über Umgebungsvariablen. Für unsere kleine Demo-Anwendung ist es aber einfacher, die Parameter in der application.properties-Datei zu hinterlegen.

Lege dazu eine Datei application.properties im Verzeichnis bootstrap/src/main/resources an, mit folgendem Inhalt:

%prod.quarkus.datasource.jdbc.url=dummy
%prod.persistence=inmemory

%mysql.quarkus.datasource.jdbc.url=jdbc:mysql://localhost:3306/shop
%mysql.quarkus.datasource.username=root
%mysql.quarkus.datasource.password=test
%mysql.quarkus.hibernate-orm.database.generation=update
%mysql.persistence=mysql
Code-Sprache: Klartext (plaintext)

Mit dem Präfix %prod bzw. %mysql definieren wir eine Property für ein bestimmtes Profil.

Konfiguration des Produktivmodus – In-Memory-Modus

Das Profil „prod” ist das Standardprofil für den Produktivmodus – hier wollen wir den In-Memory-Modus verwenden.

Leider müssen wir auch im In-Memory-Modus eine JDBC-URL angeben. Ohne diese würde Quarkus mit folgender Fehlermeldung abbrechen:

Model classes are defined for the default persistence unit but configured datasource not found: the default EntityManagerFactory will not be created.

Quarkus würde sich daran stören, dass im Code Entity-Klassen existieren, dass aber keine Datenbankverbindung definiert ist. Das ist leider ein unschöner Aspekt unserer Demo-Anwendung. In einer realen Anwendung ist es eher unwahrscheinlich, dass wir JPA-Entities definieren, die Anwendung aber nicht mit einer Datenbank verbinden.

Konfiguration des Produktivmodus – MySQL-Modus

Im Profil „mysql” wollen wir den MySQL-Modus verwenden und geben entsprechend die MySQL-Verbindungsdaten für die Testdatenbank an (die wir im Folgenden über Docker starten werden).

Mit dem Paramter quarkus.hibernate-orm.database.generation=update definieren wir, dass Hibernate alle Datenbanktabellen automatisch erstellen und ggf. aktualisieren soll. Dieser Paramter war in den Tests und im Dev-Mode automatisch gesetzt.

In Produktion sollten wir diese Einstellung eigentlich nicht verwenden und stattdessen ein Tool wie Flyway oder Liquibase einsetzen. Doch für unsere Demo-Anwendung halte ich den Extra-Aufwand, den das mit sich bringen würde, für übertrieben.

Quarkus-Anwendung bauen

Wir bauen die Anwendung über das Quarkus-Maven-Plugin, über folgendes Kommando:

mvn clean packageCode-Sprache: Klartext (plaintext)

Danach findest du die lauffähige Anwendung im Verzeichnis bootstrap/target/quarkus-app.

Start der Quarkus-Anwendung im In-Memory-Modus

Du kannst die Anwendung nun mit folgendem Kommando im In-Memory-Modus starten:

cd bootstrap/target/quarkus-app
java -jar quarkus-run.jarCode-Sprache: Klartext (plaintext)

Du wirst eine Warnung sehen, dass die in der application.properties definierte Dummy-JDBC-URL ungültig ist. Diese Warnung kannst du ignorieren, da sich die Anwendung in diesem Modus ja nicht mit einer Datenbank verbindet.

Start der Quarkus-Anwendung im MySQL-Modus

Um die Anwendung im MySQL-Modus zu starten, musst du zunächst eine MySQL-Datenbank starten. Das kannst du mit folgendem Docker-Kommando machen (Achtung: wenn du Windows verwendest, musst du alles in eine Zeile schreiben und die Backslashes entfernen):

docker run --name hexagon-mysql -d -p3306:3306 \
    -e MYSQL_DATABASE=shop -e MYSQL_ROOT_PASSWORD=test mysql:8.2Code-Sprache: Klartext (plaintext)

Danach startest du die Anwendung wie folgt:

java -jar -Dquarkus.profile=mysql quarkus-run.jarCode-Sprache: Klartext (plaintext)

Konfiguration der Datenbankverbindung über System Properties oder Umgebungsvariablen

Falls deine Datenbankparameter anders sein sollten, kannst du diese in der application.properties abändern, oder zur Laufzeit alternative Werte angeben.

Dies geht zum einen über System Properties, z. B. wie folgt:

java -jar \
    -Dquarkus.profile=mysql \
    -Dquarkus.datasource.jdbc.url=jdbc:mysql://<hostname and port>/<database name> \
    -Dquarkus.datasource.username=<your username> \
    -Dquarkus.datasource.password=<your password> \
    quarkus-run.jarCode-Sprache: Klartext (plaintext)

Alternativ kannst du die Einstellungen auch über Umgebungsvariablen definieren (unter Windows musst du set statt export verwenden):

export QUARKUS_PROFILE=mysql
export QUARKUS_DATASOURCE_JDBC_URL=jdbc:mysql://<hostname and port>/<database name>
export QUARKUS_DATASOURCE_USERNAME=<your username>
export QUARKUS_DATASOURCE_PASSWORD=<your password>
java -jar quarkus-run.jarCode-Sprache: Klartext (plaintext)

Start der Quarkus-Anwendung mit Docker

Du kannst die Anwendung auch sehr einfach in ein Docker-Image verpacken. Lege dazu im Verzeichnis bootstrap/src/main/docker eine Datei mit dem Namen Dockerfile.jvm und mit folgendem Inhalt an:

FROM eclipse-temurin:20

ENV LANGUAGE='en_US:en'

COPY --chown=185 target/quarkus-app/lib/ /deployments/lib/
COPY --chown=185 target/quarkus-app/*.jar /deployments/
COPY --chown=185 target/quarkus-app/app/ /deployments/app/
COPY --chown=185 target/quarkus-app/quarkus/ /deployments/quarkus/

EXPOSE 8080
USER 185
ENV JAVA_OPTS="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager"
ENV JAVA_APP_JAR="/deployments/quarkus-run.jar"

ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar $JAVA_APP_JAR"]Code-Sprache: Dockerfile (dockerfile)

Führe danach im Projektverzeichnis folgendes Kommando aus:

docker build -f bootstrap/src/main/docker/Dockerfile.jvm \
    -t happycoders/shop-demo bootstrapCode-Sprache: Klartext (plaintext)

Danach kannst du das Docker-Image z. B. wie folgt starten:

docker run -p 8080:8080 happycoders/shop-demoCode-Sprache: Klartext (plaintext)

Jetzt könnten wir z. B. noch eine docker-compose.yml-Datei erstellen, die eine MySQL-Datenbank und unseren Shop im MySQL-Modus hochfährt. Doch da es sich hier um ein Tutorial für hexagonale Architektur handelt und nicht um ein Docker-Tutorial, überlasse ich weitere Experimente zum Thema Docker dir.

Fazit und Ausblick

In diesem Teil des Tutorials über die hexagonale Architektur haben wir unsere in den vorherigen Teilen der Serie entwickelte Demo-Shop-Anwendung auf das Quarkus-Framework migriert. Dadurch konnten wir die Dependencies vereinheitlichen und eine Menge Boilerplate Code entfernen.

Ein Application Framework bereitet unsere Anwendung auch für einen möglichen Einsatz in Produktion vor – wir könnten jetzt mit relativ überschaubarem Aufwand unsere Anwendung „production-ready” machen:

Bei der Migration zu Quarkus hat sich erneut der große Vorteil der hexagonalen Architektur gezeigt: Wir brauchten nicht eine Zeile Code im Kern der Anwendung zu ändern – und damit ist selbst das Application Framework in der hexagonalen Architektur nur ein austauschbares technisches Detail.

Genau das werden wir uns auch im nächsten und letzten Teil dieser Tutorial-Serie zunutze machen: Dort werden wir nämlich Quarkus durch das Spring Framework ersetzen.

Wenn dir der Artikel gefallen hat, würde ich mich ü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.