In this article, I will show you how to implement a Java application with hexagonal architecture, step by step – and how to enforce compliance with the architecture rules using Maven and the library “ArchUnit.”
You will see the advantages of hexagonal architecture in practice:
We will start implementing the data model and business logic and develop a pure Java project for quite a while. Technical details like REST controllers and the database will be connected relatively late. This also means that we can postpone deciding which technology to use until we have gained some experience with our application.
In three follow-up articles, we will:
- replace the persistence solution (from an in-memory solution to MySQL),
- embed the application in the Quarkus framework, and
- replace Quarkus with Spring
... and all this without changing even one line of code in the application core (OK, we might add a few annotations for transaction control).
Hexagonal Architecture – Example Application
In this tutorial, we will develop a small sample application together. The application provides the (highly simplified) backend for an online store that includes the following functionalities:
- Searching for products
- Adding a product to the shopping cart
- Retrieving the shopping cart with the products, their respective quantity, and the total price
- Emptying the shopping cart
In the business logic, we want to ensure the following pre- and postconditions:
- The amount of a product added to the cart must be at least one.
- After adding a product, the total quantity of this product in the cart must not exceed the amount of the product available in the warehouse.
That’s all there is to it. I’ve intentionally kept the application simple, as the focus of this article should be on the architecture and not the functionality.
Technologies Used
We will implement the application using Java 20 (Mockito unfortunately does not yet support the current LTS release Java 21) and initially without any application framework like Spring or Quarkus. Why? The application framework is a technical detail – and as such, it should not be the foundation of an application. The foundation of a hexagonal application is its business logic!
Nevertheless, in the fourth and fifth parts of the series, I will show you how to embed the application in an application framework because such a framework provides many useful non-functional features such as declarative transaction management, metrics and health endpoints, and much more.
For now, we only use the following:
- RESTEasy as an implementation of Jakarta RESTful Web Services – also known as JAX-RS,
- Undertow as a lightweight web server,
- ArchUnit to verify compliance with architectural boundaries,
- Project Lombok to save us boilerplate code.
In addition, we will take a test-driven approach:
- For each domain entity, we write a unit test.
- For each domain service, we write a unit test.
- For each adapter, we write an integration test.
- We write end-to-end tests for the most critical use cases.
I will not print all tests in this article, but only one example each for an entity, a service, a primary adapter, and a secondary adapter. You can find the complete source code for this article, including a full test suite, in this GitHub repository.
It’s best to check out the without-jpa-adapters branch because the main branch already contains the JPA persistence solution, which I won’t demonstrate until the next part of the series. It was initially going to be part of this article; however, when I realized how long this tutorial would be, I decided to postpone this step until a follow-up article.
Hexagonal Architecture – Project Structure
The following graphic shows the concrete hexagonal architecture we will use to set up the store application.
In the center, we will place the model classes for our store. I did not represent the model as a hexagon because it is not defined by the hexagonal architecture (remember, the hexagonal architecture leaves open what happens inside the application hexagon).
Nevertheless, the model is a separate module because it is not supposed to access the ports. Only the domain services coordinating the business logic in the model classes will access the ports.
In the application hexagon, we implement the primary (left) and secondary ports (right) as well as the domain services – that is, the business functions (in the form of individual use cases), which in turn access the secondary ports as well as the business logic of the model classes.
We will connect three types of adapters to the ports:
- To the primary port: a REST adapter through which we can call the store functions.
- To the secondary port: an in-memory adapter that stores the shop data in RAM.
- In the next part of this series, also to the secondary port: a JPA adapter that persists the shop data to a MySQL database via Hibernate.
Finally, the Bootstrap component will instantiate the domain services and adapters, launch a web server, and deploy the application on the server.
The black arrows represent the method call directions, and the white arrows the source code dependency direction (“dependency rule”).
So how do we map this architecture to the source code? To do this, we will define modules (in the form of Maven modules) and, within the modules, Java packages. I will describe modules and packages and the directory structure through which they are represented in the following sections.
Hexagonal Architecture – Module Structure
I will split the source code into four Maven modules that will reside in four subdirectories of the project directory:
model | Contains the domain model, i.e. those classes that represent the shopping cart and the products. According to domain-driven design, we will distinguish here between Entities (have an identity and are mutable) and Value Objects (have no identity and are immutable). |
application | Contains a) the ports and b) the domain services that implement the use cases. application and model module together form the application core. |
adapter | Contains the REST and persistence adapters. |
bootstrap | Instantiates adapters and domain services and starts the built-in Undertow web server. |
We will define the dependencies between the Maven modules as follows, analogous to the project structure graphic above:
This way, we have ensured that all source code dependencies point from the outside toward the core.
Later I will show you how to use the “ArchUnit” library to ensure that a module can only access certain packages of another module, e.g., the adapters can only access the ports of the application module but not the domain services located in the same module.¹
¹ If, at this point, you’re thinking of the Java module system “Jigsaw” – I’ll cover that too in the Enforcing Architectural Boundaries section.
Hexagonal Architecture – Package Structure
The main package of the sample application is eu.happycoders.shop
. Within the modules, we will create the following package structure (I based this on the excellent book “Get Your Hands Dirty on Clean Architecture” by Tom Hombergs):
Model
The model module is in the package eu.happycoders.shop.model
. Below that, we will create sub-packages throughout the tutorial to group the various entities and value objects. We do not want to plan these in advance but create them as needed during development.
eu.happycoders.shop.model
Application
The application module is located in the eu.happycoders.shop.application
package – with two sub-packages for the ports and the domain services. I divide the ports, in turn, into incoming and outgoing ports. For outgoing ports, I also create a sub-package persistence
to prepare the application for later extensions, e.g., outgoing ports for access to an external system.
eu.happycoders.shop.application
├ port
│ ├ in
│ └ out.persistence
└ service
Adapter
The adapter module is located in the package eu.happycoders.shop.adapter
. Below this are – analogous to the ports – the packages in
and out.persistence
.
Since ports can be implemented by different types of adapters, we create appropriate sub-packages for them:
in.rest
for the REST adapters andout.persistence.inmemory
for the in-memory persistence solution.
As mentioned at the beginning of the tutorial, we will later extend the application with a MySQL persistence solution – we will implement this in the out.persistence.jpa
package (shown in brackets below).
eu.happycoders.shop.adapter
├ in
│ └ rest
└ out.persistence
├ inmemory
└ (jpa)
Bootstrap
We put the bootstrap logic in the package eu.happycoders.shop.bootstrap
.
eu.happycoders.shop.bootstrap
Hexagonal Architecture – Folder Structure
The module and package structure results in the following folder structure for our sample application (you don’t need to create these directories manually; we’ll let our IDE do that for us later):
project
├ bootstrap
│ └ src
│ ├ main
│ │ └ java
│ │ └ eu/happycoders/shop
│ │ └ bootstrap
│ └ test
│ └ ...
├ adapter
│ └ src
│ ├ main
│ │ └ java
│ │ └ eu/happycoders/shop
│ │ └ adapter
│ │ ├ in
│ │ │ └ rest
│ │ └ out
│ │ └ persistence
│ │ ├ inmemory
│ │ └ (jpa)
│ └ test
│ └ ...
├ application
│ └ src
│ ├ main
│ │ └ java
│ │ └ eu/happycoders/shop
│ │ └ application
│ │ └ port
│ │ ├ in
│ │ └ out
│ │ └ persistence
│ └ test
│ └ ...
└ model
└ src
├ main
│ └ java
│ └ eu/happycoders/shop
│ └ model
└ test
└ ...
Code language: plaintext (plaintext)
And now, enough of the theory – let’s start with the practice!
Let’s Go ... Setting up the Project
I will show you below how to set up the project using IntelliJ IDEA. If you have another favorite IDE, you will surely know where to find the corresponding functions there.
We first create a new project via New Project in the Welcome Screen or via File→New→Project in the menu. We select Maven as the build system. I am using the current Java version 20, but for this tutorial, Java 16 will do as well (an older version will not work because we will use records).
You can deactivate the checkbox “Add sample code”; otherwise, IntelliJ will create a Main
class. But that wouldn’t be too bad either; you could just delete it afterward.
At the bottom, you should open the “Advanced Settings” and enter a GroupId and ArtifactId. You can enter eu.happycoders.store and hexagonal-architecture-java for this tutorial.
After the project is created, we create the four modules. To do this, you can right-click on the project and then select New→Module:
In the following dialog box, enter “model” as the module name. The ArtifactId at the bottom should automatically change to “model” as well.
Repeat this for the application, adapter, and bootstrap modules. After that, the project should look like this:
What bothers me is that IntelliJ repeats the <properties>
block in the pom.xml files of each module, although it is already defined in the parent POM and thus inherited by the modules. We will, therefore, remove the <properties>
block from all modules.
We can now open a terminal window and execute mvn clean compile
to ensure we did not make a mistake.
After the basic framework is in place, we can implement the domain model.
Implementing the Domain Model
Before we start with the implementation, we need to think about the modeling of the domain. I’ll show you below how I go about this using tactical design, a discipline of domain-driven design.
Modeling the Domain
You probably remember from the first part: we don’t want to model database-driven, i.e., we don’t want to first think about how to store shopping carts and products in a database. Instead, we start with the planning of an object model.
I usually start with very rough planning. We obviously need a Product
and a Cart
class. Since we can add a certain quantity of a product to the shopping cart, we need a class in between that stores a Product
and a quantity: a CartLineItem
.
Here you can see the first rough planning as a UML diagram:
A Cart
is a composition of CartLineItem
s (composition means that a CartLineItem
could not exist without a Cart
). A CartLineItem
, in turn, stores a quantity
and a reference to a Product
.
Cart
and Product
are entities, i.e., objects with an identity that we can store and load via a repository. Each customer should have exactly one shopping cart, so I use a CustomerId
to identify the cart. We identify products via a ProductId
.
CustomerId
and ProductId
are value objects in domain-driven design terminology. Here you can see the class diagram extended by the two IDs:
To display a product we need its name, description, and price. We model the price as a value object named Money
, which contains an amount and a currency:
Next, we need a method to add a product to the cart: Cart.addProduct(…)
. If we add a product multiple times, we need to increase the quantity of an existing entry: CartLineItem.increaseQuantityBy(…)
.
To determine whether a product is available at all, we also need the number of products in stock: Product.itemsInStock
:
Finally, we want to display the total price for a shopping cart: method subTotal()
in Cart
and CartLineItem
– and the total number of products: method Cart.numberOfItems()
:
In the next section, I’ll show you how to implement the model classes in Java.
In an actual project, I don’t do modeling and implementation in two separate steps. I also don’t generate such detailed class diagrams but usually just sketch a first draft on paper. For this tutorial, it seemed clearer to me to present modeling and implementation in two steps.
Implementing the Domain Classes
For clarity, I group the classes into four sub-packages in the model module under the package eu.happycoders.shop.model
(in the screenshot, you can also see the previously unmentioned class NotEnoughItemsInStockException
– I will talk about this shortly):
Below I show you the code of the production classes and an example unit test. In fact, I took a test-driven approach and created tests for each class in advance. You can find this in the model/src/test/java directory in the GitHub repository.
CustomerId
Let’s start with the CustomerId (the link is to the class in the GitHub repository) – this is implemented as a record and represents a wrapper around an int
value:
package eu.happycoders.shop.model.customer;
public record CustomerId(int value) {
public CustomerId {
if (value < 1) {
throw new IllegalArgumentException("'value' must be a positive integer");
}
}
}
Code language: Java (java)
Why do we need such a wrapper? Can’t we store the customer number directly as an int
value in Cart
? We could – but that would be a code smell called “primitive obsession.” The wrapper has two advantages:
- We can make sure that the value is valid. In the example, the customer number must be a positive number.
- We can pass the customer number to methods in a type-safe way. If the customer number were an
int
primitive, we could accidentally swap parameters in a method with multipleint
parameters.
ProductId
The ProductId class is a wrapper around a string and provides the static method randomProductId()
to generate a random product ID.
I won’t include the imports in the following listings; modern IDEs are able to add them automatically.
package eu.happycoders.shop.model.product;
// ... imports ...
public record ProductId(String value) {
private static final String ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
private static final int LENGTH_OF_NEW_PRODUCT_IDS = 8;
public ProductId {
Objects.requireNonNull(value, "'value' must not be null");
if (value.isEmpty()) {
throw new IllegalArgumentException("'value' must not be empty");
}
}
public static ProductId randomProductId() {
ThreadLocalRandom random = ThreadLocalRandom.current();
char[] chars = new char[LENGTH_OF_NEW_PRODUCT_IDS];
for (int i = 0; i < LENGTH_OF_NEW_PRODUCT_IDS; i++) {
chars[i] = ALPHABET.charAt(random.nextInt(ALPHABET.length()));
}
return new ProductId(new String(chars));
}
}
Code language: Java (java)
We will use the randomProductId()
method in unit tests and later to generate demo products.
Money
The Money class is a record with two fields – currency
and amount
– and a static factory method and methods to add two money amounts and multiply a money amount:
package eu.happycoders.shop.model.money;
// ... imports ...
public record Money(Currency currency, BigDecimal amount) {
public Money {
Objects.requireNonNull(currency, "'currency' must not be null");
Objects.requireNonNull(amount, "'amount' must not be null");
if (amount.scale() > currency.getDefaultFractionDigits()) {
throw new IllegalArgumentException(
("Scale of amount %s is greater "
+ "than the number of fraction digits used with currency %s")
.formatted(amount, currency));
}
}
public static Money of(Currency currency, int mayor, int minor) {
int scale = currency.getDefaultFractionDigits();
return new Money(
currency, BigDecimal.valueOf(mayor).add(BigDecimal.valueOf(minor, scale)));
}
public Money add(Money augend) {
if (!this.currency.equals(augend.currency())) {
throw new IllegalArgumentException(
"Currency %s of augend does not match this money's currency %s"
.formatted(augend.currency(), this.currency));
}
return new Money(currency, amount.add(augend.amount()));
}
public Money multiply(int multiplicand) {
return new Money(currency, amount.multiply(BigDecimal.valueOf(multiplicand)));
}
}
Code language: Java (java)
In the main branch of the GitHub repository, you’ll see two more methods, ofMinor(…)
and amountInMinorUnit()
. We won’t need these until the next part of the series (for storing a money amount in the database).
Product
A product is mutable: the seller should be able to change the description and the price, for example. Therefore, we cannot implement it as a record.
To save us from writing getters and setters, we can use Lombok annotations. To do this, you must first add the appropriate dependency to the pom.xml file in the root directory of the project:
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
<scope>provided</scope>
</dependency>
</dependencies>
Code language: HTML, XML (xml)
After that, we can implement the Product class as follows:
package eu.happycoders.shop.model.product;
// ... imports ...
@Data
@Accessors(fluent = true)
@AllArgsConstructor
public class Product {
private final ProductId id;
private String name;
private String description;
private Money price;
private int itemsInStock;
}
Code language: Java (java)
CartLineItem
We continue with the CartLineItem. Since the quantity of a product in the cart can change, this is also not a record but a regular class with Lombok annotations.
However, here I use @Getter
instead of the @Data
annotation. I don’t want setters here because the only field that can change is quantity
. And this should only be able to be changed via the method increaseQuantityBy(…)
(encapsulation!).
package eu.happycoders.shop.model.cart;
// ... imports ...
@Getter
@Accessors(fluent = true)
@RequiredArgsConstructor
@AllArgsConstructor
public class CartLineItem {
private final Product product;
private int quantity;
public void increaseQuantityBy(int augend, int itemsInStock)
throws NotEnoughItemsInStockException {
if (augend < 1) {
throw new IllegalArgumentException("You must add at least one item");
}
int newQuantity = quantity + augend;
if (itemsInStock < newQuantity) {
throw new NotEnoughItemsInStockException(
("Product %s has less items in stock (%d) "
+ "than the requested total quantity (%d)")
.formatted(product.id(), product.itemsInStock(), newQuantity),
product.itemsInStock());
}
this.quantity = newQuantity;
}
public Money subTotal() {
return product.price().multiply(quantity);
}
}
Code language: Java (java)
Here we have implemented business logic, namely increasing the quantity of a product in the shopping cart, within a model class. This is called a “Rich Domain Model” – in contrast to an “Anemic Domain Model,” where the model classes contain only fields, getters, and setters, and the business logic is implemented in service classes.
NotEnoughItemsInStockException
The increaseQuantityBy(…)
method also checks the pre- and postconditions. If the precondition is not met (the number to be added is less than one), the method throws an IllegalArgumentException
(because it should not be possible to call the method with a too-low number in the first place).
If the postcondition is unmet (the shopping cart must not contain more than the available number of items), the method throws a NotEnoughItemsInStockException. This exception includes the available quantity as a parameter so that we can display a corresponding message in the frontend:
package eu.happycoders.shop.model.cart;
public class NotEnoughItemsInStockException extends Exception {
private final int itemsInStock;
public NotEnoughItemsInStockException(String message, int itemsInStock) {
super(message);
this.itemsInStock = itemsInStock;
}
public int itemsInStock() {
return itemsInStock;
}
}
Code language: Java (java)
Cart
Let’s get to the core of the model, the Cart class. It stores the shopping cart entries in a map from ProductId
to CartLineItem
, so when we add a product – in the addProduct(…)
method – we can check if it is already in the cart.
The method lineItems()
returns a copy of the values in the map so that the lineItems
data structure cannot be modified outside the class.
package eu.happycoders.shop.model.cart;
// ... imports ...
@Accessors(fluent = true)
@RequiredArgsConstructor
public class Cart {
@Getter private final CustomerId id;
private final Map<ProductId, CartLineItem> lineItems = new LinkedHashMap<>();
public void addProduct(Product product, int quantity)
throws NotEnoughItemsInStockException {
lineItems
.computeIfAbsent(product.id(), ignored -> new CartLineItem(product))
.increaseQuantityBy(quantity, product.itemsInStock());
}
public List<CartLineItem> lineItems() {
return List.copyOf(lineItems.values());
}
public int numberOfItems() {
return lineItems.values().stream().mapToInt(CartLineItem::quantity).sum();
}
public Money subTotal() {
return lineItems.values().stream()
.map(CartLineItem::subTotal)
.reduce(Money::add)
.orElse(null);
}
}
Code language: Java (java)
This completes the implementation of our data model. In the following section, I will show you another example unit test.
CartTest
As announced, I’ll show you an example unit test here. For this, we need to add dependencies to JUnit and AssertJ in the pom.xml in the root directory in advance (we will need these dependencies in all modules):
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.24.2</version>
<scope>test</scope>
</dependency>
Code language: HTML, XML (xml)
In fact, I did that in advance and wrote the tests along with the entity classes. Since this article focuses not on TDD (Test-driven development), I show the tests only now. Here you can see a screenshot of all model tests:
We test the model class Cart
described in the previous section with the CartTest class (I include two static import statements here, which IntelliJ can’t resolve on its own):
package eu.happycoders.shop.model.cart;
import static eu.happycoders.shop.model.money.TestMoneyFactory.euros;
import static org.assertj.core.api.Assertions.assertThat;
// ... more imports ...
class CartTest {
@Test
void givenEmptyCart_addTwoProducts_numberOfItemsAndSubTotalIsCalculatedCorrectly()
throws NotEnoughItemsInStockException {
Cart cart = TestCartFactory.emptyCartForRandomCustomer();
Product product1 = TestProductFactory.createTestProduct(euros(12, 99));
Product product2 = TestProductFactory.createTestProduct(euros(5, 97));
cart.addProduct(product1, 3);
cart.addProduct(product2, 5);
assertThat(cart.numberOfItems()).isEqualTo(8);
assertThat(cart.subTotal()).isEqualTo(euros(68, 82));
}
// more tests
}
Code language: Java (java)
The test generates an empty shopping cart via TestCartFactory and two products at €12.99 and €5.97 via TestProductFactory and TestMoneyFactory, then adds product 1 to the cart three times and product 2 five times and finally verifies that the cart contains a total of eight products for a total price of €68.82.
This is just one test of many. You can find many more model tests in the GitHub repository.
If you have followed the tutorial up to this point and copied the tests from the GitHub repository, you can now execute them in a terminal using mvn clean test
to verify if all the tests succeed.
Implementing the Application Hexagon
In the application hexagon, we implement ports and domain services for the following use cases:
- Searching for products
- Adding a product to the shopping cart
- Retrieving the shopping cart with the products, their respective quantity, and the total price
- Emptying the shopping cart
The single-responsibility principle states that there should be only one reason to change a class. Accordingly, I will create a separate primary port and service class for each use case. This won’t make much difference in the sample application, but in real-world applications, I regularly encounter cluttered and hard-to-maintain service classes with several thousand lines of code. By creating one use case per port we prevent this from the very beginning.
I give the primary ports class names ending in UseCase
.
The secondary ports are used to persist entities, so they correspond to repositories in domain-driven design terminology. Since DDD repositories are used for both storing and loading entities and generally do not grow large, I will not subdivide them further.
The following UML class diagram shows all ports (primary at the top, secondary at the bottom) and services (in the middle row) of the application hexagon. The primary ports and services are arranged from left to right in the same order as the use cases in the bulleted list at the beginning of this section.
For more clarity, I have created two sub-packages, product
and cart
, under each of the packages for the primary ports and services. The following screenshot shows the packages and classes of the application module in the IDE:
Below I will show you the implementations of the ports and services, use case by use case.
But first, we must ensure that the application module can access the model module. We do that with the following entry in the pom.xml file of the application module:
<dependencies>
<dependency>
<groupId>eu.happycoders.shop</groupId>
<artifactId>model</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
Code language: HTML, XML (xml)
And now, it’s time for the use cases...
Use Case 1: Searching for Products
Use case description: The customer should be able to enter a text in a search field. The search text should be at least two characters long. The search should return all products where the search text appears in the title or the description.
We start with our first primary port, FindProductsUseCase:
package eu.happycoders.shop.application.port.in.product;
// ... imports ...
public interface FindProductsUseCase {
List<Product> findByNameOrDescription(String query);
}
Code language: Java (java)
In fact, that’s all there is to it. Ports do not get more complicated than this.
We implement the port in the FindProductsService class. The service must access the secondary port ProductRepository
(which you will find below) to search for the products in the deployed persistence solution.
We pass the port – or rather its implementation – to the service’s constructor (“constructor dependency injection”) later in the bootstrap module.
package eu.happycoders.shop.application.service.product;
// ... imports ...
public class FindProductsService implements FindProductsUseCase {
private final ProductRepository productRepository;
public FindProductsService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Override
public List<Product> findByNameOrDescription(String query) {
Objects.requireNonNull(query, "'query' must not be null");
if (query.length() < 2) {
throw new IllegalArgumentException("'query' must be at least two characters long");
}
return productRepository.findByNameOrDescription(query);
}
}
Code language: Java (java)
And here is the injected ProductRepository (the interface in the GitHub repository already has two more methods – one for the following use case and one for integration tests):
package eu.happycoders.shop.application.port.out.persistence;
// ... imports ...
public interface ProductRepository {
List<Product> findByNameOrDescription(String query);
}
Code language: Java (java)
This completes the implementation of the use case from the application hexagon’s perspective. How the products are searched for in the persistence solution is later up to the adapter that will implement the ProductRepository
interface (i.e., the secondary port).
Use Case 2: Adding a Product to the Shopping Cart
Use case description: The customer should be able to add a product in a certain quantity to their shopping cart.
Here is the primary port, AddToCartUseCase:
package eu.happycoders.shop.application.port.in.cart;
// ... imports ...
public interface AddToCartUseCase {
Cart addToCart(CustomerId customerId, ProductId productId, int quantity)
throws ProductNotFoundException, NotEnoughItemsInStockException;
}
Code language: Java (java)
The only method of this interface declares two exceptions. We have already defined the NotEnoughItemsInStockException
in the model module in the previous chapter.
The second exception, ProductNotFoundException, does not come from the model but is defined in the application hexagon, in the same package as the port. This is because whether a product exists is determined when the service accesses the repository (i.e., in the application, not the model).
The source code of the exception is trivial:
package eu.happycoders.shop.application.port.in.cart;
public class ProductNotFoundException extends Exception {}
Code language: Java (java)
The following is the source code of the use case implementation, AddToCartService.
The addToCart(…)
method first validates the input parameters, loads the product and shopping cart from repositories (or creates a new shopping cart), adds the product to the shopping cart in the desired quantity, and saves the shopping cart again:
package eu.happycoders.shop.application.service.cart;
// ... imports ...
public class AddToCartService implements AddToCartUseCase {
private final CartRepository cartRepository;
private final ProductRepository productRepository;
public AddToCartService(
CartRepository cartRepository, ProductRepository productRepository) {
this.cartRepository = cartRepository;
this.productRepository = productRepository;
}
@Override
public Cart addToCart(CustomerId customerId, ProductId productId, int quantity)
throws ProductNotFoundException, NotEnoughItemsInStockException {
Objects.requireNonNull(customerId, "'customerId' must not be null");
Objects.requireNonNull(productId, "'productId' must not be null");
if (quantity < 1) {
throw new IllegalArgumentException("'quantity' must be greater than 0");
}
Product product =
productRepository.findById(productId).orElseThrow(ProductNotFoundException::new);
Cart cart =
cartRepository
.findByCustomerId(customerId)
.orElseGet(() -> new Cart(customerId));
cart.addProduct(product, quantity);
cartRepository.save(cart);
return cart;
}
}
Code language: Java (java)
In this service, we inject two repositories (secondary ports):
First, the already existing ProductRepository. We extend this with the method findById(…)
to load a concrete product – and also with the method save(…)
, which we will need later to create test products:
package eu.happycoders.shop.application.port.out.persistence;
// ... imports ...
public interface ProductRepository {
void save(Product product);
Optional<Product> findById(ProductId productId);
List<Product> findByNameOrDescription(String query);
}
Code language: Java (java)
And second, the new CartRepository to store and load the cart (in the GitHub repository, you can already find a third method, deleteById(…)
, which we will add for the fourth use case):
package eu.happycoders.shop.application.port.out.persistence;
// ... imports ...
public interface CartRepository {
void save(Cart cart);
Optional<Cart> findByCustomerId(CustomerId customerId);
}
Code language: Java (java)
With this, the second use case is also done; let’s move on to the third…
Use Case 3: Retrieving the Shopping Cart
Use case description: The customer should be able to retrieve their shopping cart, including the products, their respective quantity, the total number of products, and the total price.
We start again with the primary port, GetCartUseCase:
package eu.happycoders.shop.application.port.in.cart;
// ... imports ...
public interface GetCartUseCase {
Cart getCart(CustomerId customerId);
}
Code language: Java (java)
The implementation, GetCartService, forwards the call to the repository method findByCustomerId(…)
already created for the previous use case. If no shopping cart exists, a new one will be created, so this method will never return null
.
package eu.happycoders.shop.application.service.cart;
// ... imports ...
public class GetCartService implements GetCartUseCase {
private final CartRepository cartRepository;
public GetCartService(CartRepository cartRepository) {
this.cartRepository = cartRepository;
}
@Override
public Cart getCart(CustomerId customerId) {
Objects.requireNonNull(customerId, "'customerId' must not be null");
return cartRepository
.findByCustomerId(customerId)
.orElseGet(() -> new Cart(customerId));
}
}
Code language: Java (java)
Note that we have already implemented the calculation of the total number of products and the total price in the Cart model class.
Let’s move on to the fourth and final use case...
Use Case 4: Emptying the Shopping Cart
Use case description: The customer should be able to empty their shopping cart.
First again, the primary port, EmptyCartUseCase:
package eu.happycoders.shop.application.port.in.cart;
// ... imports ...
public interface EmptyCartUseCase {
void emptyCart(CustomerId customerId);
}
Code language: Java (java)
And its implementation, EmptyCartService. We empty a shopping cart by deleting it. When the user queries it again, the GetCartUseCase
described in the previous section simply returns a new Cart
object.
package eu.happycoders.shop.application.service.cart;
// ... imports ...
public class EmptyCartService implements EmptyCartUseCase {
private final CartRepository cartRepository;
public EmptyCartService(CartRepository cartRepository) {
this.cartRepository = cartRepository;
}
@Override
public void emptyCart(CustomerId customerId) {
Objects.requireNonNull(customerId, "'customerId' must not be null");
cartRepository.deleteById(customerId);
}
}
Code language: Java (java)
For this use case, we add one more method, deleteById(…)
, to the secondary port CartRepository; it thus looks like the following:
package eu.happycoders.shop.application.port.out.persistence;
// ... imports ...
public interface CartRepository {
void save(Cart cart);
Optional<Cart> findByCustomerId(CustomerId customerId);
void deleteById(CustomerId customerId);
}
Code language: Java (java)
This completes the implementation of the application hexagon – and thus also the business logic.
A note on security: In a real application, we would, of course, not simply pass the customer number as a parameter but require the customer to authenticate.
Unit Tests for Domain Services
I also took a test-driven approach to the domain services in the application module. However, as announced, I will only print an example test here.
The screenshot from my IDE gives an overview of all domain service tests, as you will find them in the GitHub repository:
We will mock the ports with which the services communicate. For this, we need another dependency to Mockito in the pom.xml of the project directory:
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.5.0</version>
<scope>test</scope>
</dependency>
Code language: HTML, XML (xml)
The model module contains some factory classes, e.g., TestProductFactory and TestMoneyFactory, to create test products. However, even though we have defined a dependency from the application module to the model module, we need help accessing the test classes of the model module from the application module.
First, we must export the test classes from the model module by creating a so-called “Attached Test JAR.” We do this with the following entry in the pom.xml of the model module:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<executions>
<execution>
<goals>
<goal>test-jar</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
Code language: HTML, XML (xml)
Next, we must import the “Attached Test JAR” into the application module. To do this, we add the following dependency to the pom.xml of the application 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>
Code language: HTML, XML (xml)
With this, everything is prepared for the actual tests.
Test for Use Case 2: Adding a Product to the Shopping Cart
In the domain class tests, I showed you how we can test the Cart.addProduct(…)
method. In keeping with this, I’ll show you now how to test AddToCartService.addToCart(…)
.
Below you will find an excerpt of the AddToCartServiceTest class. Again, I have only included those static imports that my IDE cannot automatically detect.
In the first code block, we create a test customer number and two test products using the test factories imported from the model module. We store the customer number and test products in static variables to be able to use them in all tests.
In the second code block, we use Mockito to create test doubles for the secondary ports, cartRepository
and productRepository
. We inject these via the constructor into the AddToCartService
. Test doubles and service are not static, as they should be freshly initialized for each test (JUnit creates a new instance of the test class for each test).
In the initTestDoubles()
method annotated with @BeforeEach
, we define via Mockito.when(…)
that the findById(…)
method of the secondary port productRepository
returns the test products.
package eu.happycoders.shop.application.service.cart;
import static eu.happycoders.shop.model.money.TestMoneyFactory.euros;
import static eu.happycoders.shop.model.product.TestProductFactory.createTestProduct;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
// ... more imports ...
class AddToCartServiceTest {
private static final CustomerId TEST_CUSTOMER_ID = new CustomerId(61157);
private static final Product TEST_PRODUCT_1 = createTestProduct(euros(19, 99));
private static final Product TEST_PRODUCT_2 = createTestProduct(euros(25, 99));
private final CartRepository cartRepository = mock(CartRepository.class);
private final ProductRepository productRepository = mock(ProductRepository.class);
private final AddToCartService addToCartService =
new AddToCartService(cartRepository, productRepository);
@BeforeEach
void initTestDoubles() {
when(productRepository.findById(TEST_PRODUCT_1.id()))
.thenReturn(Optional.of(TEST_PRODUCT_1));
when(productRepository.findById(TEST_PRODUCT_2.id()))
.thenReturn(Optional.of(TEST_PRODUCT_2));
}
@Test
void givenExistingCart_addToCart_cartWithAddedProductIsSavedAndReturned()
throws NotEnoughItemsInStockException, ProductNotFoundException {
// Arrange
Cart persistedCart = new Cart(TEST_CUSTOMER_ID);
persistedCart.addProduct(TEST_PRODUCT_1, 1);
when(cartRepository.findByCustomerId(TEST_CUSTOMER_ID))
.thenReturn(Optional.of(persistedCart));
// Act
Cart cart = addToCartService.addToCart(TEST_CUSTOMER_ID, TEST_PRODUCT_2.id(), 3);
// Assert
verify(cartRepository).save(cart);
assertThat(cart.lineItems()).hasSize(2);
assertThat(cart.lineItems().get(0).product()).isEqualTo(TEST_PRODUCT_1);
assertThat(cart.lineItems().get(0).quantity()).isEqualTo(1);
assertThat(cart.lineItems().get(1).product()).isEqualTo(TEST_PRODUCT_2);
assertThat(cart.lineItems().get(1).quantity()).isEqualTo(3);
}
// more tests
}
Code language: Java (java)
In the actual test method, we first create a shopping cart and add the first product to it. Using Mockito.when(…)
, we specify that cartRepository.findByCustomerId(TEST_CUSTOMER_ID)
should return exactly this shopping cart.
Then we call the method under test, AddToCartService.addToCart(…)
, to add a second product to the shopping cart. This method returns the updated shopping cart.
Finally, we check, firstly via Mockito.verify(…)
, that the updated shopping cart has been persisted via CartRepository.save(…)
, and secondly, that the shopping cart contains both products in the expected quantity.
This test impressively shows how we can test the business logic of the application without any REST controller and database at all.
You can find all further tests of the application module in the GitHub repository.
Implementing Adapters
Our application is not yet ready to run. We still need adapters:
- REST adapters to call the use cases, and
- Persistence adapters to store shopping carts and products.
We create a REST adapter (top row in the following class diagram) for each of the primary ports (use cases) and an in-memory persistence adapter (bottom row) for each of the secondary ports (repositories):
Technically, this one-to-one mapping is not required. We could also create, for example, a cart REST adapter, a product REST adapter, and a single persistence adapter:
However, this would violate the single-responsibility principle. With two or three implemented methods per class, this is still manageable. But I’ve seen it quickly turn into ten or even a hundred methods per class.
The following screenshot shows all packages of the adapter module in the IDE. As you can see, there are several more classes than those shown in the class diagram. I will describe them all in the following sections.
Let’s start with the implementation of the REST adapters...
Implementing REST Adapters
So far, we have written pure Java code. If we disregard Lombok (which simplified the work but was not absolutely necessary) and the test libraries, we have not yet used any additional library. This will change now because we do not want to implement a REST adapter without help.
We will use the “Jakarta RESTful Web Services” standard (known as “JAX-RS” before Java EE was handed over to the Eclipse Foundation) and, as an implementation of this standard, the widely used RESTEasy from JBoss / Red Hat.
We add the following dependencies to the pom.xml file of the adapter module:
<dependencies>
<dependency>
<groupId>eu.happycoders.shop</groupId>
<artifactId>application</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>jakarta.ws.rs</groupId>
<artifactId>jakarta.ws.rs-api</artifactId>
<version>3.1.0</version>
</dependency>
</dependencies>
Code language: HTML, XML (xml)
Note that we only need a dependency on the “Jakarta RESTful Web Services” API here – none on the RESTEasy implementation of this API! We will need that later for integration tests and in the bootstrap module to launch the application.
REST Adapter 1: Searching for Products
We implement the REST adapter for the product search in the FindProductsController. As this controller must call the FindProductsUseCase
(i.e., the primary port), we inject it via its constructor.
package eu.happycoders.shop.adapter.in.rest.product;
import static eu.happycoders.shop.adapter.in.rest.common.ControllerCommons.clientErrorException;
// ... more imports ...
@Path("/products")
@Produces(MediaType.APPLICATION_JSON)
public class FindProductsController {
private final FindProductsUseCase findProductsUseCase;
public FindProductsController(FindProductsUseCase findProductsUseCase) {
this.findProductsUseCase = findProductsUseCase;
}
@GET
public List<ProductInListWebModel> findProducts(@QueryParam("query") String query) {
if (query == null) {
throw clientErrorException(Response.Status.BAD_REQUEST, "Missing 'query'");
}
List<Product> products;
try {
products = findProductsUseCase.findByNameOrDescription(query);
} catch (IllegalArgumentException e) {
throw clientErrorException(Response.Status.BAD_REQUEST, "Invalid 'query'");
}
return products.stream().map(ProductInListWebModel::fromDomainModel).toList();
}
}
Code language: Java (java)
In the findProducts(…)
method, we load the products using the findByNameOrDescription(…)
method of the FindProductsUseCase
port. This method throws an IllegalArgumentException
if the search term is too short.
We catch the exception and instead throw a ClientErrorException
, which we create using the helper method clientErrorException(…)
implemented in ControllerCommons:
package eu.happycoders.shop.adapter.in.rest.common;
// ... imports ...
public final class ControllerCommons {
private ControllerCommons() {}
public static ClientErrorException clientErrorException(
Response.Status status, String message) {
return new ClientErrorException(errorResponse(status, message));
}
public static Response errorResponse(Response.Status status, String message) {
ErrorEntity errorEntity = new ErrorEntity(status.getStatusCode(), message);
return Response.status(status).entity(errorEntity).build();
}
}
Code language: Java (java)
The ClientErrorException
is defined by the “Jakarta RESTful Web Services” API. When a controller method throws this exception, the controller returns an HTTP error code, and the ErrorEntity passed to the exception as a JSON string.
ErrorEntity
is a simple record:
package eu.happycoders.shop.adapter.in.rest.common;
public record ErrorEntity(int httpStatus, String errorMessage) {}
Code language: Java (java)
The error message would later look like this in case of an invalid call via curl
, for example:
$ curl http://localhost:8081/products?query=x -i
HTTP/1.1 400 Bad Request
[...]
{"httpStatus":400,"errorMessage":"Invalid 'query'"}
Code language: Shell Session (shell)
Let’s go back to the findProducts(…)
method in FindProductsController
. If no IllegalArgumentException
was thrown, the following last line of the method is executed:
return products.stream().map(ProductInListWebModel::fromDomainModel).toList();
Code language: Java (java)
The FindProductsUseCase.findByNameOrDescription(…)
method returned a list of Product
entities. These are defined in the model module and contain all the information about a product, including some that we don’t want to show to the user.
We, therefore, map (see section “Mapping” in the first part of the series) the domain model class Product
to an adapter-specific model class ProductInListWebModel. This is implemented as a record and contains the static factory method fromDomainModel(…)
, which we passed above as a method reference to the Stream.map(…)
method.
package eu.happycoders.shop.adapter.in.rest.product;
// ... imports ...
public record ProductInListWebModel(
String id, String name, Money price, int itemsInStock) {
public static ProductInListWebModel fromDomainModel(Product product) {
return new ProductInListWebModel(
product.id().value(), product.name(), product.price(), product.itemsInStock());
}
}
Code language: Java (java)
This completes the implementation of our controller. The rest will be done later by RESTEasy and the Undertow web server. We will be able to access the controller via the following URL when the application is ready:
http://localhost:8081/products/?query=monitor
Code language: plaintext (plaintext)
... and then received the following response:
[
{
"id": "K3SR7PBX",
"name": "27-Inch Curved Computer Monitor",
"price": {
"currency": "EUR",
"amount": 159.99
},
"itemsInStock": 24081
},
{
"id": "Q3W43CNC",
"name": "Dual Monitor Desk Mount",
"price": {
"currency": "EUR",
"amount": 119.9
},
"itemsInStock": 1079
}
]
Code language: JSON / JSON with Comments (json)
Our first REST adapter is ready.
REST Adapter 2: Adding a Product to the Shopping Cart
Following the same pattern, we create the REST adapter to add a product to the cart (AddToCartController class):
package eu.happycoders.shop.adapter.in.rest.cart;
import static eu.happycoders.shop.adapter.in.rest.common.ControllerCommons.clientErrorException;
import static eu.happycoders.shop.adapter.in.rest.common.CustomerIdParser.parseCustomerId;
import static eu.happycoders.shop.adapter.in.rest.common.ProductIdParser.parseProductId;
// ... more imports ...
@Path("/carts")
@Produces(MediaType.APPLICATION_JSON)
public class AddToCartController {
private final AddToCartUseCase addToCartUseCase;
public AddToCartController(AddToCartUseCase addToCartUseCase) {
this.addToCartUseCase = addToCartUseCase;
}
@POST
@Path("/{customerId}/line-items")
public CartWebModel addLineItem(
@PathParam("customerId") String customerIdString,
@QueryParam("productId") String productIdString,
@QueryParam("quantity") int quantity) {
CustomerId customerId = parseCustomerId(customerIdString);
ProductId productId = parseProductId(productIdString);
try {
Cart cart = addToCartUseCase.addToCart(customerId, productId, quantity);
return CartWebModel.fromDomainModel(cart);
} catch (ProductNotFoundException e) {
throw clientErrorException(
Response.Status.BAD_REQUEST, "The requested product does not exist");
} catch (NotEnoughItemsInStockException e) {
throw clientErrorException(
Response.Status.BAD_REQUEST,
"Only %d items in stock".formatted(e.itemsInStock()));
}
}
}
Code language: Java (java)
We first parse the customer and product number via two helper methods of the CustomerIdParser and ProductIdParser classes (not printed here). Both throw the already-discussed ClientErrorException
, which RESTEasy then converts into a corresponding HTTP error if the input is invalid.
We then call the use case method AddToCartUseCase.addToCart(…)
. This method returns an object of the domain model class Cart
, which we map to the adapter-specific model class CartWebModel via the static factory method CartWebModel.fromDomainModel(…)
:
package eu.happycoders.shop.adapter.in.rest.cart;
// ... imports ...
public record CartWebModel(
List<CartLineItemWebModel> lineItems, int numberOfItems, Money subTotal) {
static CartWebModel fromDomainModel(Cart cart) {
return new CartWebModel(
cart.lineItems().stream().map(CartLineItemWebModel::fromDomainModel).toList(),
cart.numberOfItems(),
cart.subTotal());
}
}
Code language: Java (java)
CartWebModel
is a record that contains a list of CartLineItemWebModel objects in addition to the total number of products and the total price. These are generated via the static factory method CartLineItemWebModel.fromDomainModel(…)
from the CartLineItem
entries of the Cart
model:
package eu.happycoders.shop.adapter.in.rest.cart;
// ... imports ...
public record CartLineItemWebModel(
String productId, String productName, Money price, int quantity) {
public static CartLineItemWebModel fromDomainModel(CartLineItem lineItem) {
Product product = lineItem.product();
return new CartLineItemWebModel(
product.id().value(), product.name(), product.price(), lineItem.quantity());
}
}
Code language: Java (java)
Here we see that the web model can look quite different from the domain model: while CartLineItem
contains a reference to a Product
, CartLineItemWebModel
stores the relevant product data (ID, name, price) directly.
If the addToCartUseCase.addToCart(…)
port invocation throws a ProductNotFoundException
or a NotEnoughItemsInStockException
, we’ll convert the exception to a corresponding ClientErrorException
.
REST Adapter 3: Retrieving the Shopping Cart
The third REST adapter, GetCartController, turns out to be quite simple since we already have all the required blocks:
package eu.happycoders.shop.adapter.in.rest.cart;
import static eu.happycoders.shop.adapter.in.rest.common.CustomerIdParser.parseCustomerId;
// ... more imports ...
@Path("/carts")
@Produces(MediaType.APPLICATION_JSON)
public class GetCartController {
private final GetCartUseCase getCartUseCase;
public GetCartController(GetCartUseCase getCartUseCase) {
this.getCartUseCase = getCartUseCase;
}
@GET
@Path("/{customerId}")
public CartWebModel getCart(@PathParam("customerId") String customerIdString) {
CustomerId customerId = parseCustomerId(customerIdString);
Cart cart = getCartUseCase.getCart(customerId);
return CartWebModel.fromDomainModel(cart);
}
}
Code language: Java (java)
REST Adapter 4: Emptying the Shopping Cart
The same is true for the fourth and final REST adapter, EmptyCartController:
package eu.happycoders.shop.adapter.in.rest.cart;
import static eu.happycoders.shop.adapter.in.rest.common.CustomerIdParser.parseCustomerId;
// ... more imports ...
@Path("/carts")
@Produces(MediaType.APPLICATION_JSON)
public class EmptyCartController {
private final EmptyCartUseCase emptyCartUseCase;
public EmptyCartController(EmptyCartUseCase emptyCartUseCase) {
this.emptyCartUseCase = emptyCartUseCase;
}
@DELETE
@Path("/{customerId}")
public void deleteCart(@PathParam("customerId") String customerIdString) {
CustomerId customerId = parseCustomerId(customerIdString);
emptyCartUseCase.emptyCart(customerId);
}
}
Code language: Java (java)
Since the deleteCart(…)
method has no return value, we don’t need to call a model converter here either, reducing the method to only two lines.
With this, we have implemented all four REST adapters.
Integration Tests for REST Adapters
We could test the REST adapters again using unit tests – for example, instantiating an AddToCartController
with a mocked AddToCartUseCase
and then checking that a call to addLineItem(…)
is forwarded correctly to addToCartUseCase.addToCart(…)
.
However, we would then have to rely on all annotations being correct, that we later configure RESTEasy and Untertow correctly, and that a JSON serialization library is on the classpath. Relying on such tests is extremely risky.
I want to ensure that the adapters really work, and the only way to do that is with an integration test where we start a web server and invoke the controllers over HTTP.
For this, we need a few additional dependencies:
resteasy-undertow
– the Undertow web server combined with the RESTEasy libraryresteasy-jackson2-provider
– a RESTEasy module for converting Java objects to JSON (and vice versa)rest-assured
– a library that allows us to make HTTP calls from integration tests
We do this by making the following entries in the adapter module’s pom.xml file (in case you’re wondering about the reversed order – I like to arrange the dependencies alphabetically):
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<version>5.3.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-jackson2-provider</artifactId>
<version>6.2.5.Final</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-undertow</artifactId>
<version>6.2.5.Final</version>
<scope>test</scope>
</dependency>
Code language: HTML, XML (xml)
As in the application module, we again need a dependency on the “Attached Test JAR” of the model module to be able to use the test factories implemented there, e.g., TestProductFactory.createTestProduct(…)
:
<dependency>
<groupId>eu.happycoders.shop</groupId>
<artifactId>model</artifactId>
<version>${project.version}</version>
<classifier>tests</classifier>
<type>test-jar</type>
<scope>test</scope>
</dependency>
Code language: HTML, XML (xml)
Here is a screenshot from my IDE with an overview of all REST adapter integration tests and associated helper classes:
In the next section, you will find an example REST adapter test. All other tests can be found here in the GitHub repository.
Integration Test for REST Adapter 2: Adding a Product to the Shopping Cart
You may have noticed it in the last screenshot: There is only a single CartsControllerTest
– no separate tests for each use case. This has a practical reason: the Undertow server takes a while to boot up. If we split the test into three tests, testing would take three times as long.
Below is a snippet of the CartsControllerTest class for the “adding a product to the cart” use case.
The first two code blocks should look familiar from the previous test: In the first one, we create test data, and in the second one, the test doubles.
In the init()
method annotated with @BeforeAll
, we configure our server (the TEST_PORT
constant is defined in the HttpTestCommons class), start it, and deploy our test application. To do this, we pass an instance of an anonymous inner class to the deploy(…)
method. This class extends jakarta.ws.rs.core.Application
and overrides its getSingletons()
method, returning a set of all REST controllers to which we inject the test doubles for the ports.
The stop()
method annotated with @AfterAll
shuts down the server.
I describe the actual test method under the source code excerpt.
package eu.happycoders.shop.adapter.in.rest.cart;
import static eu.happycoders.shop.adapter.in.rest.HttpTestCommons.TEST_PORT;
import static eu.happycoders.shop.adapter.in.rest.cart.CartsControllerAssertions.assertThatResponseIsCart;
import static eu.happycoders.shop.model.money.TestMoneyFactory.euros;
import static eu.happycoders.shop.model.product.TestProductFactory.createTestProduct;
import static io.restassured.RestAssured.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
// ... more imports ...
class CartsControllerTest {
private static final CustomerId TEST_CUSTOMER_ID = new CustomerId(61157);
private static final Product TEST_PRODUCT_1 = createTestProduct(euros(19, 99));
private static final AddToCartUseCase addToCartUseCase = mock(AddToCartUseCase.class);
private static final GetCartUseCase getCartUseCase = mock(GetCartUseCase.class);
private static final EmptyCartUseCase emptyCartUseCase = mock(EmptyCartUseCase.class);
private static UndertowJaxrsServer server;
@BeforeAll
static void init() {
server =
new UndertowJaxrsServer()
.setPort(TEST_PORT)
.start()
.deploy(
new Application() {
@Override
public Set<Object> getSingletons() {
return Set.of(
new AddToCartController(addToCartUseCase),
new GetCartController(getCartUseCase),
new EmptyCartController(emptyCartUseCase));
}
});
}
@AfterAll
static void stop() {
server.stop();
}
@Test
void givenSomeTestData_addLineItem_invokesAddToCartUseCaseAndReturnsUpdatedCart()
throws NotEnoughItemsInStockException, ProductNotFoundException {
// Arrange
CustomerId customerId = TEST_CUSTOMER_ID;
ProductId productId = TEST_PRODUCT_1.id();
int quantity = 5;
Cart cart = new Cart(customerId);
cart.addProduct(TEST_PRODUCT_1, quantity);
when(addToCartUseCase.addToCart(customerId, productId, quantity)).thenReturn(cart);
// Act
Response response =
given()
.port(TEST_PORT)
.queryParam("productId", productId.value())
.queryParam("quantity", quantity)
.post("/carts/" + customerId.value() + "/line-items")
.then()
.extract()
.response();
// Assert
assertThatResponseIsCart(response, cart);
}
// more tests
}
Code language: Java (java)
In the test method, we create a shopping cart with one product and define via Mockito.when(…)
that the AddToCartUseCase.addToCart(…)
method should return this shopping cart when called with the corresponding customer number, product ID, and quantity.
After that, we use the REST Assured library to send an actual HTTP call to the server with these very parameters.
Using assertThatResponseIsCart(…)
, we check if the HTTP call returned the expected shopping cart in JSON format. This method is defined in the CartsControllerAssertions class (not printed here).
All other tests of the REST adapters look similar. You can find them here in the GitHub repository.
To finally put our application into operation, the persistence adapters are still missing. We will implement these next.
Implementing the Persistence Adapter With an In-Memory Store
To get an initial feel for the running application, we don’t need to persist the data in a database. We can make it easy for now and keep the data in memory for the time being. Here we take advantage of one of the great benefits of hexagonal architecture: the decision about technical issues, such as the database, can be postponed, and they can be easily replaced later (as you will see).
We start by implementing the secondary port CartRepository
through the InMemoryCartRepository class:
package eu.happycoders.shop.adapter.out.persistence.inmemory;
// ... imports ...
public class InMemoryCartRepository implements CartRepository {
private final Map<CustomerId, Cart> carts = new ConcurrentHashMap<>();
@Override
public void save(Cart cart) {
carts.put(cart.id(), cart);
}
@Override
public Optional<Cart> findByCustomerId(CustomerId customerId) {
return Optional.ofNullable(carts.get(customerId));
}
@Override
public void deleteById(CustomerId customerId) {
carts.remove(customerId);
}
}
Code language: Java (java)
The save(…)
method stores the shopping cart in a Map
, the findByCustomerId(…)
method loads it from the Map
, and the deleteByCustomerId(…)
method deletes the entry from the Map
.
The InMemoryProductRepository is implemented almost as simply. To be able to use the application, we create a few demo products in the repository’s constructor. To do this, we pass each product listed in the DemoProducts class to the safe(…)
method.
package eu.happycoders.shop.adapter.out.persistence.inmemory;
// ... imports ...
public class InMemoryProductRepository implements ProductRepository {
private final Map<ProductId, Product> products = new ConcurrentHashMap<>();
public InMemoryProductRepository() {
createDemoProducts();
}
private void createDemoProducts() {
DemoProducts.DEMO_PRODUCTS.forEach(this::save);
}
@Override
public void save(Product product) {
products.put(product.id(), product);
}
@Override
public Optional<Product> findById(ProductId productId) {
return Optional.ofNullable(products.get(productId));
}
@Override
public List<Product> findByNameOrDescription(String query) {
String queryLowerCase = query.toLowerCase(Locale.ROOT);
return products.values().stream()
.filter(product -> matchesQuery(product, queryLowerCase))
.toList();
}
private boolean matchesQuery(Product product, String query) {
return product.name().toLowerCase(Locale.ROOT).contains(query)
|| product.description().toLowerCase(Locale.ROOT).contains(query);
}
}
Code language: Java (java)
In addition to saving and loading a product, the repository is also responsible for searching for products. We implement this in the findByNameOrDescription(…)
method by iterating over all stored products and checking whether their name or description contains the search term.
In the next part of the article series, I’ll show you how to replace the in-memory adapter with a JPA adapter that stores the data in a MySQL database.
Unit Tests for Persistence Adapters
As long as the persistence adapters do not yet communicate with an external system such as a database, we can work with simple unit tests.
Since we already know that we will implement additional JPA adapters later – and then have to test the same functionality with two different adapters – I write the tests each in an abstract class and extend that by a concrete class that creates the respective adapter to be tested.
That sounds more complicated than it is. The following class diagram should help in understanding:
The abstract class AbstractProductRepositoryTest contains an instance of ProductRepository
, which is instantiated via the abstract method createProductRepository()
. All tests will be executed on this instance.
In the concrete classes InMemoryProductRepositoryTest and, in the next part of the series, JpaProductRepositoryTest
, the method createProductRepository()
is implemented and returns the concrete adapter to be tested, i.e., the InMemoryProductRepository
and the JpaProductRepository
, respectively. The JpaProductRepositoryTest
will also start and stop an actual MySQL database – but more about that in the next part of the series.
With this structure, we can run the same tests for both adapter implementations without having to write all tests twice.
For completeness, here's another screenshot of the test classes in my IDE (without the JPA repository tests):
In the next section, I will show you an example ProductRepository
adapter test. You can find all other tests here in the GitHub repository.
Unit Test for the ProductRepository Adapter
The following code shows a snippet of the AbstractProductRepositoryTest class. In the upper part, you can see the initialization of the repository described in the previous section using the abstract method createProductRepository()
.
The actual test method calls the ProductRepository.findByNameOrDescription(…)
method. It then checks whether the expected products, i.e., the test products matching the search term, are returned.
package eu.happycoders.shop.adapter.out.persistence;
import static org.assertj.core.api.Assertions.assertThat;
// ... more imports ...
public abstract class AbstractProductRepositoryTest<T extends ProductRepository> {
private T productRepository;
@BeforeEach
void initRepository() {
productRepository = createProductRepository();
}
protected abstract T createProductRepository();
@Test
void givenTestProducts_findByNameOrDescription_returnsMatchingProducts() {
String query = "monitor";
List<Product> products = productRepository.findByNameOrDescription(query);
assertThat(products)
.containsExactlyInAnyOrder(
DemoProducts.COMPUTER_MONITOR, DemoProducts.MONITOR_DESK_MOUNT);
}
// more tests
}
Code language: Java (java)
In a real application that does not automatically create demo products, we would either create the test products in the initRepository()
method annotated with @BeforeEach
or directly in the test method (depending on whether we need them for all tests or only for this one test).
The concrete test class, InMemoryProductRepositoryTest, implements only the createProductRepository()
method:
package eu.happycoders.shop.adapter.out.persistence.inmemory;
// ... imports ...
class InMemoryProductRepositoryTest
extends AbstractProductRepositoryTest<InMemoryProductRepository> {
@Override
protected InMemoryProductRepository createProductRepository() {
return new InMemoryProductRepository();
}
}
Code language: Java (java)
You can find all persistence adapter tests here in the GitHub repository.
OK, we have all the components for the application ready: our model, ports and services, and primary and secondary adapters. We can test everything with the following terminal command:
mvn clean test
But how do we start the application now? You’ll find out in the next chapter.
Implementing the Bootstrap Module
We still have to write a bit of code to finally launch the application. But not much, I promise! The following screenshot proves it:
First, we need to define a few dependencies – one to the adapter module and two more to Undertow and the RESTEasy JSON module (both of which you already know from the adapter integration tests).
To do this, enter the following into the bootstrap module’s pom.xml:
<dependencies>
<dependency>
<groupId>eu.happycoders.shop</groupId>
<artifactId>adapter</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-undertow</artifactId>
<version>6.2.5.Final</version>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-jackson2-provider</artifactId>
<version>6.2.5.Final</version>
<scope>runtime</scope>
</dependency>
</dependencies>
Code language: HTML, XML (xml)
Next, as in the REST adapter integration test, we need to extend the Application
class. Instead of an anonymous inner class, we use a regular class this time because we need to initialize and wire more objects than in the tests:
- The starting point is the overwritten
getSingletons()
method. This method first callsinitPersistenceAdapters()
to initialize the in-memory repositories. - Then the four controllers are initialized, and one service per controller.
- The four controllers are then returned in a
Set
; the Undertow web server does the rest.
You can find the code in the RestEasyUndertowShopApplication class in the without-jpa-adapters branch of the GitHub repository. (If you checked out the main branch, this class is already extended with the option to initialize the JPA/MySQL adapters).
package eu.happycoders.shop.bootstrap;
// ... imports ...
public class RestEasyUndertowShopApplication extends Application {
private CartRepository cartRepository;
private ProductRepository productRepository;
@Override
public Set<Object> getSingletons() {
initPersistenceAdapters();
return Set.of(
addToCartController(),
getCartController(),
emptyCartController(),
findProductsController());
}
private void initPersistenceAdapters() {
cartRepository = new InMemoryCartRepository();
productRepository = new InMemoryProductRepository();
}
private AddToCartController addToCartController() {
AddToCartUseCase addToCartUseCase =
new AddToCartService(cartRepository, productRepository);
return new AddToCartController(addToCartUseCase);
}
private GetCartController getCartController() {
GetCartUseCase getCartUseCase = new GetCartService(cartRepository);
return new GetCartController(getCartUseCase);
}
private EmptyCartController emptyCartController() {
EmptyCartUseCase emptyCartUseCase = new EmptyCartService(cartRepository);
return new EmptyCartController(emptyCartUseCase);
}
private FindProductsController findProductsController() {
FindProductsUseCase findProductsUseCase = new FindProductsService(productRepository);
return new FindProductsController(findProductsUseCase);
}
}
Code language: Java (java)
Next, we need a main()
method to start the application and the Undertow server.
This startup code is in the Launcher class (in the GitHub repository, you’ll find two more methods, startOnPort(…)
and stop()
– we’ll need those for the end-to-end tests).
package eu.happycoders.shop.bootstrap;
import org.jboss.resteasy.plugins.server.undertow.UndertowJaxrsServer;
public class Launcher {
private UndertowJaxrsServer server;
public static void main(String[] args) {
new Launcher().startOnDefaultPort();
}
public void startOnDefaultPort() {
server = new UndertowJaxrsServer();
startServer();
}
private void startServer() {
server.start();
server.deploy(RestEasyUndertowShopApplication.class);
}
}
Code language: Java (java)
And that's it – our application is ready!
You can find some more end-to-end tests here in the GitHub repository. These tests use the Launcher
class to launch the application and then, as already shown in the REST adapter tests, send HTTP calls to the REST controllers and verify the response.
The end-to-end tests use REST Assured and some of the helper functions of the adapter tests. Therefore, the adapter module must export the test classes, and the bootstrap module must import them. You can find the corresponding pom.xml entries here, here, and here.
Starting the Application
The easiest way to start the application is directly from your IDE.
When started, you can call the REST endpoints in the terminal with curl
or another tool, like Postman, or directly from IntelliJ (I don’t know to what extent other IDEs support this).
In IntelliJ, open the file sample-requests.http and click on one of the green arrows. In the window at the bottom right, you will then see the controller’s response; here, for example, the search result for the term “monitor” (second example query):
Play around a bit with the requests. Add a few products to the cart and watch the total price increase.
By the way, there are only 55 of the first demo product, “Plastic Sheeting,” in stock, and the sample request in line 14 adds 20 of them to the shopping cart. So if you run this request a third time, you should see the following error message:
Congratulations! You have successfully developed a fully functional hexagonal architecture application.
Enforcing Architectural Boundaries
There is one last chapter. I would still like to show you how to monitor the architectural boundaries and enforce that no one (that is, neither yourself nor anyone else) violates the rules of hexagonal architecture.
We have already made intensive use of the first option:
Maven Modules
By dividing our application into Maven modules and defining dependencies according to the “Dependency Rule,” we have already achieved an important goal: that all source code dependencies point towards the core.
The strict division also forces us to think carefully about which module we create each new class in. That prevents working according to the motto, “I’ll just quickly put the class here and move it to the right place later.”
However, Maven modules are quite coarse-granular, and as we all know, double is better – so I’ll show you another method in the next section.
ArchUnit
The ArchUnit library makes it possible to write tests that fail when defined architecture rules are violated. This could, for example, cause a CI/CD pipeline to terminate.
In addition, we can define more fine-grained rules, e.g., at the package level, to ensure, for example, that adapters only access ports and not the domain services directly.
To use ArchUnit, we first need to add the following dependency to the bootstrap module's pom.xml (the bootstrap module is the appropriate place since we have visibility to all other modules from here):
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>1.1.0</version>
<scope>test</scope>
</dependency>
Code language: HTML, XML (xml)
We can then have the architecture rules verified in the DependencyRuleTest test class. To do this, I wrote the helper method checkNoDependencyFromTo(…)
that checks that no dependencies exist from one package to another and call it in the test method checkDependencyRule()
for all disallowed dependencies:
package eu.happycoders.shop.bootstrap.archunit;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
// ... more imports ...
class DependencyRuleTest {
private static final String ROOT_PACKAGE = "eu.happycoders.shop";
private static final String MODEL_PACKAGE = "model";
private static final String APPLICATION_PACKAGE = "application";
private static final String PORT_PACKAGE = "application.port";
private static final String SERVICE_PACKAGE = "application.service";
private static final String ADAPTER_PACKAGE = "adapter";
private static final String BOOTSTRAP_PACKAGE = "bootstrap";
@Test
void checkDependencyRule() {
String importPackages = ROOT_PACKAGE + "..";
JavaClasses classesToCheck = new ClassFileImporter().importPackages(importPackages);
checkNoDependencyFromTo(MODEL_PACKAGE, APPLICATION_PACKAGE, classesToCheck);
checkNoDependencyFromTo(MODEL_PACKAGE, ADAPTER_PACKAGE, classesToCheck);
checkNoDependencyFromTo(MODEL_PACKAGE, BOOTSTRAP_PACKAGE, classesToCheck);
checkNoDependencyFromTo(APPLICATION_PACKAGE, ADAPTER_PACKAGE, classesToCheck);
checkNoDependencyFromTo(APPLICATION_PACKAGE, BOOTSTRAP_PACKAGE, classesToCheck);
checkNoDependencyFromTo(PORT_PACKAGE, SERVICE_PACKAGE, classesToCheck);
checkNoDependencyFromTo(ADAPTER_PACKAGE, SERVICE_PACKAGE, classesToCheck);
checkNoDependencyFromTo(ADAPTER_PACKAGE, BOOTSTRAP_PACKAGE, classesToCheck);
}
private void checkNoDependencyFromTo(
String fromPackage, String toPackage, JavaClasses classesToCheck) {
noClasses()
.that()
.resideInAPackage(fullyQualified(fromPackage))
.should()
.dependOnClassesThat()
.resideInAPackage(fullyQualified(toPackage))
.check(classesToCheck);
}
private String fullyQualified(String packageName) {
return ROOT_PACKAGE + '.' + packageName + "..";
}
}
Code language: Java (java)
Now, if we insert a disallowed dependency in the code and run the test, we get an error message like the following:
Architecture Violation [Priority: MEDIUM] - Rule 'no classes that reside in a package 'eu.happycoders.store.application.port..' should depend on classes that reside in a package 'eu.happycoders.store.application.service..'' was violated (1 times)
Java modules
Since Java 9, we can define Java platform modules. To do this, we need to put a module-info.java file in each of the Maven modules and define in it which packages a module exports and which it imports.
For the packages of our application, this is simple. However, we would also need to define imports for all third-party dependencies and, in some cases, even for transitive dependencies. In addition, we also need to specify which parts of our code third-party dependencies can access via reflection.
I tested this for interest, but I don’t advise using it because the configuration is very complex (especially in combination with unit and integration tests) and provides little added value over Maven modules and ArchUnit.
Summary and Outlook
You learned in this tutorial how to implement a Java application according to hexagonal architecture and how to enforce compliance with architectural rules using Maven and ArchUnit.
We implemented all the business logic in the first two modules, model and application (together, these form the application core). We isolated them from the technical concerns, such as REST controllers and persistence solutions, through the ports. We added these later in the adapter module.
This isolation also allows us to delay decisions on technical details. While in the traditional layered architecture, we start with planning a database on which the whole application is based, we still have not connected our hexagonal application to a database until now.
We’ll make up for that in the next part of this series – without having to change a single line of code in the application core. We will just need to add new adapters and initialize them in the bootstrap module.
So, should we implement all applications based on hexagonal architecture from now on?
No, because this architecture requires a lot of overhead. Whether this effort is worthwhile depends on the type and size of the application. A simple CRUD application that stores a single resource type in a database and makes it retrievable/modifiable via REST interface can be implemented with three classes (entity, controller, repository) – or even with two if you apply the active record pattern. A hexagonal architecture would be oversized for this.
Think of hexagonal architecture as another tool at your disposal. In this tutorial, you learned how to use this tool.
Does a hexagonal architecture application always have to follow the structure of this tutorial?
Not even that. You can also work without Maven modules or with less or more than in this tutorial. You can arrange or name the packages differently.
I have had a very good experience with this structure, even in large projects. And once the structure stands, it stands. You only have the effort once. We could now add numerous model classes, ports, services, and adapters without changing anything in the module structure, Maven configuration, or ArchUnit tests.
Drop me a comment if you had a good experience with this or another structure or have other remarks about this tutorial!
If you liked the article, I would be pleased about 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.