hexagonal architecture spring boothexagonal architecture spring boot
HappyCoders Glasses

Hexagonale Architektur mit Spring Boot
[Tutorial]

Sven Woltmann
Sven Woltmann
Aktualisiert: 27. November 2024

In diesem letzten Teil der Tutorial-Serie zeige ich dir, wie wir die Anwendung, die wir im Laufe dieses Tutorials – zunächst ohne Framework – nach der hexagonalen Architektur entwickelt und dann auf Quarkus umgestellt haben, auf das Spring-Boot-Framework migrieren.

Rückblick

Wir haben diese Artikelserie mit den Grundlagen der hexagonalen Architektur begonnen.

Danach haben wir eine Java-Anwendung mit der hexagonalen Architektur entwickelt – zunächst ohne ein Application Framework. Anschließend haben wir die Anwendung um einen JPA-Adapter erweitert, um die Daten in einer Datenbank zu persistieren anstatt sie lediglich im Arbeitsspeicher zu halten. Im vorangegangenen Teil der Serie haben wir dann die hexagonale Anwendung in Quarkus als Application Framework eingebettet.

Alle Erweiterungen der Anwendung – sowohl die Anbindung eines neuen Adpaters als auch die Migration zu Quarkus – konnten wir durchführen, ohne auch nur eine Zeile Code im Anwendungskern ändern zu müssen.

Die folgende Grafik zeigt die Architektur unserer Anwendung. Der zuvor erwähnte Anwendungskern wird durch das mit „Application” bezeichnete Hexagon dargestellt:

Hexagonale Architektur der Beispiel-Anwendung

Den aktuellen Stand der Demo-Anwendung findest du im „with-quarkus”-Branch dieses GitHub-Repositories.

Worum geht es in diesem Teil?

In diesem fünften und letzten Teil der Serie werden wir unsere Anwendung von Quarkus zu Spring Boot migrieren – und auch das wieder, ohne Code im Anwendungskern ändern zu müssen.

Wir werden dabei wieder schrittweise vorgehen wie bei der Migration zu Quarkus. Doch da Spring sowohl bei den primären als auch bei den sekundären Controllern andere Technologien einsetzt (Spring Web MVC anstelle von Jakarta RESTful Web Services im Frontend und Spring Data JPA anstelle von Panache im Backend), wird die Anwendung zwischen den Schritten nicht kompilierbar und lauffähig sein, sondern erst nach Abschluss der Migration.

Wir werden die Anwendung in folgenden Schritten zu Spring Boot migrieren:

  1. Zuerst werden wir die Quarkus-Dependencies in den pom.xml-Dateien durch Spring-Boot-Dependencies ersetzen.
  2. Dann werden wir die primären REST-Adapter anpassen, indem wir die JAX-RS-Annotationen durch Spring-Web-MVC-Annotationen ersetzen.
  3. Danach werden wir die sekundären Persistenzadapter migrieren, indem wir die Panache-Repositories durch Spring-Data-JPA-Repositories ersetzen. Dabei werden wir auch die Annotationen austauschen, die kontrollieren, welche Adapter (In-Memory oder JPA) erzeugt werden.
  4. Dann werden wir die Quarkus-Anwendungskonfiguration im adapter-Modul durch eine Spring-Boot-Konfiguration ersetzen, woraufhin das adapter-Modul wieder kompilierbar sein wird.
  5. Zuletzt werden wir eine Spring-Boot-Starter-Klasse hinzufügen und die End-to-End-Tests im bootstrap-Modul anpassen.

Den Stand des Codes nach Abschluss der Migration findest du im „with-spring-boot”-Branch des GitHub-Repositories. Alle Änderungen gegenüber der Quarkus-Version findest du in diesem Pull Request.

Beginnen wir mit Schritt 1...

Schritt 1: Dependencies austauschen

Ausgehend vom Projektstand im „with-quarkus”-Branch werden wir in den pom.xml-Dateien des Projekts die Quarkus-Bill-of-Materials durch den Spring-Boot-Starter-Parent und alle Quarkus-Dependences durch Spring-Boot-Dependencies ersetzen.

Anpassung der Eltern-pom.xml

Um die Versionen aller Spring-Boot-Bibliotheken verfügbar zu haben, fügen wir als erstes in die pom.xml des Projektverzeichnisses den Spring-Boot-Starter-Parent ein, z. B. zwischen <modelVersion> und <groupId>:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.1.5</version>
</parent>
Code-Sprache: HTML, XML (xml)

Alle Quarkus-spezifischen Einträge können wir dann entfernen:

  • den <quarkus.platform.version>-Eintrag aus dem <properties>-Block,
  • die Version der assertj-core-Bibliothek, da diese bereits im Spring-Boot-Parent definiert ist,
  • die Quarkus-BOM-Dependency aus dem Dependency-Management
  • und das Quarkus-Maven-Plugin.

Hier findest du die Änderungen als GitHub-Commit und hier die geänderte pom.xml.

Anpassung der adapter/pom.xml

In der adapter/pom.xml ersetzen wir alle Quarkus-Extensions durch:

  • Spring-Boot-Libraries,
  • den MySQL-Treiber
  • und Testcontainers.

(Die letzten beiden Libraries wurden zuvor über Quarkus-Extensions als transitive Dependencies ins Projekt importiert.)

Außerdem müssen wir einen H2-Datenbanktreiber hinzufügen. Der Grund dafür ist, dass die Spring-Boot-Anwendung durch das Einbinden von spring-boot-starter-data-jpa auch im In-Memory-Modus eine gültige Datenbankkonfiguration erwartet. Quarkus konnten wir im In-Memory-Modus mit einer Dummy-MySQL-URL abspeisen, was lediglich zu einer Warnung geführt hat. Spring hingegen bricht den Startvorgang bei einer ungültigen Datenbank-URL ab. Die Anbindung einer – letztendlich nicht genutzten – H2-In-Memory-Datenbank ist ein Workaround, der nicht schön ist, aber für unsere Demo-Anwendung ausreichend.

Nach den Änderungen sieht der <dependencies>-Block der adapter/pom.xml wie folgt aus:

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

    <!-- External -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>

    <!-- Test scope -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>io.rest-assured</groupId>
        <artifactId>rest-assured</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)

Hier findest du die Änderungen als GitHub-Commit und hier die angepasste adapter/pom.xml.

Anpassung der bootstrap/pom.xml

In der bootstrap/pom.xml müssen wir die Test-Dependencies auf die Quarkus-Extensions von JUnit und Mockito durch spring-boot-starter-test ersetzen und Testcontainers hinzufügen – dieses war zuvor transitiv über die Quarkus-Extensions importiert.

Hier siehst du die Test-Dependencies nach der Migration:

<!-- Test scope -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</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>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>mysql</artifactId>
    <scope>test</scope>
</dependency>
Code-Sprache: HTML, XML (xml)

Außerdem müssen wir das quarkus-maven-plugin durch das spring-boot-maven-plugin ersetzen. Der <build>-Block sieht damit wie folgt aus:

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>
Code-Sprache: HTML, XML (xml)

Hier findest du die Änderungen als GitHub-Commit und hier die bootstrap/pom.xml nach den Änderungen.

Beachte, dass die adapter- und bootstrap-Module der Anwendung zu diesem Zeitpunkt nicht mehr kompiliert werden können, die model- und application-Module hingegen schon, da diese keinerlei technische Details enthalten.

Entsprechend sollte an dieser Stelle ein Aufruf von mvn spotless:apply clean verify zu folgender Ausgabe führen:

[INFO] parent ............................................. SUCCESS [  0.377 s]
[INFO] model .............................................. SUCCESS [  2.908 s]
[INFO] application ........................................ SUCCESS [  2.392 s]
[INFO] adapter ............................................ FAILURE [  1.221 s]
[INFO] bootstrap .......................................... SKIPPED
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------

Wir werden in den folgenden Abschnitten nach und nach den Code im adapter-Modul und dann im bootstrap-Modul anpassen, um das Projekt wieder kompilierbar zu machen.

Schritt 2: REST-Adapter von JAX-RS nach Spring Web MVC migrieren

Im Adapter-Modul beginnen wir mit den Anpassungen der REST-Adapter. Die Migration von Jakarta RESTful Web Services nach Spring Web MVC bedeutet in erster Linie, dass wir

  • Annotationen und
  • Enum-Konstanten von HTTP-Statuscodes

durch ihre jeweiligen Spring-Pendants ersetzen müssen.

Außerdem müssen wir den Jakarta-EE-Exception-Handling-Mechanismus nachbauen, da es etwas Vergleichbares meines Wissens nach in Spring nicht gibt. Korrigiere mich bitte über die Kommentarfunktion, wenn ich falsch liege!

Zuletzt müssen wir in den REST-Assured-Integrationstests den HTTP-Port wieder setzen. Das hatten wir im Zuge der Migration zu Quarkus ausgebaut, da Quarkus das automatisch macht.

JAX-RS-Annotationen durch Spring-Annotationen ersetzen

Beginnen wir mit den Annotationen...

GetCartController

Fangen wir mit dem einfachsten Controller an, dem GetCartController. Hier siehst du den JAX-RS-Controller einschließlich der relevanten Imports:

import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;

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

  . . .

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

Gehen wir die Annotationen einmal von oben nach unten durch. Die folgende Tabelle listet die JAX-RS-Annotationen auf, erklärt deren Bedeutung und zeigt das jeweilige Spring-Pendant:

JAX-RS-Annotation(en)BeschreibungSpring-Pendant
@Path("/carts")Markiert die Klasse als REST-Controller und definiert den Basis-Pfad@RestController
@RequestMapping("/carts")
@Produces(APPLICATION_JSON)Definiert das Antwortformat; war in der Anwendung ohne Application Framework notwendig. Hätte in der Quarkus-Version entfernt werden können, da Quarkus JSON als Default setzt.Entfällt, da auch Spring JSON als Default setzt.
@GET
@Path("/{customerId}")
Markiert die Methode als GET-Controller und definiert den Pfad.@GetMapping("/{customerId}")
@PathParam("customerId")Extrahiert das Pfadelement {customerId} in den annotierten Methodenparameter.@PathVariable("customerId")

Wenn wir die in der Tabelle aufgelisteten Annotationen ersetzen, wird aus dem oben gezeigten Jakarta-RESTful-Webservices-Controller der folgende Spring-Web-MVC-Controller (GetCartController im GitHub-Repository, hier nur die Änderungen):

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;


@RestController
@RequestMapping("/carts")
public class GetCartController {

  . . .

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

Wie du siehst, ist der Unterschied zwischen Spring und Jakarta EE minimal.

EmptyCartController

Beim EmptyCartController können wir genauso vorgehen, müssen allerdings eine Besonderheit bei den HTTP-Statuscodes beachten – mehr dazu weiter unten.

Hier zunächst der Jakarta-RESTful-Webservices-Code (die Imports drucke ich dieses Mal nicht mit ab):

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

  . . .

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

Wir treffen hier auf eine weitere JAX-RS-Annotation, @DELETE:

JAX-RS-Annotation(en)BeschreibungSpring-Pendant
@DELETE
@Path("/{customerId}")
Markiert die Methode als DELETE-Controller und definiert den Pfad.@DeleteMapping("/{customerId}")

Die JAX-RS-@DELETE-Annotation führt dazu, dass der Controller einen HTTP-Statuscode 204 „No Content” zurückliefert. Springs @DeleteMapping-Annotation hingegen gibt den HTTP-Statuscode 200 „OK” zurück. Das würde die Integrationstests für diese Methode fehlschlagen lassen.

Um das ursprüngliche Verhalten beizubehalten und auch Spring einen Statuscode 204 zurückgeben zu lassen, muss die Methode eine entsprechende ResponseEntity zurückliefern. Diese können wir über ResponseEntity.noContent().build() erzeugen.

Hier der nach Spring migrierte Controller (EmptyCartController im GitHub-Repository, hier nur die Änderungen):

@RestController
@RequestMapping("/carts")
public class EmptyCartController {

  . . .

  @DeleteMapping("/{customerId}")
  public ResponseEntity<Void> deleteCart(
      @PathVariable("customerId") String customerIdString) {
    CustomerId customerId = parseCustomerId(customerIdString);
    emptyCartUseCase.emptyCart(customerId);
    return ResponseEntity.noContent().build();
  }
}Code-Sprache: Java (java)

Enum-Konstanten der HTTP-Statuscodes austauschen

Bei den zwei anderen REST-Controllern, AddToCartController und FindProductsController müssen wir außerdem die Enum-Konstanten für die HTTP-Statuscodes austauschen.

AddToCartController

Schauen wir zunächst auf den AddToCartController – hier der alte Code:

. . .
import jakarta.ws.rs.core.Response;

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

  . . .

  @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 sehen zunächst zwei weitere Annotationen im Einsatz:

JAX-RS-Annotation(en)BeschreibungSpring-Pendant
@POST
@Path("/.../...")
Markiert die Methode als POST-Controller und definiert den Pfad.@PostMapping("/.../...")
@QueryParam("...")Bindet einen Query- bzw. Request-Parameter (zwei Unterschiedliche Bezeichnungen für die mit einem Fragezeichen an die URL angehängten Parameter) mit dem angegebenen Namen an die annotierte Variable.@RequestParam("...")

Die JAX-RS-Enum-Klasse jakarta.ws.rs.core.Response.Status definiert Konstanten für alle möglichen HTTP-Statuscodes. In Spring sind die Statuscodes in der Klasse org.springframework.http.HttpStatus definiert.

Das folgende Listing zeigt den vollständig nach Spring migrierten Controller (AddToCartController im GitHub-Repository, hier die Änderungen):

import org.springframework.http.HttpStatus;
. . .

@RestController
@RequestMapping("/carts")
public class AddToCartController {

  . . .

  @PostMapping("/{customerId}/line-items")
  public CartWebModel addLineItem(
      @PathVariable("customerId") String customerIdString,
      @RequestParam("productId") String productIdString,
      @RequestParam("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(
          HttpStatus.BAD_REQUEST, "The requested product does not exist");
    } catch (NotEnoughItemsInStockException e) {
      throw clientErrorException(
          HttpStatus.BAD_REQUEST, 
          "Only %d items in stock".formatted(e.itemsInStock()));
    }
  }
}
Code-Sprache: Java (java)

Beachte, dass die Aufrufe der clientErrorException(...)-Methode aktuell noch nicht kompilieren, da wir diese noch nicht auf den Spring-HttpStatus migriert haben.

FindProductsController

Zuletzt gibt es auch in FindProductsController eine Besonderheit zu beachten, nämlich beim Query- bzw. Request-Parameter. Hier zunächst der alte Code:

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

  . . .

  @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)

Und hier der nach Spring migrierte Code (Klasse FindProductsController, hier die Änderungen):

@RestController
@RequestMapping("/products")
public class FindProductsController {

  . . .

  @GetMapping
  public List<ProductInListWebModel> findProducts(
      @RequestParam(value = "query", required = false) String query) {
    if (query == null) {
      throw clientErrorException(HttpStatus.BAD_REQUEST, "Missing 'query'");
    }

    List<Product> products;

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

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

Die ersetzten Annotationen haben wir im Grunde bereits bei den drei vorherigen Controllern besprochen. Allerdings mussten wir hier bei der @RequestParam-Annotation den Parameter required = false hinzufügen. Andernfalls würde ein fehlender query-Parameter zu einer Fehlermeldung des Frameworks führen anstatt zu der Fehlermeldung, die wir zu Beginn der Methode selbst implementiert haben.

Auch hier kompiliert der Aufruf der clientErrorException(...)-Methode aktuell noch nicht.

CustomerIdParser und ProductIdParser

In den Parser-Klassen geben wir je nach Art des Fehlers bestimmte HTTP-Statuscodes zurück. Wir müssen also auch in diesen Klassen die entsprechenden Response.Status-Konstanten durch HttpStatus-Konstanten ersetzen.

Hier die geänderten Klassen:

public final class CustomerIdParser {

  private CustomerIdParser() {}

  public static CustomerId parseCustomerId(String string) {
    try {
      return new CustomerId(Integer.parseInt(string));
    } catch (IllegalArgumentException e) {
      throw clientErrorException(HttpStatus.BAD_REQUEST, "Invalid 'customerId'");
    }
  }
}Code-Sprache: Java (java)

Und:

public final class ProductIdParser {

  private ProductIdParser() {}

  public static ProductId parseProductId(String string) {
    if (string == null) {
      throw clientErrorException(HttpStatus.BAD_REQUEST, "Missing 'productId'");
    }

    try {
      return new ProductId(string);
    } catch (IllegalArgumentException e) {
      throw clientErrorException(HttpStatus.BAD_REQUEST, "Invalid 'productId'");
    }
  }
}Code-Sprache: Java (java)

Die Änderungen findest du in hier und hier und die Ergebnisse in den angepassten Klassen CustomerIdParser und ProductIdParser.

Exception-Handler implementieren

In der Jakarta-RESTful-Webservices-Implementierung konnten wir bei Eingabefehlern eine jakarta.ws.rs.ClientErrorException werfen. Dieser Exception konnten wir ein Response-Objekt übergeben, welches den HTTP-Statuscode und einen JSON-Response-Body mit einer Fehlermeldung enthält.

Soweit ich weiß, bietet das Spring-Framework keine entsprechende Exception. Korrigiere mich bitte über die Kommentarfunktion, wenn ich falsch liege!

Daher müssen wir uns selbst eine solche Exception mitsamt zugehörigem Exception-Handler bauen. Die Exception (ClientErrorException im GitHub-Repository) bekommt einen Konstruktor, dem wir eine ResponseEntity übergeben, die im Feld response gespeichert wird:

public class ClientErrorException extends RuntimeException {

  @Getter private final ResponseEntity<ErrorEntity> response;

  public ClientErrorException(ResponseEntity<ErrorEntity> response) {
    super(getMessage(response));
    this.response = response;
  }

  private static String getMessage(ResponseEntity<ErrorEntity> response) {
    ErrorEntity body = response.getBody();
    return body != null ? body.errorMessage() : null;
  }
}Code-Sprache: Java (java)

Der Exception-Handler (Klasse ClientErrorHandler) ist ebenfalls schnell implementiert. Er bekommt eine @RestControllerAdvice-Annotation sowie eine @ExceptionHandler-annotierte Methode, die die in der ClientErrorException hinterlegte ResponseEntity zurückliefert:

@RestControllerAdvice
public class ClientErrorHandler {

  @ExceptionHandler(ClientErrorException.class)
  public ResponseEntity<ErrorEntity> handleProductNotFoundException(
      ClientErrorException ex) {
    return ex.getResponse();
  }
}Code-Sprache: Java (java)

Dann sind nur noch kleine Anpassungen an der ControllerCommons-Klasse nötig, um statt der jakarta.ws.rs.ClientErrorException unsere eigene ClientErrorException zu werfen. Hier die vollständige ControllerCommons-Klasse:

public final class ControllerCommons {

  private ControllerCommons() {}

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

  public static ResponseEntity<ErrorEntity> errorResponse(
      HttpStatus status, String message) {
    ErrorEntity errorEntity = new ErrorEntity(status.value(), message);
    return ResponseEntity.status(status.value()).body(errorEntity);
  }
}Code-Sprache: Java (java)

Das Spring-Framework sorgt nun dafür, dass eine ClientErrorException vom ClientErrorHandler abgefangen wird und der REST-Controller den in der Exception gespeicherten Fehlercode und die Fehlernachricht zurückliefert.

Nach der Anpassung der ControllerCommons-Klasse sollten die Compiler-Fehler in den Controllern und den Parsern verschwunden sein.

Integrationstests von Quarkus nach Spring migrieren

In den Integrationstests müssen wir folgende Änderungen vornehmen:

  • Annotationen austauschen,
  • in den REST-Assured-Tests den Server-Port setzen,
  • Jakartas Response.Status durch Springs HttpStatus ersetzen.

Ich zeige die Änderungen beispielhaft am ProductsControllerTest. Dies ist der alte Stand:

import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST;

. . .

@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;

  . . .

  @Test
  void givenANullQuery_findProducts_returnsError() {
    Response response = given().get("/products").then().extract().response();

    assertThatResponseIsError(response, BAD_REQUEST, "Missing 'query'");
  }

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

Annotationen austauschen

Wir tauschen folgende Annotationen aus:

JAX-RS-Annotation(en)BeschreibungSpring-Pendant
@QuarkusTestStartet die Anwendung vor dem Integrationstest.@ActiveProfiles("test")
@SpringBootTest(

    webEnvironment = RANDOM_PORT)
@InjectMockMockt eine Bean und injiziert den Mock überall dort, wo die Bean verwendet wird.@MockBean

Server-Port setzen

Dann informieren wir REST-Assured über den Port der Anwendung (mit Quarkus geschah das automatisch). Durch folgende Zeile injiziert Spring den Server-Port in die Variable port:

@LocalServerPort private Integer port;Code-Sprache: Java (java)

Dann müssen wir nach jedem Aufruf von given() den Aufruf port(port) einfügen. Also z. B.:

given()
    .get("/products")
    .then().extract().response();Code-Sprache: Java (java)

wird zu:

given()
    .port(port)  // ⟵ set port
    .get("/products")
    .then().extract().response();Code-Sprache: Java (java)

Response.Status durch HttpStatus ersetzen

Zuletzt ersetzen wir den statischen Import jakarta.ws.rs.core.Response.Status.BAD_REQUEST durch org.springframework.http.HttpStatus.BAD_REQUEST. Da der Name der Enum-Konstante, BAD_REQUEST, in beiden Frameworks gleich ist, müssen wir hier nichts weiter tun.

Die geänderte Testklasse sieht dann wie folgt aus:

import static org.springframework.http.HttpStatus.BAD_REQUEST;

. . .

@ActiveProfiles("test")
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class ProductsControllerTest {

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

  @LocalServerPort private Integer port;

  @MockBean FindProductsUseCase findProductsUseCase;

  . . .

  @Test
  void givenANullQuery_findProducts_returnsError() {
    Response response = given().port(port).get("/products").then().extract().response();

    assertThatResponseIsError(response, BAD_REQUEST, "Missing 'query'");
  }

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

Der Aufruf von assertThatResponseIsError(...) kompiliert aktuell noch nicht, da die Methode noch nicht auf den Spring-HttpStatus umgestellt ist. Das machen wir als nächstes.

Wir ersetzen in der Klasse HttpTestCommons den Typ der Variablen expectedStatus, Response.Status durch HttpStatus und expectedStatus.getStatusCode() durch expectedStatus.value(). Die geänderte Klasse sieht dann wie folgt aus:

public final class HttpTestCommons {

  private HttpTestCommons() {}

  public static void assertThatResponseIsError(
      Response response,
      HttpStatus expectedStatus,
      String expectedErrorMessage) {
    assertThat(response.getStatusCode()).isEqualTo(expectedStatus.value());

    JsonPath json = response.jsonPath();

    assertThat(json.getInt("httpStatus")).isEqualTo(expectedStatus.value());
    assertThat(json.getString("errorMessage")).isEqualTo(expectedErrorMessage);
  }
}Code-Sprache: Java (java)

Den CartsControllerTest passen wir analog dazu an. Und in CartsControllerAssertions und ProductsControllerAssertions ersetzen wir den statischen Import jakarta.ws.rs.core.Response.Status.OK durch org.springframework.http.HttpStatus.OK und den Ausdruck OK.getStatusCode() durch OK.value().

Du findest alle Änderungen an den Tests ab dieser Position im Git-Commit und die geänderten Klassen in diesem Verzeichnis im GitHub-Repository.

Damit haben wir die primären REST-Adapter von Quarkus nach Spring migriert. Im nächsten Schritt wenden wir uns den sekundären Persistenzadaptern zu.

Schritt 3: JPA-Adapter von Panache nach Spring Data JPA migrieren

Um die sekundären Persistenzadapter nach Spring zu migrieren, gehen wir wie folgt vor:

  • Wir tauschen die Annotationen aus, die steuern, ob die In-Memory-Adapter oder die JPA-Adapter verwendet werden.
  • Wir ersetzen die Bean-erzeugenden Annotationen.
  • Wir ersetzen die Panache-Repositories durch Spring-Data-JPA-Repositories.
  • Da Springs findById(...)-Methoden Optionals zurückliefern, müssen wir ein paar kleine Anpassungen an den Mappern durchführen.
  • Abschließend passen wir die Integrationstests an.

Annotationen austauschen

In der Quarkus-Version haben wir die folgenden Annotationen verwendet, um zu steuern, welche Persistenzadapter verwendet werden:

An den In-Memory-Adaptern InMemoryCartRepository und InMemoryProductRepository:

@LookupIfProperty(name = "persistence", stringValue = "inmemory", 
                  lookupIfMissing = true)Code-Sprache: Java (java)

An den JPA-Adaptern JpaCartRepository und JpaProductRepository:

@LookupIfProperty(name = "persistence", stringValue = "mysql")
Code-Sprache: Java (java)

Für die Spring-Version ersetzen wir die Annotationen wie folgt:

In-Memory-Adapter:

@ConditionalOnProperty(name = "persistence", havingValue = "inmemory", 
                       matchIfMissing = true)Code-Sprache: Java (java)

JPA-Adapter:

@ConditionalOnProperty(name = "persistence", havingValue = "mysql")Code-Sprache: Java (java)

Außerdem ersetzen wir in denselben vier Klassen die Bean-erzeugende Annotation @ApplicationScoped durch @Repository.

Hier findest du die nach Spring migrierten Klassen InMemoryCartRepository und InMemoryProductRepository. An den JPA-Adaptern werden wir im nächsten Abschnitt noch weitere Änderungen vornehmen.

Panache-Repositories durch Spring-Data-JPA-Repositories ersetzen

In der Quarkus-Variante haben wir Panache verwendet, um JPA-Entities komfortabel aus der Datenbank zu laden. Das Spring-Pendant dazu lautet „Spring Data JPA”.

Das folgende Listing zeigt die beiden Panache-Repositories:

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

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

Und hier siehst du die entsprechenden Spring-Data-JPA-Repositories (beachte auch die geänderten Klassennamen):

@Repository
public interface JpaCartSpringDataRepository
    extends JpaRepository<CartJpaEntity, Integer> {}

@Repository
public interface JpaProductSpringDataRepository
    extends JpaRepository<ProductJpaEntity, String> {

  @Query("SELECT p FROM ProductJpaEntity p "
          + "WHERE p.name like ?1 or p.description like ?1")
  List<ProductJpaEntity> findByNameOrDescriptionLike(String pattern);
}Code-Sprache: Java (java)

Es gibt zwei bedeutende Unterschiede:

  • Die Panache-Repositories sind Klassen, die Spring-Data-JPA-Repositories sind Interfaces.
  • Im Panache-Repository haben wir die Suche über die PanacheRepositoryBase.find(...)-Methode realisiert, indem wir dieser eine HQL-WHERE-Query übergeben haben. Im Spring-Data-JPA-Repository können wir dafür eine separate Interface-Methode deklarieren und diese mit einer JPQL-Query-Annotation versehen. Hierauf komme ich weiter unten noch einmal zurück, wenn ich die Änderungen an den Adapter-Klassen zeige.

Nun müssen wir noch unsere Adapter JpaCartRepository und JpaProductRepository anpassen und statt der Panache-Repositories die Spring-Data-JPA-Repositories verwenden.

Beginnen wir mit dem einfacheren Adapter, JpaCartRepository. Bisher sieht er so aus:

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

  private final JpaCartPanacheRepository panacheRepository;

  public JpaCartRepository(JpaCartPanacheRepository panacheRepository) {
    this.panacheRepository = panacheRepository;
  }

  @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 ersetzen die zwei Klassen-Annotationen @LookupIfProperty und @ApplicationScoped wie im Abschnitt „Annotationen austauschen” oben beschrieben.

Wir ersetzen das Feld JpaCartPanacheRepository panacheRepository durch JpaCartSpringDataRepository springDataRepository und passen den Konstruktor entsprechend an.

Den Aufruf panacheRepository.getEntityManager().merge(...) ersetzen wir durch springDataRepository.save(...).

Während die panacheRepository.findById(...)-Methode ein CartJpaEntity zurücklieferte, gibt springDataRepository.findById(...) ein Optional<CartJpaEntity> zurück. Entsprechend passen wir den Aufruf des Mappers an.

Hier siehst du die Spring-Version der Adapter-Klasse:

@ConditionalOnProperty(name = "persistence", havingValue = "mysql")
@Repository
public class JpaCartRepository implements CartRepository {

  private final JpaCartSpringDataRepository springDataRepository;

  public JpaCartRepository(JpaCartSpringDataRepository springDataRepository) {
    this.springDataRepository = springDataRepository;
  }

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

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

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

Die Methodenreferenz CartMapper::toModelEntity führt aktuell noch zu einem Compilerfehler, da die entsprechende Methode noch nicht existiert. Im CartMapper haben wir aktuell eine Methode toModelEntityOptional(...), die aus einem CartJpaEntity ein Optional<Cart> macht:

static Optional<Cart> toModelEntityOptional(CartJpaEntity cartJpaEntity) {
  if (cartJpaEntity == null) {
    return Optional.empty();
  }

  CustomerId customerId = new CustomerId(cartJpaEntity.getCustomerId());
  Cart cart = new Cart(customerId);

  for (CartLineItemJpaEntity lineItemJpaEntity : cartJpaEntity.getLineItems()) {
    cart.putProductIgnoringNotEnoughItemsInStock(
        ProductMapper.toModelEntity(lineItemJpaEntity.getProduct()),
        lineItemJpaEntity.getQuantity());
  }

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

Das Spring-Data-JPA-Repository liefert uns bereits ein Optional<CartJpaEntity>, welches wir mit der map(...)-Methode auf ein Optional<Cart> mappen können. Dafür ersetzen wir die toModelEntityOptional(...)-Methode durch folgende toModelEntity(...)-Methode:

static Cart toModelEntity(CartJpaEntity cartJpaEntity) {
  CustomerId customerId = new CustomerId(cartJpaEntity.getCustomerId());
  Cart cart = new Cart(customerId);

  for (CartLineItemJpaEntity lineItemJpaEntity : cartJpaEntity.getLineItems()) {
    cart.putProductIgnoringNotEnoughItemsInStock(
        ProductMapper.toModelEntity(lineItemJpaEntity.getProduct()),
        lineItemJpaEntity.getQuantity());
  }

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

JpaCartRepository sollte nun keine Compiler-Fehler mehr anzeigen.

Schauen wir als nächstes auf die Klasse JpaProductRepository. Hier die Quarkus-Version:

@LookupIfProperty(name = "persistence", stringValue = "mysql")
@ApplicationScoped
public class JpaProductRepository implements ProductRepository {

  private final JpaProductPanacheRepository panacheRepository;

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

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

  @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)

Wir machen zunächst einmal die gleichen Anpassungen wie beim JpaCartAdapter.

In der findByNameOrDescription(...)-Methode ersetzen wir außerdem den Aufruf der find(...)-Methode:

panacheRepository
    .find("name like ?1 or description like ?1", "%" + queryString + "%")
    .list();Code-Sprache: Java (java)

durch einen Aufruf der im Spring-Data-JPA-Interface deklarierten findByNameOrDescriptionLike(...)-Methode.

springDataRepository.findByNameOrDescriptionLike("%" + queryString + "%");Code-Sprache: Java (java)

Bei der Panache-Variante übergeben wir an dieser Stelle eine HQL-(Hibernate Query Language)-WHERE-Query; bei der Spring-Variante haben wir hingegen die findByNameOrDescriptionLike(...)-Methode im JpaProductSpringDataRepository-Interface mit einer JPQL-Query annotiert:

@Query("SELECT p FROM ProductJpaEntity p "
        + "WHERE p.name like ?1 or p.description like ?1")
List<ProductJpaEntity> findByNameOrDescriptionLike(String pattern);Code-Sprache: Java (java)

Hier siehst du die nach Spring migrierte Klasse:

@ConditionalOnProperty(name = "persistence", havingValue = "mysql")
@Repository
public class JpaProductRepository implements ProductRepository {

  private final JpaProductSpringDataRepository springDataRepository;

  public JpaProductRepository(JpaProductSpringDataRepository springDataRepository) {
    this.springDataRepository = springDataRepository;
  }

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

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

  @Override
  @Transactional
  public Optional<Product> findById(ProductId productId) {
    Optional<ProductJpaEntity> jpaEntity = 
        springDataRepository.findById(productId.value());
    return jpaEntity.map(ProductMapper::toModelEntity);
  }

  @Override
  @Transactional
  public List<Product> findByNameOrDescription(String queryString) {
    List<ProductJpaEntity> entities =
        springDataRepository.findByNameOrDescriptionLike("%" + queryString + "%");

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

Du findest alle Änderungen (einschließlich derer an den Mappern, die ich hier nicht mit abgedruckt habe) in diesem Git-Commit und die Ergebnisse in den Klassen JpaCartRepository, JpaCartSpringDataRepository, CartMapper, JpaProductRepository, JpaProductSpringDataRepository und ProductMapper.

Anpassung der Integrationstests

In den Integrationstests für die Perstistenzadapter müssen wir:

  • die Dependency Injection anpassen,
  • die Konfiguration der Testprofile von Quarkus an Spring anpassen.

Dependency Injection anpassen

Ich zeige dir die notwendigen Anpassungen an der Dependency Injection an der Klasse AbstractProductRepositoryTest – hier ist der aktuelle Stand:

public abstract class AbstractProductRepositoryTest {

  @Inject Instance<ProductRepository> productRepositoryInstance;

  private ProductRepository productRepository;

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

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

Bei Quarkus mussten wir das Produkt-Repository, was entweder ein In-Memory- oder ein JPA-Repository sein kann, als Instance<ProductRepository> injizieren und dann in der @BeforeEach-Methode über Instance.get() auslesen.

In Spring geht das einfacher. Wir können die konfigurierbare Bean, wie jede andere Bean, ganz ohne Umwege injizieren – und eine @BeforeEach-Methode brauchen wir nicht:

public abstract class AbstractProductRepositoryTest {

  @Autowired ProductRepository productRepository;

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

Die Klasse AbstractCartRepositoryTest wird analog angepasst.

Quarkus Testprofile nach Spring migrieren

In der Quarkus-Variante haben wir eine Testprofil-Klasse TestProfileWithMySQL definiert, die die Konfiguration persistence=mysql setzt:

public class TestProfileWithMySQL implements QuarkusTestProfile {

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

Diese Klasse können wir löschen. In Spring benötigen wir solch eine Klasse nicht. Testprofile in Spring benötigen einen Namen und eine Properties-Datei. Wir legen im Verzeichnis test/resources des application-Moduls die Datei application-test-with-mysql.properties an mit folgendem Inhalt:

spring.datasource.url=jdbc:tc:mysql:8.0:///shop
spring.jpa.hibernate.ddl-auto=update

persistence=mysqlCode-Sprache: Klartext (plaintext)

In der letzten Zeile definieren wir den aus Quarkus bekannten Konfigurationseintrag persistence=mysql. Die zwei Zeilen zu Beginn legen fest, dass wir als Datenbank Testcontainers („tc”) verwenden und dass Hibernate die Tabellen automatisch anlegen soll. Beides hat Quarkus automatisch gemacht.

Ich hatte zuvor schon erwähnt, dass Spring wegen des vorhandenen spring-data-jpa-Moduls auch im In-Memory-Modus eine Datenbank benötigt, da es sonst nicht startet. Dafür legen wir noch eine Datei application-test.properties an mit folgendem Inhalt:

spring.datasource.url=jdbc:h2:mem:testdb
Code-Sprache: Klartext (plaintext)

Im In-Memory-Modus soll also eine H2-Datenbank verwendet werden. Dies ist der einfachste Workaround, der mir für unsere Demo-Anwendung eingefallen ist.

Um nun für die jeweiligen Tests die entsprechenden Profile zu aktivieren, müssen wir noch ein paar Annotationen in den folgenden Testklassen ändern. So sehen die konkreten Persistenztests in der Quarkus-Variante aus:

@QuarkusTest
class InMemoryCartRepositoryTest extends AbstractCartRepositoryTest {}

@QuarkusTest
class InMemoryProductRepositoryTest extends AbstractProductRepositoryTest {}

@QuarkusTest
@TestProfile(TestProfileWithMySQL.class)
class JpaCartRepositoryTest extends AbstractCartRepositoryTest {}

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

Wir ersetzen die Annotationen wie folgt:

  • @QuarkusTest ersetzen wir durch @SpringBootTest.
  • @TestProfile(TestProfileWithMySQL.class) ersetzen wir durch @ActiveProfiles("test-with-mysql").
  • Und den In-Memory-Tests müssen wir noch die Annotation @ActiveProfiles("test") hinzufügen, damit sie im test-with-mysql-Profil nicht erzeugt werden. Bei Quarkus wurden sie automatisch nicht erzeugt, wenn ein Profil aktiviert ist.

Nach den Änderungen sehen die Testklassen wie folgt aus:

@SpringBootTest
@ActiveProfiles("test")
class InMemoryCartRepositoryTest extends AbstractCartRepositoryTest {}

@SpringBootTest
@ActiveProfiles("test")
class InMemoryProductRepositoryTest extends AbstractProductRepositoryTest {}

@SpringBootTest
@ActiveProfiles("test-with-mysql")
class JpaCartRepositoryTest extends AbstractCartRepositoryTest {}

@SpringBootTest
@ActiveProfiles("test-with-mysql")
class JpaProductRepositoryTest extends AbstractProductRepositoryTest {}
Code-Sprache: Java (java)

Du findest alle Änderungen an den Tests ab dieser Position im Git-Commit und die geänderten Testklassen in diesem Verzeichnis im GitHub-Repository.

Schritt 4: Anwendungskonfiguration von Quarkus nach Spring Boot migrieren

In diesem Schritt ersetzen wir die Quarkus-Anwendungskonfiguration im Adapter-Modul durch eine Spring-Boot-Konfiguration. Am Ende dieses vergleichweise kurzen Schritts werden wir das Adapter-Modul kompilieren und die Tests ausführen können.

Die Quarkus-Anwendungskonfiguration liegt in der Klasse QuarkusAppConfig, die aktuell wie folgt aussieht:

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)

Zur Erinnerung:

Wir benötigen diese Klasse, um die im application-Modul liegenden Service-Klassen zu instanziieren. Denn das application-Modul weiß nichts von Quarkus (es ist ein technisches Detail, das dort nicht hingegört), und somit können und wollen wir dort auch keine @ApplicationScoped-Annotationen einsetzen.

Das gleiche gilt für Spring. Das application-Modul weiß nichts von Spring, und entsprechend können wir dort auch keine @Service-Annotation verwenden.

Was müssen wir nun konkret ändern?

  • Wir bennenen die Klasse QuarkusAppConfig um in SpringAppConfig.
  • Wir annotieren die Klasse mit @SpringBootApplication.
  • Wir ersetzen die @Inject-Annotationen durch @Autowired.
  • Wir entfernen die Instance<...>-Wrapper um CartRepository und ProductRepository (so wie wir es auch in den Tests der Persistenzadapter gemacht haben).
  • Wir ersetzen das Annotation-Paar @Produces @ApplicationScoped durch @Bean.

Danach sieht die Klasse wie folgt aus:

@SpringBootApplication
public class SpringAppConfig {

  @Autowired CartRepository cartRepository;

  @Autowired ProductRepository productRepository;

  @Bean
  GetCartUseCase getCartUseCase() {
    return new GetCartService(cartRepository);
  }

  @Bean
  EmptyCartUseCase emptyCartUseCase() {
    return new EmptyCartService(cartRepository);
  }

  @Bean
  FindProductsUseCase findProductsUseCase() {
    return new FindProductsService(productRepository);
  }

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

Du findest die Änderungen in diesem Git-Commit und das Ergebnis in der Klasse SpringAppConfig.

Damit haben wir das Adapter-Modul fertig migriert. Ein Aufruf von mvn spotless:apply clean verify sollte nun (trotz einiger Compiler-Fehler im bootstrap-Modul) zeigen, dass sich das Adapter-Modul erfolgreich kompilieren lässt und dass alle Tests erfolgreich durchlaufen:

[INFO] parent ............................................. SUCCESS [  0.388 s]
[INFO] model .............................................. SUCCESS [  3.178 s]
[INFO] application ........................................ SUCCESS [  2.586 s]
[INFO] adapter ............................................ SUCCESS [ 35.616 s]
[INFO] bootstrap .......................................... FAILURE [  0.901 s]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------

Damit haben wir einen Großteil der Migration von Quarkus zu Spring Boot erledigt. Im nächsten Kapitel wenden wir uns dem letzten Modul, dem bootstrap-Modul, zu.

Schritt 5: Konfiguration, Starter-Klasse und End-to-End-Tests

Im bootstrap-Modul müssen wir zunächst die Quarkus-Konfiguration in eine Spring-Konfiguration übersetzen.

In Quarkus liegen die Konfigurationsparameter für alle Profile in der Datei application.properties im src/main/resources-Verzeichnis des bootstrap-Moduls. Den Konfigurationsparametern ist der Profilname „prod” bzw. „mysql” mit einem Prozentzeichen vorangestellt:

%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=mysqlCode-Sprache: Properties (properties)

Im Default-Produktionsprofil „prod” wollen wir die In-Memory-Adapter aktivieren, und im Profil „mysql” wollen wir die JPA-Adapter aktivieren und diese mit einer lokalen MySQL-Datenbank verbinden.

Für Spring splitten wir die Konfiguration in zwei Dateien auf: application.properties für den Default-Modus und application-mysql.properties für den MySQL-Modus.

application.properties enthält die ersten zwei Zeilen mit folgenden Anpassungen:

  • der Profil-Präfix ist entfernt,
  • der Key quarkus.datasource.jdbc.url ist durch spring.datasource.url ersetzt,
  • die Dummy-Datenquelle, die, wie oben bereits erwähnt, Spring zum Absturz bringen würde, ist durch eine H2-Datenquelle ersetzt.
spring.datasource.url=jdbc:h2:mem:testdb
persistence=inmemoryCode-Sprache: Properties (properties)

Die application-mysql.properties enthält die unteren fünf Zeilen der Quarkus-application.properties, entsprechend angepasst auf Spring:

spring.datasource.url=jdbc:mysql://localhost:3306/shop
spring.datasource.username=root
spring.datasource.password=test
spring.jpa.hibernate.ddl-auto=update
persistence=mysqlCode-Sprache: Properties (properties)

Spring-Starter-Klasse

Zuletzt brauchen wir für Spring noch eine Starter-Klasse, die bei Quarkus nicht erforderlich war. Wir legen dazu eine Klasse Launcher im eu.happycoders.shop.bootstrap-Paket des bootstrap-Moduls an, mit folgendem Inhalt:

public class Launcher {

  public static void main(String[] args) {
    SpringApplication.run(SpringAppConfig.class, args);
  }
}Code-Sprache: Java (java)

End-to-End-Tests anpassen

Bevor wir unsere Anwendung starten, passen wir noch die zwei End-to-End-Tests an. Wir müssen hier die gleichen Anpassungen vornehmen wie im adapter-Modul. Ich zeige dir die Änderungen beispielhaft am FindProductsTest – hier der bisherige Stand:

@QuarkusTest
@TestProfile(TestProfileWithMySQL.class)
class FindProductsTest {

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

    Response response =
        given()
            .queryParam("query", query)
            .get("/products")
            .then()
            .extract()
            .response();

    assertThatResponseIsProductList(
        response, List.of(COMPUTER_MONITOR, MONITOR_DESK_MOUNT));
  }
}Code-Sprache: Java (java)

Wir passen den Test wie folgt an:

  • @QuarkusTest ersetzen wir durch @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT).
  • @TestProfile(TestProfileWithMySQL.class) ersetzen wir durch @ActiveProfiles("test-with-mysql").
  • Um den Port herauszufinden, auf dem die Spring-Anwendung läuft, fügen wir folgende Zeile ein:
    @LocalServerPort private Integer port;
  • Und schließlich setzen wir den Port, indem wir given() um .port(port) erweitern.

Die geänderte Testklasse sieht dann wie folgt aus:

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test-with-mysql")
class FindProductsTest {

  @LocalServerPort private Integer port;

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

    Response response =
        given()
            .port(port)
            .queryParam("query", query)
            .get("/products")
            .then()
            .extract()
            .response();

    assertThatResponseIsProductList(
        response, List.of(COMPUTER_MONITOR, MONITOR_DESK_MOUNT));
  }
}Code-Sprache: Java (java)

Die Testklasse CartTest passen wir analog an.

Du findest alle Änderungen dieses Schritts in diesem Git-Commit. Die Ergebnisse der Änderungen findest du in den Klassen Launcher, FindProductsTest und CartTest sowie den Konfigurationsdateien application.properties und application-mysql.properties.

Ein Aufruf von mvn spotless:apply clean verify sollte das Projekt jetzt vollständig kompilieren und alle Tests erfolgreich durchlaufen lassen:

[INFO] parent ............................................. SUCCESS [  0.371 s]
[INFO] model .............................................. SUCCESS [  3.027 s]
[INFO] application ........................................ SUCCESS [  2.492 s]
[INFO] adapter ............................................ SUCCESS [ 33.636 s]
[INFO] bootstrap .......................................... SUCCESS [ 33.090 s]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

Starten der Anwendung

Nun ist es an der Zeit, die migrierte Anwendung zu starten.

Start der Anwendung aus der IDE heraus

Wir können die Anwendung direkt aus der IDE starten – in IntelliJ z. B. durch die Tastenkombination Ctrl+Shift+F10 in der geöffneten Launcher-Klasse oder durch Klick auf das grüne Dreieck neben dem Klassennamen oder neben der main-Methode:

intellij launch application

Sobald die Anwendung gestartet ist, kannst du die REST-Endpoints aufrufen wie im Abschnitt „Start der Anwendung” im zweiten Teil der Serie beschrieben.

In IntelliJ kannst du einfach die Datei sample-requests.http im doc-Verzeichnis öffnen und die verschiedenen GET-, POST- und DELETE-Kommandos anklicken.

Start der Anwendung im MySQL-Modus

Um die Anwendung im MySQL-Modus zu starten, brauchst du zunächst eine lokale MySQL-Datenbank. Diese startest du am einfachsten wie folgt über Docker (unter Windows musst du den Backslash am Ende der ersten Zeile entfernen und alles in eine Zeile schreiben):

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

Sobald die Datenbank hochgefahren ist, musst du die Launcher-Klasse mit der VM-Option -Dspring.profiles.active=mysql starten. In IntelliJ kannst du dazu z. B. auf das grüne Dreieck neben der Launcher-Klasse und dann auf „Modify Run Configuration...” klicken:

intellij vm options step1.v2

Im folgenden Dialog drückst du dann Alt+V, um die Eingabezeile für die VM-Optionen anzuzeigen und trägst dort -Dspring.profiles.active=mysql ein:

intellij vm options step2

Falls deine Datenbank z. B. auf einem anderen Port läuft, kannst du diesen in der application-mysql.properties ändern. Du kannst die Konfiguration auch durch VM-Optionen oder Umgebungsvariablen überschreiben, z. B. die Datenbank-URL durch die VM-Option -Dspring.datasource.url=... oder über die Environment Variable SPRING_DATASOURCE_URL=....

Fazit

In diesem fünften und letzten Teil der Tutorial-Serie über die hexagonale Architektur haben wir eine nach dieser Architektur entwickelte Demo-Anwendung von Quarkus nach Spring Boot migriert.

Dabei mussten wir nicht eine Zeile Code im Anwendungskern ändern und haben damit einen der großen Vorteile der hexagonalen Architektur demonstriert: Technische Details – und dazu zählt auch das Application Framework – lassen sich austauschen, ohne den Kern der Anwendung, also den fachlichen Code, anpassen zu müssen.

Was ist besser – Spring oder Quarkus?

Womit ließ sich die hexagonale Architektur einfacher implementieren? Mit Spring oder Quarkus? Letztendlich gibt es keinen großen Unterschied – vom Programmiermodell her funktionieren beide Frameworks gleich.

Wir haben ein paar Annotationen ausgetauscht, die Panache-Repositories durch Spring-Boot-Data-Repositories ersetzt und die Anwendungs- und Test-Konfigurationen angepasst.

Bei Quarkus sehe ich Vorteile in der vollautomatischen Konfiguration von Testcontainers und von REST-Assured, der vorhandenen ClientErrorException – und natürlich im Developer-Mode, der Änderungen ohne Kompilieren und Neustart ermöglicht. Bei Spring mussten wir in den Persistenzadaptern nicht den Umweg über die Instance<...>-Dependency-Injection machen.

Letztendlich ist die Wahl des Framework eine Frage der Erfahrung und des persönlichen Geschmacks.

Damit sind wir am Ende dieser Tutorial-Serie angekommen. Wenn dir die Serie 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.

Planst du die hexagonale Architektur in Zukunft einzusetzen? Wenn ja, für welche Art von Anwendung? Mit oder ohne Framework? Mit Quarkus oder Spring? Lass es mich gerne über die Kommentar-Funktion wissen!