Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 11 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ It is part of the HappyCoders tutorial series on Hexagonal Architecture:
* [Part 2: Hexagonal Architecture with Java - Tutorial](https://www.happycoders.eu/software-craftsmanship/hexagonal-architecture-java/).
* [Part 3: Ports and Adapters Java Tutorial: Adding a Database Adapter](https://www.happycoders.eu/software-craftsmanship/ports-and-adapters-java-tutorial-db/).
* [Part 4: Hexagonal Architecture with Quarkus - Tutorial](https://www.happycoders.eu/software-craftsmanship/hexagonal-architecture-quarkus/).
* [Part 5: Hexagonal Architecture with Spring Boot - Tutorial](https://www.happycoders.eu/software-craftsmanship/hexagonal-architecture-spring-boot/).

# Branches

Expand All @@ -33,7 +34,7 @@ In the `with-quarkus` branch, you'll find an implementation using [Quarkus](http

## `with-spring`

There will soon be an additional branch with an implementation using [Spring](https://spring.io/) instead of Quarkus.
In the `with-spring` branch, you'll find an implementation using [Spring](https://spring.io/) as application framework.

# Architecture Overview

Expand All @@ -47,29 +48,26 @@ The following diagram shows the hexagonal architecture of the application along

![Hexagonal Architecture Modules](doc/hexagonal-architecture-modules.png)

The `model` module is not represented as a hexagon because it is not defined by the Hexagonal Architecture. Hexagonal Architecture leaves open what happens inside the application hexagon.
The `model` module is not represented as a hexagon because it is not defined by the Hexagonal Architecture. Hexagonal Architecture leaves open what happens inside the application hexagon.

# How to Run the Application

You can run the application in Quarkus dev mode with the following command:
The easiest way to run the application is to start the `main` method of the `Launcher` class (you'll find it in the `boostrap` module) from your IDE.

```shell
mvn test-compile quarkus:dev
```
By default, the application will run with the in-memory persistence option.

You can use one of the following VM options to select a persistence mechanism:
To select the MySQL persistence option, start it with the following VM option:

* `-Dpersistence=inmemory` to select the in-memory persistence option (default)
* `-Dpersistence=mysql` to select the MySQL option
`-Dspring.profiles.active=mysql`

For example, to run the application in MySQL mode, enter:
If you selected the MySQL option, you will need a running MySQL database. The easiest way to start one is to use the following Docker command:

```shell
mvn test-compile quarkus:dev -Dpersistence=mysql
docker run --name hexagon-mysql -d -p3306:3306 \
-e MYSQL_DATABASE=shop -e MYSQL_ROOT_PASSWORD=test mysql:8.1
```

In dev mode, Quarkus will automatically start a MySQL database using Docker,
and it will automatically create all database tables.
The connection parameters for the database are hardcoded in `application-mysql.properties`. If you are using the Docker container as described above, you can leave the connection parameters as they are. Otherwise, you may need to adjust them.

# Example Curl Commands

Expand Down
38 changes: 18 additions & 20 deletions adapter/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -23,44 +23,42 @@

<!-- External -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-arc</artifactId>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-orm</artifactId>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-orm-panache</artifactId>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-mysql</artifactId>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy-jackson</artifactId>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>

<!-- Test scope -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5-mockito</artifactId>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<scope>test</scope>
</dependency>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,38 +10,40 @@
import eu.happycoders.shop.application.service.cart.EmptyCartService;
import eu.happycoders.shop.application.service.cart.GetCartService;
import eu.happycoders.shop.application.service.product.FindProductsService;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Instance;
import jakarta.enterprise.inject.Produces;
import jakarta.inject.Inject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

class QuarkusAppConfig {
/**
* Spring application configuration, making Spring beans from services defined in application
* module.
*
* @author Sven Woltmann
*/
@SpringBootApplication
public class SpringAppConfig {

@Inject Instance<CartRepository> cartRepository;
@Autowired CartRepository cartRepository;

@Inject Instance<ProductRepository> productRepository;
@Autowired ProductRepository productRepository;

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

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

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

@Produces
@ApplicationScoped
@Bean
AddToCartUseCase addToCartUseCase() {
return new AddToCartService(cartRepository.get(), productRepository.get());
return new AddToCartService(cartRepository, productRepository);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,20 @@
import eu.happycoders.shop.model.cart.NotEnoughItemsInStockException;
import eu.happycoders.shop.model.customer.CustomerId;
import eu.happycoders.shop.model.product.ProductId;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

/**
* REST controller for all shopping cart use cases.
*
* @author Sven Woltmann
*/
@Path("/carts")
@Produces(MediaType.APPLICATION_JSON)
@RestController
@RequestMapping("/carts")
public class AddToCartController {

private final AddToCartUseCase addToCartUseCase;
Expand All @@ -33,24 +32,22 @@ public AddToCartController(AddToCartUseCase addToCartUseCase) {
this.addToCartUseCase = addToCartUseCase;
}

@POST
@Path("/{customerId}/line-items")
@PostMapping("/{customerId}/line-items")
public CartWebModel addLineItem(
@PathParam("customerId") String customerIdString,
@QueryParam("productId") String productIdString,
@QueryParam("quantity") int quantity) {
@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(
Response.Status.BAD_REQUEST, "The requested product does not exist");
throw clientErrorException(HttpStatus.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()));
HttpStatus.BAD_REQUEST, "Only %d items in stock".formatted(e.itemsInStock()));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,19 @@

import eu.happycoders.shop.application.port.in.cart.EmptyCartUseCase;
import eu.happycoders.shop.model.customer.CustomerId;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* REST controller for all shopping cart use cases.
*
* @author Sven Woltmann
*/
@Path("/carts")
@Produces(MediaType.APPLICATION_JSON)
@RestController
@RequestMapping("/carts")
public class EmptyCartController {

private final EmptyCartUseCase emptyCartUseCase;
Expand All @@ -25,10 +25,10 @@ public EmptyCartController(EmptyCartUseCase emptyCartUseCase) {
this.emptyCartUseCase = emptyCartUseCase;
}

@DELETE
@Path("/{customerId}")
public void deleteCart(@PathParam("customerId") String customerIdString) {
@DeleteMapping("/{customerId}")
public ResponseEntity<Void> deleteCart(@PathVariable("customerId") String customerIdString) {
CustomerId customerId = parseCustomerId(customerIdString);
emptyCartUseCase.emptyCart(customerId);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,18 @@
import eu.happycoders.shop.application.port.in.cart.GetCartUseCase;
import eu.happycoders.shop.model.cart.Cart;
import eu.happycoders.shop.model.customer.CustomerId;
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;
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;

/**
* REST controller for all shopping cart use cases.
*
* @author Sven Woltmann
*/
@Path("/carts")
@Produces(MediaType.APPLICATION_JSON)
@RestController
@RequestMapping("/carts")
public class GetCartController {

private final GetCartUseCase getCartUseCase;
Expand All @@ -26,9 +25,8 @@ public GetCartController(GetCartUseCase getCartUseCase) {
this.getCartUseCase = getCartUseCase;
}

@GET
@Path("/{customerId}")
public CartWebModel getCart(@PathParam("customerId") String customerIdString) {
@GetMapping("/{customerId}")
public CartWebModel getCart(@PathVariable("customerId") String customerIdString) {
CustomerId customerId = parseCustomerId(customerIdString);
Cart cart = getCartUseCase.getCart(customerId);
return CartWebModel.fromDomainModel(cart);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package eu.happycoders.shop.adapter.in.rest.common;

import lombok.Getter;
import org.springframework.http.ResponseEntity;

/**
* An exception to be thrown in case of a client error (e.g., invalid input).
*
* @author Sven Woltmann
*/
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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package eu.happycoders.shop.adapter.in.rest.common;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/**
* Handles {@link ClientErrorException} by returning a JSON body containing the error details.
*
* @author Sven Woltmann
*/
@RestControllerAdvice
public class ClientErrorHandler {

@ExceptionHandler(ClientErrorException.class)
public ResponseEntity<ErrorEntity> handleProductNotFoundException(ClientErrorException ex) {
return ex.getResponse();
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package eu.happycoders.shop.adapter.in.rest.common;

import jakarta.ws.rs.ClientErrorException;
import jakarta.ws.rs.core.Response;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

/**
* Common functionality for all REST controllers.
Expand All @@ -12,12 +12,12 @@ public final class ControllerCommons {

private ControllerCommons() {}

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

public static Response errorResponse(Response.Status status, String message) {
ErrorEntity errorEntity = new ErrorEntity(status.getStatusCode(), message);
return Response.status(status).entity(errorEntity).build();
public static ResponseEntity<ErrorEntity> errorResponse(HttpStatus status, String message) {
ErrorEntity errorEntity = new ErrorEntity(status.value(), message);
return ResponseEntity.status(status.value()).body(errorEntity);
}
}
Loading