hexagonal architecture spring boothexagonal architecture spring boot
HappyCoders Glasses

Hexagonal Architecture with Spring Boot
[Tutorial]

Sven Woltmann
Sven Woltmann
Last update: December 27, 2023

In this final part of the tutorial series, I will show you how to migrate the application we developed in this tutorial using the hexagonal architecture – initially without a framework, then with Quarkus – to the Spring Boot framework.

Review

We started this series of articles with the basics of hexagonal architecture.

We then developed a Java application using hexagonal architecture – initially without an application framework. We then extended the application with a JPA adapter to persist the data in a database instead of just keeping it in memory. In the previous part of the series, we embedded the hexagonal application into Quarkus as an application framework.

We were able to carry out all extensions to the application – both the connection to a new adapter and the migration to Quarkus – without having to change a single line of code in the application core.

The following graphic shows the architecture of our application. The aforementioned application core is represented by the hexagon labeled “Application”:

Hexagonal architecture of the sample application

You can find the current status of the demo application in the “with-quarkus” branch of this GitHub repository.

What Is This Part About?

In this fifth and final part of the series, we will migrate our application from Quarkus to Spring Boot – again, without changing any code in the application core.

We will proceed step by step as we did with the migration to Quarkus. However, as Spring uses different technologies for both primary and secondary controllers (Spring Web MVC instead of Jakarta RESTful Web Services in the frontend and Spring Data JPA instead of Panache in the backend), the application will not be compilable and executable between the steps, but only after we’ve completed the migration.

We will migrate the application to Spring Boot in the following steps:

  1. First, we will replace the Quarkus dependencies in the pom.xml files with Spring Boot dependencies.
  2. Then, we will adjust the primary REST adapters by replacing the JAX-RS annotations with Spring Web MVC annotations.
  3. We will then migrate the secondary persistence adapters by replacing the Panache repositories with Spring Data JPA repositories. We will also exchange the annotations that control which adapters (in-memory or JPA) are created.
  4. We will then replace the Quarkus application configuration in the adapter module with a Spring Boot configuration, after which the adapter module will be compilable again.
  5. Finally, we will add a Spring Boot launcher class and migrate the end-to-end tests in the bootstrap module.

You can find the state of the code after completing the migration in the “with-spring-boot” branch of the GitHub repository. You can find all changes compared to the Quarkus version in this pull request.

Let’s start with step 1...

Step 1: Replacing the Dependencies

Based on the project state in the “with-quarkus” branch, we will replace the Quarkus bill-of-materials with the Spring Boot starter parent and all Quarkus dependencies with Spring Boot dependencies in the project’s pom.xml files.

Adjusting the Parent pom.xml

To have the versions of all Spring Boot libraries available, we first add the Spring Boot starter parent to the pom.xml of the project directory, e.g., between <modelVersion> and <groupId>:

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

We can then remove all Quarkus-specific entries:

  • the <quarkus.platform.version> entry from the <properties> block,
  • the version of the assertj-core library, as this is already defined in the Spring boot parent,
  • the Quarkus BOM dependency from dependency management,
  • and the Quarkus Maven plugin.

Here, you can find the changes as a GitHub commit, and here is the modified pom.xml.

Adjusting the adapter/pom.xml

In the adapter/pom.xml we replace all Quarkus extensions with:

  • Spring boot libraries,
  • the MySQL driver,
  • and test containers.

(The last two libraries were previously imported into the project as transitive dependencies via Quarkus extensions.)

We also need to add an H2 database driver. The reason for this is that the Spring Boot application expects a valid database configuration even in in-memory mode due to the dependency on spring-boot-starter-data-jpa. We were able to fob off Quarkus in in-memory mode with a dummy MySQL URL, which only resulted in a warning. Spring, on the other hand, aborts the start process if the database URL is invalid. The connection of an – ultimately unused – H2 in-memory database is a workaround that is not pretty but sufficient for our demo application.

After the changes, the <dependencies> block of the adapter/pom.xml looks as follows:

<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 language: HTML, XML (xml)

Here, you can find the changes as a GitHub commit, and here is the modified adapter/pom.xml.

Adjusting the bootstrap/pom.xml

In bootstrap/pom.xml, we have to replace the test dependencies on the Quarkus JUnit and Mockito extensions with spring-boot-starter-test and add Testcontainers – this was previously imported transitively via the Quarkus extensions.

Here, you can see the test dependencies after the 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 language: HTML, XML (xml)

We also need to replace the quarkus-maven-plugin with the spring-boot-maven-plugin. The <build> block thus looks as follows:

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

Here, you can find the changes as a GitHub commit, and here is the bootstrap/pom.xml after the changes.

Note that the adapter and bootstrap modules of the application can no longer be compiled at this point, but the model and application modules can, as they do not contain any technical details.

Accordingly, a call to mvn spotless:apply clean verify should result in the following output at this point:

[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] ------------------------------------------------------------------------

In the following sections, we will gradually adjust the adapter module code and then the bootstrap module code to make the project compilable again.

Step 2: Migrating REST Adapters From JAX-RS to Spring Web MVC

In the adapter module, we start by modifying the REST adapters. The migration from Jakarta RESTful Web Services to Spring Web MVC means, first and foremost, that we must replace

  • annotations and
  • HTTP status code enum constants

with their respective Spring counterparts.

We also need to recreate the Jakarta EE exception handling mechanism, as there is nothing comparable in Spring as far as I know. Please correct me via the comment function if I’m wrong!

Finally, we must set the HTTP port again in the REST Assured integration tests. We had removed this as part of the migration to Quarkus, as Quarkus does this automatically.

Replacing JAX-RS Annotations With Spring Annotations

Let’s start with the annotations...

GetCartController

Let’s start with the easiest controller, GetCartController. Here, you can see the JAX-RS controller, including the relevant 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 language: Java (java)

Let’s go through the annotations from top to bottom. The following table lists the JAX-RS annotations, explains their meaning, and shows the respective Spring counterpart:

JAX-RS annotation(s)DescriptionSpring counterpart
@Path("/carts")Marks the class as a REST controller and defines the base path@RestController
@RequestMapping("/carts")
@Produces(APPLICATION_JSON)Defines the response format; was necessary for the application without an application framework. It could have been removed in the Quarkus version, as Quarkus sets JSON as default.Omitted, as Spring also sets JSON as the default.
@GET
@Path("/{customerId}")
Marks the method as a GET controller and defines the path.@GetMapping("/{customerId}")
@PathParam("customerId")Extracts the path element {customerId} into the annotated method parameter.@PathVariable("customerId")

If we replace the annotations listed in the table, the Jakarta RESTful web services controller shown above turns into the following Spring Web MVC controller (GetCartController in the GitHub repository; here are only the changes):

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 language: Java (java)

As you can see, the difference between Spring and Jakarta EE is minimal.

EmptyCartController

We can proceed the same way with the EmptyCartController, but we have to pay attention to a difference in the HTTP status codes – more on this below.

Here is the Jakarta RESTful web services code (I’ll omit the imports this time):

@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 language: Java (java)

We encounter another JAX-RS annotation here, @DELETE:

JAX-RS annotation(s)DescriptionSpring counterpart
@DELETE
@Path("/{customerId}")
Marks the method as a DELETE controller and defines the path.@DeleteMapping("/{customerId}")

The JAX-RS @DELETE annotation causes the controller to return an HTTP status code 204, “No Content.” Spring’s @DeleteMapping annotation, on the other hand, returns the HTTP status code 200, “OK.” This would cause the integration tests for this method to fail.

To retain the original behavior and have Spring return a status code 204, the method must return a corresponding ResponseEntity. We can generate this via ResponseEntity.noContent().build().

Here is the controller migrated to Spring (EmptyCartController in the GitHub repository; here are only the changes):

@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 language: Java (java)

Replacing the HTTP Status Code Enum Constants

For the two other REST controllers, AddToCartController and FindProductsController, we also need to exchange the enum constants for the HTTP status codes.

AddToCartController

Let’s first take a look at AddToCartController – here is the old 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 language: Java (java)

We see two further annotations in use:

JAX-RS annotation(s)DescriptionSpring counterpart
@POST
@Path("/.../...")
Marks the method as a POST controller and defines the path.@PostMapping("/.../...")
@QueryParam("...")Binds a query or request parameter (two different names for the parameters appended to the URL with a question mark) with the specified name to the annotated variable.@RequestParam("...")

The JAX-RS enum class jakarta.ws.rs.core.Response.Status defines constants for all possible HTTP status codes. Spring defines the status codes in the class org.springframework.http.HttpStatus.

The following listing shows the controller completely migrated to Spring (AddToCartController in the GitHub repository; here are the changes):

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 language: Java (java)

Note that the calls to the clientErrorException(...) method do not yet compile, as we have not yet migrated them to the Spring HttpStatus.

FindProductsController

Finally, there is also a detail to note in FindProductsController, namely the query or request parameter. Here is the old code first:

@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 language: Java (java)

And here is the code migrated to Spring (class FindProductsController; here are the changes):

@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 language: Java (java)

We have already discussed the replaced annotations in the three previous controllers. However, we had to add the parameter required = false to the @RequestParam annotation. Otherwise, a missing query parameter would lead to an error message from the framework instead of the error message we implemented at the beginning of the method.

Here, too, the call to the clientErrorException(...) method does not yet compile.

CustomerIdParser and ProductIdParser

In the parser classes, we return specific HTTP status codes depending on the type of error. We must, therefore, also replace the corresponding Response.Status constants in these classes with HttpStatus constants.

Here are the changed classes:

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 language: Java (java)

And:

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 language: Java (java)

You can find the changes here and here and the results in the adjusted classes CustomerIdParser and ProductIdParser.

Implementing the Exception Handler

In the Jakarta RESTful web services implementation, we could throw a jakarta.ws.rs.ClientErrorException on input errors. We passed a Response object to this exception containing the HTTP status code and a JSON response body with an error message.

As far as I know, the Spring framework does not provide a corresponding exception. Please correct me via the comment function if I’m wrong!

We, therefore, have to build such an exception ourselves, together with a corresponding exception handler. The exception (ClientErrorException in the GitHub repository) has a constructor to which we pass a ResponseEntity, which is then saved in the response field:

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 language: Java (java)

The exception handler (ClientErrorHandler class) is also quickly implemented. It receives a @RestControllerAdvice annotation and a @ExceptionHandler annotated method that returns the ResponseEntity stored in ClientErrorException:

@RestControllerAdvice
public class ClientErrorHandler {

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

Then, only minor adjustments to the ControllerCommons class are necessary to throw our own ClientErrorException instead of the jakarta.ws.rs.ClientErrorException. Here is the complete ControllerCommons class:

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 language: Java (java)

The Spring framework now intercepts a ClientErrorException using the ClientErrorHandler, and the REST controller returns the error code and the error message stored in the exception.

After adjusting the ControllerCommons class, the compiler errors in the controllers and parsers should have disappeared.

Migrating Integration Tests From Quarkus to Spring

We need to make the following changes in the integration tests:

  • replace annotations,
  • set the server port in the REST Assured tests,
  • replace Jakarta’s Response.Status with Spring’s HttpStatus.

I will show the changes using the ProductsControllerTest as an example. This is the old version:

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 language: Java (java)

Replacing Annotations

We replace the following annotations:

JAX-RS annotation(s)DescriptionSpring counterpart
@QuarkusTestStarts the application before the integration test.@ActiveProfiles("test")
@SpringBootTest(

    webEnvironment = RANDOM_PORT)
@InjectMockMocks a bean and injects the mock wherever the bean is used.@MockBean

Setting the Server Port

Then we inform REST Assured about the port of the application (with Quarkus this was done automatically). Using the following line, Spring injects the server port into the variable port:

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

Then, we must insert the call port(port) after each call to given(). For example:

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

turns into:

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

Replacing Response.Status With HttpStatus

Finally, we replace the static import jakarta.ws.rs.core.Response.Status.BAD_REQUEST with org.springframework.http.HttpStatus.BAD_REQUEST. As the name of the enum constant, BAD_REQUEST, is the same in both frameworks, we don’t need to do anything else.

The modified test class then looks as follows:

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 language: Java (java)

The call to assertThatResponseIsError(...) does not yet compile, as we have not yet converted the method to Spring’s HttpStatus. That’s what we’ll do next.

In the class HttpTestCommons, we replace the type of the expectedStatus variable, Response.Status with HttpStatus and expectedStatus.getStatusCode() with expectedStatus.value(). The modified class then looks as follows:

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 language: Java (java)

We adapt the CartsControllerTest in the same way. And in CartsControllerAssertions and ProductsControllerAssertions, we replace the static import jakarta.ws.rs.core.Response.Status.OK with org.springframework.http.HttpStatus.OK and the expression OK.getStatusCode() with OK.value().

You can find all changes to the tests starting at this position in the Git commit and the changed classes in this directory in the GitHub repository.

We have thus migrated the primary REST adapters from Quarkus to Spring. In the next step, we turn to the secondary persistence adapters.

Step 3: Migrating JPA Adapters From Panache to Spring Data JPA

To migrate the secondary persistence adapters to Spring, we proceed as follows:

  • We replace the annotations that control whether the in-memory or JPA adapters are used.
  • We replace the bean-generating annotations.
  • We replace the Panache repositories with Spring Data JPA repositories.
  • Since Spring’s findById(...) methods return Optionals, we need to make a few adjustments to the mappers.
  • Finally, we adjust the integration tests.

Replacing Annotations

In the Quarkus version, we have used the following annotations to control which persistence adapters are used:

On the in-memory adapters InMemoryCartRepository and InMemoryProductRepository:

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

On the JPA adapters JpaCartRepository and JpaProductRepository:

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

For the Spring version, we replace the annotations as follows:

In-memory adapter:

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

JPA adapter:

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

We also replace the bean-generating annotation @ApplicationScoped with @Repository in the same four classes.

Here, you can find the InMemoryCartRepository and InMemoryProductRepository classes migrated to Spring. We will make further changes to the JPA adapters in the next section.

Replacing Panache Repositories With Spring Data JPA Repositories

In the Quarkus variant, we used Panache to load JPA entities from the database conveniently. The Spring counterpart to this is “Spring Data JPA.”

The following listing shows the two Panache repositories:

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

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

Here you can see the corresponding Spring Data JPA repositories (also note the changed class names):

@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 language: Java (java)

There are two essential differences:

  • The Panache repositories are classes; the Spring Data JPA repositories are interfaces.
  • In the Panache repository, we have implemented the search via the PanacheRepositoryBase.find(...) method by passing it an HQL WHERE query. In the Spring Data JPA repository, we can declare a separate interface method for this and provide it with a JPQL query annotation. I will come back to this below when I show the changes to the adapter classes.

Now, we must adjust our adapters JpaCartRepository and JpaProductRepository and use the Spring Data JPA repositories instead of the Panache repositories.

Let’s start with the simpler adapter, JpaCartRepository. So far, it looks like this:

@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 language: Java (java)

We replace the two class annotations @LookupIfProperty and @ApplicationScoped as described in the section “Replacing Annotations” above.

We replace the field JpaCartPanacheRepository panacheRepository with JpaCartSpringDataRepository springDataRepository and adjust the constructor accordingly.

We replace the call panacheRepository.getEntityManager().merge(...) with springDataRepository.save(...).

While the panacheRepository.findById(...) method returned a CartJpaEntity, springDataRepository.findById(...) returns a Optional<CartJpaEntity>. We adjust the call of the mapper accordingly.

Here you can see the Spring version of the adapter class:

@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 language: Java (java)

The method reference CartMapper::toModelEntity currently still results in a compiler error, as the corresponding method does not yet exist. In CartMapper, we currently have a method toModelEntityOptional(...) that turns a CartJpaEntity into an Optional<Cart>:

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 language: Java (java)

The Spring Data JPA repository already provides us with an Optional<CartJpaEntity>, which we can map to an Optional<Cart> using the map(...) method. To do this, we replace the toModelEntityOptional(...) method with the following toModelEntity(...) method:

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 language: Java (java)

JpaCartRepository should now no longer display any compiler errors.

Next, let’s take a look at the class JpaProductRepository. Here is the 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 language: Java (java)

First of all, we make the same adjustments as to JpaCartAdapter.

In the findByNameOrDescription(...) method, we also replace the call to the find(...) method:

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

by calling the findByNameOrDescriptionLike(...) method declared in the Spring Data JPA interface.

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

In the Panache variant, we pass an HQL (Hibernate Query Language) WHERE query at this point; in the Spring variant, however, we have annotated the findByNameOrDescriptionLike(...) method in the JpaProductSpringDataRepository interface with a JPQL query:

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

Here you can see the class migrated to Spring:

@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 language: Java (java)

You can find all changes (including those to the mappers that I have not included here) in this Git commit and the results in the classes JpaCartRepository, JpaCartSpringDataRepository, CartMapper, JpaProductRepository, JpaProductSpringDataRepository, and ProductMapper.

Adjusting the Integration Tests

In the integration tests for the persistence adapters, we must:

  • adjust the dependency injection,
  • migrate the configuration of the Quarkus test profiles to Spring.

Adjusting Dependency Injection

I will show you the necessary adjustments to the dependency injection on the class AbstractProductRepositoryTest – here is the current state:

public abstract class AbstractProductRepositoryTest {

  @Inject Instance<ProductRepository> productRepositoryInstance;

  private ProductRepository productRepository;

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

  . . .
}Code language: Java (java)

With Quarkus, we had to inject the product repository, which can be either an in-memory or a JPA repository, as Instance<ProductRepository> and then read it out in the @BeforeEach method via Instance.get().

It’s easier in Spring. We can inject the configurable bean, like any other bean, without any detours – and we don’t need a @BeforeEach method:

public abstract class AbstractProductRepositoryTest {

  @Autowired ProductRepository productRepository;

  . . .
}Code language: Java (java)

The class AbstractCartRepositoryTest is adapted analogously.

Migrating Quarkus Test Profiles to Spring

In the Quarkus variant, we have defined a test profile class TestProfileWithMySQL, which sets the configuration persistence=mysql:

public class TestProfileWithMySQL implements QuarkusTestProfile {

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

We can delete this class. We don’t need such a class in Spring. Test profiles in Spring require a name and a properties file. We create the file application-test-with-mysql.properties in the test/resources directory of the application module with the following content:

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

persistence=mysqlCode language: plaintext (plaintext)

In the last line, we define the configuration entry persistence=mysql known from Quarkus. The two lines at the beginning specify that we use Testcontainers (“tc”) as the database and that Hibernate should create the tables automatically. Quarkus did both automatically.

I already mentioned that Spring needs a database in the in-memory mode because of the spring-data-jpa dependency. Otherwise, it will not start. To do this, we create an application-test.properties file with the following content:

spring.datasource.url=jdbc:h2:mem:testdbCode language: plaintext (plaintext)

So, an H2 database should be used in in-memory mode. That is the simplest workaround I could think of for our demo application.

To activate the corresponding profiles for the respective tests, we need to change a few annotations in the following test classes. This is what the concrete persistence tests look like in the Quarkus variant:

@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 language: Java (java)

We replace the annotations as follows:

  • @QuarkusTest with @SpringBootTest
  • @TestProfile(TestProfileWithMySQL.class) with @ActiveProfiles("test-with-mysql")
  • We also have to add the annotation @ActiveProfiles("test") to the in-memory tests so that they are not created in the test-with-mysql profile. With Quarkus, they were automatically not created if we activated a profile.

After the changes, the test classes look as follows:

@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 language: Java (java)

You can find all changes to the tests at this position in the Git commit and the migrated test classes in this directory in the GitHub repository.

Step 4: Migrating the Application Configuration From Quarkus to Spring Boot

In this step, we replace the Quarkus application configuration in the adapter module with a Spring Boot configuration. At the end of this comparatively short step, we will be able to compile the adapter module and run the tests.

The Quarkus application configuration is in the class QuarkusAppConfig, which currently looks as follows:

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 language: Java (java)

Reminder:

We need this class to instantiate the service classes in the application module. This is because the application module knows nothing about Quarkus (it is a technical detail that does not belong there), and therefore, we cannot and do not want to use @ApplicationScoped annotations there.

The same applies to Spring. The application module knows nothing about Spring, so we cannot use the @Service annotation there.

What specifically do we need to change now?

  • We rename the class QuarkusAppConfig to SpringAppConfig.
  • We annotate the class with @SpringBootApplication.
  • We replace the @Inject annotations with @Autowired.
  • We remove the Instance<...> wrappers around CartRepository and ProductRepository (as in the persistence adapter tests).
  • We replace the annotation pair @Produces @ApplicationScoped with @Bean.

The class then looks as follows:

@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 language: Java (java)

You can find the changes in this Git commit and the result in the SpringAppConfig class.

We have now finished migrating the adapter module. A call to mvn spotless:apply clean verify should now show (despite some compiler errors in the bootstrap module) that the adapter module compiles and that all tests have run successfully:

[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] ------------------------------------------------------------------------

We have now completed a large part of the migration from Quarkus to Spring Boot. In the next chapter, we turn to the last module, the bootstrap module.

Step 5: Configuration, Starter Class, and End-To-End Tests

In the bootstrap module, we must first translate the Quarkus configuration into a Spring configuration.

In Quarkus, the configuration parameters for all profiles are located in the file application.properties in the src/main/resources directory of the bootstrap module. The configuration parameters are preceded by the profile name “prod” or “mysql” and a percent sign:

%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 language: Properties (properties)

In the default production profile “prod,” we want to activate the in-memory adapters, and in the profile “mysql,” we want to activate the JPA adapters and connect them to a local MySQL database.

For Spring, we split the configuration into two files: application.properties for the default mode and application-mysql.properties for the MySQL mode.

application.properties contains the first two lines with the following adjustments:

  • the profile prefix is removed,
  • the key quarkus.datasource.jdbc.url is replaced by spring.datasource.url,
  • the dummy data source, which, as mentioned above, would cause Spring to abort, has been replaced by an H2 data source.
spring.datasource.url=jdbc:h2:mem:testdb
persistence=inmemoryCode language: Properties (properties)

application-mysql.properties contains the lower five lines of the Quarkus application.properties, adjusted accordingly to 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 language: Properties (properties)

Spring Starter Class

Finally, we need a Spring starter class, which was unnecessary for Quarkus. We create a class Launcher in the eu.happycoders.store.bootstrap package of the bootstrap module with the following content:

public class Launcher {

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

Adjusting End-to-End Tests

Before we start our application, we adjust the two end-to-end tests. We have to make the same adjustments here as in the adapter module. I will show you the changes using FindProductsTest as an example – here is its current state:

@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 language: Java (java)

We adapt the test as follows:

  • We replace @QuarkusTest with @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT).
  • We replace @TestProfile(TestProfileWithMySQL.class) with @ActiveProfiles("test-with-mysql").
  • To find out the port on which the Spring application is running, we insert the following line:
    @LocalServerPort private Integer port;
  • Finally, we set the port by adding .port(port) to given().

The modified test class then looks as follows:

@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 language: Java (java)

We adapt the test class CartTest in the same way.

You can find all the changes from this step in this Git commit. You can find the results of the changes in the classes Launcher, FindProductsTest, and CartTest, as well as the configuration files application.properties and application-mysql.properties.

A call to mvn spotless:apply clean verify should now fully compile the project and run all tests successfully:

[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] ------------------------------------------------------------------------

Starting the Application

Now, it is time to start the migrated application.

Starting the Application From the IDE

We can start the application directly from the IDE – in IntelliJ, for example, by using the key combination Ctrl+Shift+F10 in the open Launcher class or by clicking on the green triangle next to the class name or next to the main method:

intellij launch application

As soon as the application is running, you can call its REST endpoints as described in the section “Starting the application” in the second part of the series.

In IntelliJ, you can simply open the sample-requests.http file in the doc directory and click on the various GET, POST and DELETE commands.

Starting the Application in MySQL Mode

To start the application in MySQL mode, you first need a local MySQL database. The easiest way to start this is as follows via Docker (in Windows, you have to remove the backslash at the end of the first line and write everything in one line):

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

When the database is up and running, start the Launcher class with the VM option -Dspring.profiles.active=mysql. In IntelliJ, for example, you can click on the green triangle next to the Launcher class and then on “Modify Run Configuration...”:

intellij vm options step1.v2

In the following dialog, press Alt+V to display the input field for the VM options and enter -Dspring.profiles.active=mysql:

intellij vm options step2

For example, if your database is running on a different port, you can change this in application-mysql.properties. You can also overwrite the configuration with VM options or environment variables, e.g., the database URL with the VM option -Dspring.datasource.url=... or via the environment variable SPRING_DATASOURCE_URL=....

Conclusion

In this fifth and final part of the tutorial series on the hexagonal architecture, we migrated a demo application developed according to this architecture from Quarkus to Spring Boot.

We did not have to change a single line of code in the application core, demonstrating one of the major advantages of hexagonal architecture: Technical details – including the application framework – can be replaced without touching the application’s core, i.e., the business logic.

Which is Better – Spring or Quarkus?

How could the hexagonal architecture be implemented more easily? With Spring or Quarkus? Ultimately, there is not much difference – in terms of the programming model, both frameworks work identically.

We have replaced a few annotations, replaced the Panache repositories with Spring Boot Data repositories, and adjusted the application and test configurations.

With Quarkus, I see advantages in the fully automatic configuration of Testcontainers and REST Assured, the existing ClientErrorException – and, of course, in the developer mode, which allows changes without compiling and restarting. With Spring, we did not have to take the detour via the Instance<...> dependency injection in the persistence adapters.

Ultimately, the choice of framework is a question of experience and personal taste.

This brings us to the end of this tutorial series. If you enjoyed the series, I would be grateful for a short review on ProvenExpert.

Do you want to stay up to date and be informed when new articles are published on HappyCoders.eu? Then click here to sign up for the free HappyCoders newsletter.

Are you planning to use hexagonal architecture in the future? If so, for what type of application? With or without a framework? With Quarkus or Spring? Let me know via the comment function!