├── .github
└── workflows
│ └── build.yml
├── .gitignore
├── README.md
├── adapter
├── pom.xml
└── src
│ ├── main
│ ├── java
│ │ └── eu
│ │ │ └── happycoders
│ │ │ └── shop
│ │ │ └── adapter
│ │ │ ├── in
│ │ │ └── rest
│ │ │ │ ├── cart
│ │ │ │ ├── AddToCartController.java
│ │ │ │ ├── CartLineItemWebModel.java
│ │ │ │ ├── CartWebModel.java
│ │ │ │ ├── EmptyCartController.java
│ │ │ │ └── GetCartController.java
│ │ │ │ ├── common
│ │ │ │ ├── ControllerCommons.java
│ │ │ │ ├── CustomerIdParser.java
│ │ │ │ ├── ErrorEntity.java
│ │ │ │ └── ProductIdParser.java
│ │ │ │ └── product
│ │ │ │ ├── FindProductsController.java
│ │ │ │ └── ProductInListWebModel.java
│ │ │ └── out
│ │ │ └── persistence
│ │ │ ├── DemoProducts.java
│ │ │ ├── inmemory
│ │ │ ├── InMemoryCartRepository.java
│ │ │ └── InMemoryProductRepository.java
│ │ │ └── jpa
│ │ │ ├── CartJpaEntity.java
│ │ │ ├── CartLineItemJpaEntity.java
│ │ │ ├── CartMapper.java
│ │ │ ├── EntityManagerFactoryFactory.java
│ │ │ ├── JpaCartRepository.java
│ │ │ ├── JpaProductRepository.java
│ │ │ ├── ProductJpaEntity.java
│ │ │ └── ProductMapper.java
│ └── resources
│ │ └── META-INF
│ │ └── persistence.xml
│ └── test
│ └── java
│ └── eu
│ └── happycoders
│ └── shop
│ └── adapter
│ ├── in
│ └── rest
│ │ ├── HttpTestCommons.java
│ │ ├── cart
│ │ ├── CartsControllerAssertions.java
│ │ └── CartsControllerTest.java
│ │ └── product
│ │ ├── ProductsControllerAssertions.java
│ │ └── ProductsControllerTest.java
│ └── out
│ └── persistence
│ ├── AbstractCartRepositoryTest.java
│ ├── AbstractProductRepositoryTest.java
│ ├── inmemory
│ ├── InMemoryCartRepositoryTest.java
│ └── InMemoryProductRepositoryTest.java
│ └── jpa
│ ├── JpaCartRepositoryTest.java
│ └── JpaProductRepositoryTest.java
├── application
├── pom.xml
└── src
│ ├── main
│ └── java
│ │ └── eu
│ │ └── happycoders
│ │ └── shop
│ │ └── application
│ │ ├── port
│ │ ├── in
│ │ │ ├── cart
│ │ │ │ ├── AddToCartUseCase.java
│ │ │ │ ├── EmptyCartUseCase.java
│ │ │ │ ├── GetCartUseCase.java
│ │ │ │ └── ProductNotFoundException.java
│ │ │ └── product
│ │ │ │ └── FindProductsUseCase.java
│ │ └── out
│ │ │ └── persistence
│ │ │ ├── CartRepository.java
│ │ │ └── ProductRepository.java
│ │ └── service
│ │ ├── cart
│ │ ├── AddToCartService.java
│ │ ├── EmptyCartService.java
│ │ └── GetCartService.java
│ │ └── product
│ │ └── FindProductsService.java
│ └── test
│ └── java
│ └── eu
│ └── happycoders
│ └── shop
│ └── application
│ └── service
│ ├── cart
│ ├── AddToCartServiceTest.java
│ ├── EmptyCartServiceTest.java
│ └── GetCartServiceTest.java
│ └── product
│ └── FindProductsServiceTest.java
├── bootstrap
├── pom.xml
└── src
│ ├── main
│ └── java
│ │ └── eu
│ │ └── happycoders
│ │ └── shop
│ │ └── bootstrap
│ │ ├── Launcher.java
│ │ └── RestEasyUndertowShopApplication.java
│ └── test
│ └── java
│ └── eu
│ └── happycoders
│ └── shop
│ └── bootstrap
│ ├── archunit
│ └── DependencyRuleTest.java
│ └── e2e
│ ├── CartTest.java
│ ├── EndToEndTest.java
│ └── FindProductsTest.java
├── doc
├── architecture-components.plantuml
├── hexagonal-architecture-modules.png
├── persistence-tests.plantuml
├── ports-and-services-and-adapters-alt.plantuml
├── ports-and-services-and-adapters.plantuml
├── ports-and-services.plantuml
├── sample-requests.http
├── shop-model-iteration-1.plantuml
├── shop-model-iteration-2.plantuml
├── shop-model-iteration-3.plantuml
├── shop-model-iteration-4.plantuml
└── shop-model-iteration-5.plantuml
├── google_checks.xml
├── img
├── Java_Versions_PDF_Cheat_Sheet_Mockup_936.png
├── big-o-cheat-sheet-pdf-en-transp_936.png
└── mastering-data-structures-product-mockup-cropped-1600.png
├── model
├── pom.xml
└── src
│ ├── main
│ └── java
│ │ └── eu
│ │ └── happycoders
│ │ └── shop
│ │ └── model
│ │ ├── cart
│ │ ├── Cart.java
│ │ ├── CartLineItem.java
│ │ └── NotEnoughItemsInStockException.java
│ │ ├── customer
│ │ └── CustomerId.java
│ │ ├── money
│ │ └── Money.java
│ │ └── product
│ │ ├── Product.java
│ │ └── ProductId.java
│ └── test
│ └── java
│ └── eu
│ └── happycoders
│ └── shop
│ └── model
│ ├── cart
│ ├── CartTest.java
│ └── TestCartFactory.java
│ ├── customer
│ └── CustomerIdTest.java
│ ├── money
│ ├── MoneyTest.java
│ └── TestMoneyFactory.java
│ └── product
│ └── TestProductFactory.java
├── pmd-ruleset.xml
├── pom.xml
└── spotbugs-exclude.xml
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: build
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | jobs:
10 | build-jvm:
11 | name: Build
12 |
13 | runs-on: ubuntu-latest
14 |
15 | steps:
16 | - name: Checkout code
17 | uses: actions/checkout@v3
18 | with:
19 | fetch-depth: 0 # Fetch all history for all branches and tags (otherwise Sonar will report: "Shallow clone detected, no blame information will be provided.")
20 |
21 | - name: Set up JDK
22 | uses: actions/setup-java@v3
23 | with:
24 | distribution: 'temurin'
25 | java-version: 20
26 |
27 | - name: Cache SonarCloud packages
28 | uses: actions/cache@v3
29 | with:
30 | path: ~/.sonar/cache
31 | key: ${{ runner.os }}-sonar
32 | restore-keys: ${{ runner.os }}-sonar
33 |
34 | - name: Cache Maven packages
35 | uses: actions/cache@v3
36 | with:
37 | path: ~/.m2
38 | key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
39 | restore-keys: ${{ runner.os }}-m2
40 |
41 | - name: Verify code format
42 | run: mvn -B spotless:check
43 |
44 | - name: Compile, test, and verify
45 | run: mvn -B verify -Ptest-coverage,code-analysis
46 |
47 | - name: Analyze code with Sonar
48 | if: ${{ env.SONAR_TOKEN }} # the token is not available in Dependabot-triggered builds
49 | env:
50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
51 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
52 | run: mvn -B package org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -DskipTests
53 | # We're building twice (takes only 2s) as otherwise, Sonar would complain:
54 | # The following dependencies could not be resolved at this point of the build but seem to be part of the reactor:
55 | # o ...
56 | # Try running the build up to the lifecycle phase "package"
57 | #
58 | # If the "package" phase takes much longer in the future, this step and the previous one should be combined into one.
59 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | target/
2 | !.mvn/wrapper/maven-wrapper.jar
3 | !**/src/main/**/target/
4 | !**/src/test/**/target/
5 |
6 | ### IntelliJ IDEA ###
7 | .idea
8 | *.iws
9 | *.iml
10 | *.ipr
11 |
12 | ### Eclipse ###
13 | .apt_generated
14 | .classpath
15 | .factorypath
16 | .project
17 | .settings
18 | .springBeans
19 | .sts4-cache
20 |
21 | ### NetBeans ###
22 | /nbproject/private/
23 | /nbbuild/
24 | /dist/
25 | /nbdist/
26 | /.nb-gradle/
27 | build/
28 | !**/src/main/**/build/
29 | !**/src/test/**/build/
30 |
31 | ### VS Code ###
32 | .vscode/
33 |
34 | ### Mac OS ###
35 | .DS_Store
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Hexagonal Architecture in Java Tutorial
2 |
3 | [](https://github.com/SvenWoltmann/hexagonal-architecture-java/actions/workflows/build.yml)
4 | [](https://sonarcloud.io/dashboard?id=SvenWoltmann_hexagonal-architecture-java)
5 | [](https://sonarcloud.io/dashboard?id=SvenWoltmann_hexagonal-architecture-java)
6 | [](https://sonarcloud.io/dashboard?id=SvenWoltmann_hexagonal-architecture-java)
7 | [](https://sonarcloud.io/dashboard?id=SvenWoltmann_hexagonal-architecture-java)
8 |
9 | This repository contains a sample Java REST application implemented according to hexagonal architecture.
10 |
11 | It is part of the HappyCoders tutorial series on Hexagonal Architecture:
12 | * [Part 1: Hexagonal Architecture - What Is It? Why Should You Use It?](https://www.happycoders.eu/software-craftsmanship/hexagonal-architecture/).
13 | * [Part 2: Hexagonal Architecture with Java - Tutorial](https://www.happycoders.eu/software-craftsmanship/hexagonal-architecture-java/).
14 | * [Part 3: Ports and Adapters Java Tutorial: Adding a Database Adapter](https://www.happycoders.eu/software-craftsmanship/ports-and-adapters-java-tutorial-db/).
15 | * [Part 4: Hexagonal Architecture with Quarkus - Tutorial](https://www.happycoders.eu/software-craftsmanship/hexagonal-architecture-quarkus/).
16 | * [Part 5: Hexagonal Architecture with Spring Boot - Tutorial](https://www.happycoders.eu/software-craftsmanship/hexagonal-architecture-spring-boot/).
17 |
18 | # Branches
19 |
20 | ## `main`
21 |
22 | In the `main` branch, you'll find the application implemented without an application framework. It's only using:
23 | * [RESTEasy](https://resteasy.dev/) (implementing [Jakarta RESTful Web Services](https://jakarta.ee/specifications/restful-ws/)),
24 | * [Hibernate](https://hibernate.org/) (implementing [Jakarta Persistence API](https://jakarta.ee/specifications/persistence/)), and
25 | * [Undertow](https://undertow.io/) as a lightweight web server.
26 |
27 | ## `without-jpa-adapters`
28 |
29 | In the `without-jpa-adapters` branch, you'll find the application implemented without an application framework and without JPA adapters. It's only using RESTEasy and Undertow.
30 |
31 | ## `with-quarkus`
32 |
33 | In the `with-quarkus` branch, you'll find an implementation using [Quarkus](https://quarkus.io/) as application framework.
34 |
35 | ## `with-spring`
36 |
37 | In the `with-quarkus` branch, you'll find an implementation using [Spring](https://spring.io/) as application framework.
38 |
39 | # Architecture Overview
40 |
41 | The source code is separated into four modules:
42 | * `model` - contains the domain model
43 | * `application` - contains the domain services and the ports of the hexagon
44 | * `adapters` - contains the REST, in-memory and JPA adapters
45 | * `boostrap` - contains the configuration and bootstrapping logic
46 |
47 | The following diagram shows the hexagonal architecture of the application along with the source code modules:
48 |
49 | 
50 |
51 | 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.
52 |
53 | # How to Run the Application
54 |
55 | 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.
56 |
57 | You can use one of the following VM options to select a persistence mechanism:
58 |
59 | * `-Dpersistence=inmemory` to select the in-memory persistence option (default)
60 | * `-Dpersistence=mysql` to select the MySQL option
61 |
62 | 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:
63 |
64 | ```shell
65 | docker run --name hexagon-mysql -d -p3306:3306 \
66 | -e MYSQL_DATABASE=shop -e MYSQL_ROOT_PASSWORD=test mysql:8.1
67 | ```
68 |
69 | The connection parameters for the database are hardcoded in `RestEasyUndertowShopApplication.initMySqlAdapter()`. 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.
70 |
71 |
72 | # Example Curl Commands
73 |
74 | The following `curl` commands assume that you have installed `jq`, a tool for pretty-printing JSON strings.
75 |
76 | ## Find Products
77 |
78 | The following queries return one and two results, respectively:
79 |
80 | ```shell
81 | curl localhost:8080/products/?query=plastic | jq
82 | curl localhost:8080/products/?query=monitor | jq
83 | ```
84 |
85 | The response of the second query looks like this:
86 | ```json
87 | [
88 | {
89 | "id": "K3SR7PBX",
90 | "name": "27-Inch Curved Computer Monitor",
91 | "price": {
92 | "currency": "EUR",
93 | "amount": 159.99
94 | },
95 | "itemsInStock": 24081
96 | },
97 | {
98 | "id": "Q3W43CNC",
99 | "name": "Dual Monitor Desk Mount",
100 | "price": {
101 | "currency": "EUR",
102 | "amount": 119.9
103 | },
104 | "itemsInStock": 1079
105 | }
106 | ]
107 | ```
108 |
109 | ## Get a Cart
110 |
111 | To show the cart of user 61157 (this cart is empty when you begin):
112 |
113 | ```shell
114 | curl localhost:8080/carts/61157 | jq
115 | ```
116 |
117 | The response should look like this:
118 |
119 | ```json
120 | {
121 | "lineItems": [],
122 | "numberOfItems": 0,
123 | "subTotal": null
124 | }
125 | ```
126 |
127 | ## Adding Products to a Cart
128 |
129 | Each of the following commands adds a product to the cart and returns the contents of the cart after the product is added (note that on Windows, you have to replace the single quotes with double quotes):
130 |
131 | ```shell
132 | curl -X POST 'localhost:8080/carts/61157/line-items?productId=TTKQ8NJZ&quantity=20' | jq
133 | curl -X POST 'localhost:8080/carts/61157/line-items?productId=K3SR7PBX&quantity=2' | jq
134 | curl -X POST 'localhost:8080/carts/61157/line-items?productId=Q3W43CNC&quantity=1' | jq
135 | curl -X POST 'localhost:8080/carts/61157/line-items?productId=WM3BPG3E&quantity=3' | jq
136 | ```
137 |
138 | After executing two of the four commands, you can see that the cart contains the two products. You also see the total number of items and the sub-total:
139 |
140 | ```json
141 | {
142 | "lineItems": [
143 | {
144 | "productId": "TTKQ8NJZ",
145 | "productName": "Plastic Sheeting",
146 | "price": {
147 | "currency": "EUR",
148 | "amount": 42.99
149 | },
150 | "quantity": 20
151 | },
152 | {
153 | "productId": "K3SR7PBX",
154 | "productName": "27-Inch Curved Computer Monitor",
155 | "price": {
156 | "currency": "EUR",
157 | "amount": 159.99
158 | },
159 | "quantity": 2
160 | }
161 | ],
162 | "numberOfItems": 22,
163 | "subTotal": {
164 | "currency": "EUR",
165 | "amount": 1179.78
166 | }
167 | }
168 | ```
169 |
170 | This will increase the number of plastic sheetings to 40:
171 | ```shell
172 | curl -X POST 'localhost:8080/carts/61157/line-items?productId=TTKQ8NJZ&quantity=20' | jq
173 | ```
174 |
175 | ### Producing an Error Message
176 |
177 | Trying to add another 20 plastic sheetings will result in error message saying that there are only 55 items in stock:
178 |
179 | ```shell
180 | curl -X POST 'localhost:8080/carts/61157/line-items?productId=TTKQ8NJZ&quantity=20' | jq
181 | ```
182 |
183 | This is how the error response looks like:
184 | ```json
185 | {
186 | "httpStatus": 400,
187 | "errorMessage": "Only 55 items in stock"
188 | }
189 | ```
190 |
191 | ## Emptying the Cart
192 |
193 | To empty the cart, send a DELETE command to its URL:
194 |
195 | ```shell
196 | curl -X DELETE localhost:8080/carts/61157
197 | ```
198 |
199 | To verify it's empty:
200 | ```shell
201 | curl localhost:8080/carts/61157 | jq
202 | ```
203 |
204 | You'll see an empty cart again.
205 |
206 | ##
Additional Resources
207 |
208 | ###
Java Versions PDF Cheat Sheet
209 |
210 | **Stay up-to-date** with the latest Java features with [this PDF Cheat Sheet](https://www.happycoders.eu/java-versions/)!
211 |
212 | [
](https://www.happycoders.eu/java-versions/)
213 |
214 | * Avoid lengthy research with this **concise overview of all Java versions up to Java 23**.
215 | * **Discover the innovative features** of each new Java version, summarized on a single page.
216 | * **Impress your team** with your up-to-date knowledge of the latest Java version.
217 |
218 | 👉 [Download the Java Versions PDF](https://www.happycoders.eu/java-versions/)
219 |
220 | _(Hier geht's zur deutschen Version → [Java-Versionen PDF](https://www.happycoders.eu/de/java-versionen/))_
221 |
222 |
223 | ###
The Big O Cheat Sheet
224 |
225 | With this [1-page PDF cheat sheet](https://www.happycoders.eu/big-o-cheat-sheet/), you'll always have the **7 most important complexity classes** at a glance.
226 |
227 | [
](https://www.happycoders.eu/big-o-cheat-sheet/)
228 |
229 | * **Always choose the most efficient data structures** and thus increase the performance of your applications.
230 | * **Be prepared for technical interviews** and confidently present your algorithm knowledge.
231 | * **Become a sought-after problem solver** and be known for systematically tackling complex problems.
232 |
233 | 👉 [Download the Big O Cheat Sheet](https://www.happycoders.eu/big-o-cheat-sheet/)
234 |
235 | _(Hier geht's zur deutschen Version → [O-Notation Cheat Sheet](https://www.happycoders.eu/de/o-notation-cheat-sheet/))_
236 |
237 |
238 | ###
HappyCoders Newsletter
239 | 👉 Want to level up your Java skills?
240 | Sign up for the [HappyCoders newsletter](http://www.happycoders.eu/newsletter/) and get regular tips on programming, algorithms, and data structures!
241 |
242 | _(Hier geht's zur deutschen Version → [HappyCoders-Newsletter deutsch](https://www.happycoders.eu/de/newsletter/))_
243 |
244 |
245 | ###
🇩🇪 An alle Java-Programmierer, die durch fundierte Kenntnisse über Datenstrukturen besseren Code schreiben wollen
246 |
247 | Trage dich jetzt auf die [Warteliste](https://www.happycoders.eu/de/mastering-data-structures-warteliste/) von „Mastering Data Structures in Java“ ein, und erhalte das beste Angebot!
248 |
249 | [
](https://www.happycoders.eu/de/mastering-data-structures-warteliste/)
250 |
251 | 👉 [Zur Warteliste](https://www.happycoders.eu/de/mastering-data-structures-warteliste/)
252 |
--------------------------------------------------------------------------------
/adapter/pom.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 | 4.0.0
6 |
7 |
8 | eu.happycoders.shop
9 | parent
10 | 1.0-SNAPSHOT
11 |
12 |
13 | adapter
14 |
15 |
16 |
17 |
18 |
19 | eu.happycoders.shop
20 | application
21 | ${project.version}
22 |
23 |
24 |
25 |
26 | jakarta.persistence
27 | jakarta.persistence-api
28 |
29 |
30 | jakarta.ws.rs
31 | jakarta.ws.rs-api
32 |
33 |
34 |
35 |
36 | mysql
37 | mysql-connector-java
38 | test
39 |
40 |
41 | io.rest-assured
42 | rest-assured
43 | test
44 |
45 |
46 | org.jboss.resteasy
47 | resteasy-jackson2-provider
48 | test
49 |
50 |
51 | org.glassfish
52 | jakarta.el
53 | test
54 |
55 |
56 | org.hibernate.orm
57 | hibernate-core
58 | test
59 |
60 |
61 | org.hibernate.validator
62 | hibernate-validator
63 | test
64 |
65 |
66 | org.jboss.resteasy
67 | resteasy-undertow
68 | test
69 |
70 |
71 | org.testcontainers
72 | mysql
73 | test
74 |
75 |
76 |
77 |
78 | eu.happycoders.shop
79 | model
80 | ${project.version}
81 | tests
82 | test-jar
83 | test
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 | org.apache.maven.plugins
92 | maven-jar-plugin
93 |
94 |
95 |
96 | test-jar
97 |
98 |
99 |
100 |
101 |
102 |
103 |
--------------------------------------------------------------------------------
/adapter/src/main/java/eu/happycoders/shop/adapter/in/rest/cart/AddToCartController.java:
--------------------------------------------------------------------------------
1 | package eu.happycoders.shop.adapter.in.rest.cart;
2 |
3 | import static eu.happycoders.shop.adapter.in.rest.common.ControllerCommons.clientErrorException;
4 | import static eu.happycoders.shop.adapter.in.rest.common.CustomerIdParser.parseCustomerId;
5 | import static eu.happycoders.shop.adapter.in.rest.common.ProductIdParser.parseProductId;
6 |
7 | import eu.happycoders.shop.application.port.in.cart.AddToCartUseCase;
8 | import eu.happycoders.shop.application.port.in.cart.ProductNotFoundException;
9 | import eu.happycoders.shop.model.cart.Cart;
10 | import eu.happycoders.shop.model.cart.NotEnoughItemsInStockException;
11 | import eu.happycoders.shop.model.customer.CustomerId;
12 | import eu.happycoders.shop.model.product.ProductId;
13 | import jakarta.ws.rs.POST;
14 | import jakarta.ws.rs.Path;
15 | import jakarta.ws.rs.PathParam;
16 | import jakarta.ws.rs.Produces;
17 | import jakarta.ws.rs.QueryParam;
18 | import jakarta.ws.rs.core.MediaType;
19 | import jakarta.ws.rs.core.Response;
20 |
21 | /**
22 | * REST controller for all shopping cart use cases.
23 | *
24 | * @author Sven Woltmann
25 | */
26 | @Path("/carts")
27 | @Produces(MediaType.APPLICATION_JSON)
28 | public class AddToCartController {
29 |
30 | private final AddToCartUseCase addToCartUseCase;
31 |
32 | public AddToCartController(AddToCartUseCase addToCartUseCase) {
33 | this.addToCartUseCase = addToCartUseCase;
34 | }
35 |
36 | @POST
37 | @Path("/{customerId}/line-items")
38 | public CartWebModel addLineItem(
39 | @PathParam("customerId") String customerIdString,
40 | @QueryParam("productId") String productIdString,
41 | @QueryParam("quantity") int quantity) {
42 | CustomerId customerId = parseCustomerId(customerIdString);
43 | ProductId productId = parseProductId(productIdString);
44 |
45 | try {
46 | Cart cart = addToCartUseCase.addToCart(customerId, productId, quantity);
47 | return CartWebModel.fromDomainModel(cart);
48 | } catch (ProductNotFoundException e) {
49 | throw clientErrorException(
50 | Response.Status.BAD_REQUEST, "The requested product does not exist");
51 | } catch (NotEnoughItemsInStockException e) {
52 | throw clientErrorException(
53 | Response.Status.BAD_REQUEST, "Only %d items in stock".formatted(e.itemsInStock()));
54 | }
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/adapter/src/main/java/eu/happycoders/shop/adapter/in/rest/cart/CartLineItemWebModel.java:
--------------------------------------------------------------------------------
1 | package eu.happycoders.shop.adapter.in.rest.cart;
2 |
3 | import eu.happycoders.shop.model.cart.CartLineItem;
4 | import eu.happycoders.shop.model.money.Money;
5 | import eu.happycoders.shop.model.product.Product;
6 |
7 | /**
8 | * Model class for returning a shopping cart line item via REST API.
9 | *
10 | * @author Sven Woltmann
11 | */
12 | public record CartLineItemWebModel(
13 | String productId, String productName, Money price, int quantity) {
14 |
15 | public static CartLineItemWebModel fromDomainModel(CartLineItem lineItem) {
16 | Product product = lineItem.product();
17 | return new CartLineItemWebModel(
18 | product.id().value(), product.name(), product.price(), lineItem.quantity());
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/adapter/src/main/java/eu/happycoders/shop/adapter/in/rest/cart/CartWebModel.java:
--------------------------------------------------------------------------------
1 | package eu.happycoders.shop.adapter.in.rest.cart;
2 |
3 | import eu.happycoders.shop.model.cart.Cart;
4 | import eu.happycoders.shop.model.money.Money;
5 | import java.util.List;
6 |
7 | /**
8 | * Model class for returning a shopping cart via REST API.
9 | *
10 | * @author Sven Woltmann
11 | */
12 | public record CartWebModel(
13 | List lineItems, int numberOfItems, Money subTotal) {
14 |
15 | static CartWebModel fromDomainModel(Cart cart) {
16 | return new CartWebModel(
17 | cart.lineItems().stream().map(CartLineItemWebModel::fromDomainModel).toList(),
18 | cart.numberOfItems(),
19 | cart.subTotal());
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/adapter/src/main/java/eu/happycoders/shop/adapter/in/rest/cart/EmptyCartController.java:
--------------------------------------------------------------------------------
1 | package eu.happycoders.shop.adapter.in.rest.cart;
2 |
3 | import static eu.happycoders.shop.adapter.in.rest.common.CustomerIdParser.parseCustomerId;
4 |
5 | import eu.happycoders.shop.application.port.in.cart.EmptyCartUseCase;
6 | import eu.happycoders.shop.model.customer.CustomerId;
7 | import jakarta.ws.rs.DELETE;
8 | import jakarta.ws.rs.Path;
9 | import jakarta.ws.rs.PathParam;
10 | import jakarta.ws.rs.Produces;
11 | import jakarta.ws.rs.core.MediaType;
12 |
13 | /**
14 | * REST controller for all shopping cart use cases.
15 | *
16 | * @author Sven Woltmann
17 | */
18 | @Path("/carts")
19 | @Produces(MediaType.APPLICATION_JSON)
20 | public class EmptyCartController {
21 |
22 | private final EmptyCartUseCase emptyCartUseCase;
23 |
24 | public EmptyCartController(EmptyCartUseCase emptyCartUseCase) {
25 | this.emptyCartUseCase = emptyCartUseCase;
26 | }
27 |
28 | @DELETE
29 | @Path("/{customerId}")
30 | public void deleteCart(@PathParam("customerId") String customerIdString) {
31 | CustomerId customerId = parseCustomerId(customerIdString);
32 | emptyCartUseCase.emptyCart(customerId);
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/adapter/src/main/java/eu/happycoders/shop/adapter/in/rest/cart/GetCartController.java:
--------------------------------------------------------------------------------
1 | package eu.happycoders.shop.adapter.in.rest.cart;
2 |
3 | import static eu.happycoders.shop.adapter.in.rest.common.CustomerIdParser.parseCustomerId;
4 |
5 | import eu.happycoders.shop.application.port.in.cart.GetCartUseCase;
6 | import eu.happycoders.shop.model.cart.Cart;
7 | import eu.happycoders.shop.model.customer.CustomerId;
8 | import jakarta.ws.rs.GET;
9 | import jakarta.ws.rs.Path;
10 | import jakarta.ws.rs.PathParam;
11 | import jakarta.ws.rs.Produces;
12 | import jakarta.ws.rs.core.MediaType;
13 |
14 | /**
15 | * REST controller for all shopping cart use cases.
16 | *
17 | * @author Sven Woltmann
18 | */
19 | @Path("/carts")
20 | @Produces(MediaType.APPLICATION_JSON)
21 | public class GetCartController {
22 |
23 | private final GetCartUseCase getCartUseCase;
24 |
25 | public GetCartController(GetCartUseCase getCartUseCase) {
26 | this.getCartUseCase = getCartUseCase;
27 | }
28 |
29 | @GET
30 | @Path("/{customerId}")
31 | public CartWebModel getCart(@PathParam("customerId") String customerIdString) {
32 | CustomerId customerId = parseCustomerId(customerIdString);
33 | Cart cart = getCartUseCase.getCart(customerId);
34 | return CartWebModel.fromDomainModel(cart);
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/adapter/src/main/java/eu/happycoders/shop/adapter/in/rest/common/ControllerCommons.java:
--------------------------------------------------------------------------------
1 | package eu.happycoders.shop.adapter.in.rest.common;
2 |
3 | import jakarta.ws.rs.ClientErrorException;
4 | import jakarta.ws.rs.core.Response;
5 |
6 | /**
7 | * Common functionality for all REST controllers.
8 | *
9 | * @author Sven Woltmann
10 | */
11 | public final class ControllerCommons {
12 |
13 | private ControllerCommons() {}
14 |
15 | public static ClientErrorException clientErrorException(Response.Status status, String message) {
16 | return new ClientErrorException(errorResponse(status, message));
17 | }
18 |
19 | public static Response errorResponse(Response.Status status, String message) {
20 | ErrorEntity errorEntity = new ErrorEntity(status.getStatusCode(), message);
21 | return Response.status(status).entity(errorEntity).build();
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/adapter/src/main/java/eu/happycoders/shop/adapter/in/rest/common/CustomerIdParser.java:
--------------------------------------------------------------------------------
1 | package eu.happycoders.shop.adapter.in.rest.common;
2 |
3 | import static eu.happycoders.shop.adapter.in.rest.common.ControllerCommons.clientErrorException;
4 |
5 | import eu.happycoders.shop.model.customer.CustomerId;
6 | import jakarta.ws.rs.core.Response;
7 |
8 | /**
9 | * A parser for customer IDs, throwing a {@link jakarta.ws.rs.ClientErrorException} for invalid
10 | * customer IDs.
11 | *
12 | * @author Sven Woltmann
13 | */
14 | public final class CustomerIdParser {
15 |
16 | private CustomerIdParser() {}
17 |
18 | public static CustomerId parseCustomerId(String string) {
19 | try {
20 | return new CustomerId(Integer.parseInt(string));
21 | } catch (IllegalArgumentException e) {
22 | throw clientErrorException(Response.Status.BAD_REQUEST, "Invalid 'customerId'");
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/adapter/src/main/java/eu/happycoders/shop/adapter/in/rest/common/ErrorEntity.java:
--------------------------------------------------------------------------------
1 | package eu.happycoders.shop.adapter.in.rest.common;
2 |
3 | /**
4 | * An error entity with a status and message returned via REST API in case of an error.
5 | *
6 | * @author Sven Woltmann
7 | */
8 | public record ErrorEntity(int httpStatus, String errorMessage) {}
9 |
--------------------------------------------------------------------------------
/adapter/src/main/java/eu/happycoders/shop/adapter/in/rest/common/ProductIdParser.java:
--------------------------------------------------------------------------------
1 | package eu.happycoders.shop.adapter.in.rest.common;
2 |
3 | import static eu.happycoders.shop.adapter.in.rest.common.ControllerCommons.clientErrorException;
4 |
5 | import eu.happycoders.shop.model.product.ProductId;
6 | import jakarta.ws.rs.core.Response;
7 |
8 | /**
9 | * A parser for product IDs, throwing a {@link jakarta.ws.rs.ClientErrorException} for invalid
10 | * product IDs.
11 | *
12 | * @author Sven Woltmann
13 | */
14 | public final class ProductIdParser {
15 |
16 | private ProductIdParser() {}
17 |
18 | public static ProductId parseProductId(String string) {
19 | if (string == null) {
20 | throw clientErrorException(Response.Status.BAD_REQUEST, "Missing 'productId'");
21 | }
22 |
23 | try {
24 | return new ProductId(string);
25 | } catch (IllegalArgumentException e) {
26 | throw clientErrorException(Response.Status.BAD_REQUEST, "Invalid 'productId'");
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/adapter/src/main/java/eu/happycoders/shop/adapter/in/rest/product/FindProductsController.java:
--------------------------------------------------------------------------------
1 | package eu.happycoders.shop.adapter.in.rest.product;
2 |
3 | import static eu.happycoders.shop.adapter.in.rest.common.ControllerCommons.clientErrorException;
4 |
5 | import eu.happycoders.shop.application.port.in.product.FindProductsUseCase;
6 | import eu.happycoders.shop.model.product.Product;
7 | import jakarta.ws.rs.GET;
8 | import jakarta.ws.rs.Path;
9 | import jakarta.ws.rs.Produces;
10 | import jakarta.ws.rs.QueryParam;
11 | import jakarta.ws.rs.core.MediaType;
12 | import jakarta.ws.rs.core.Response;
13 | import java.util.List;
14 |
15 | /**
16 | * REST controller for all product use cases.
17 | *
18 | * @author Sven Woltmann
19 | */
20 | @Path("/products")
21 | @Produces(MediaType.APPLICATION_JSON)
22 | public class FindProductsController {
23 |
24 | private final FindProductsUseCase findProductsUseCase;
25 |
26 | public FindProductsController(FindProductsUseCase findProductsUseCase) {
27 | this.findProductsUseCase = findProductsUseCase;
28 | }
29 |
30 | @GET
31 | public List findProducts(@QueryParam("query") String query) {
32 | if (query == null) {
33 | throw clientErrorException(Response.Status.BAD_REQUEST, "Missing 'query'");
34 | }
35 |
36 | List products;
37 |
38 | try {
39 | products = findProductsUseCase.findByNameOrDescription(query);
40 | } catch (IllegalArgumentException e) {
41 | throw clientErrorException(Response.Status.BAD_REQUEST, "Invalid 'query'");
42 | }
43 |
44 | return products.stream().map(ProductInListWebModel::fromDomainModel).toList();
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/adapter/src/main/java/eu/happycoders/shop/adapter/in/rest/product/ProductInListWebModel.java:
--------------------------------------------------------------------------------
1 | package eu.happycoders.shop.adapter.in.rest.product;
2 |
3 | import eu.happycoders.shop.model.money.Money;
4 | import eu.happycoders.shop.model.product.Product;
5 |
6 | /**
7 | * Model class for returning a product (in a list ... that's without description) via REST API.
8 | *
9 | * @author Sven Woltmann
10 | */
11 | public record ProductInListWebModel(String id, String name, Money price, int itemsInStock) {
12 |
13 | public static ProductInListWebModel fromDomainModel(Product product) {
14 | return new ProductInListWebModel(
15 | product.id().value(), product.name(), product.price(), product.itemsInStock());
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/DemoProducts.java:
--------------------------------------------------------------------------------
1 | package eu.happycoders.shop.adapter.out.persistence;
2 |
3 | import eu.happycoders.shop.model.money.Money;
4 | import eu.happycoders.shop.model.product.Product;
5 | import eu.happycoders.shop.model.product.ProductId;
6 | import java.util.Currency;
7 | import java.util.List;
8 |
9 | /**
10 | * Demo products that are automatically stored in the product database (I tried to keep this demo
11 | * application as simple as possible, so it doesn't have an endpoint to add a product).
12 | *
13 | * @author Sven Woltmann
14 | */
15 | public final class DemoProducts {
16 |
17 | private static final Currency EUR = Currency.getInstance("EUR");
18 |
19 | public static final Product PLASTIC_SHEETING =
20 | new Product(
21 | new ProductId("TTKQ8NJZ"),
22 | "Plastic Sheeting",
23 | "Clear plastic sheeting, tear-resistant, tough, and durable",
24 | Money.of(EUR, 42, 99),
25 | 55);
26 |
27 | public static final Product COMPUTER_MONITOR =
28 | new Product(
29 | new ProductId("K3SR7PBX"),
30 | "27-Inch Curved Computer Monitor",
31 | "Enjoy big, bold and stunning panoramic views",
32 | Money.of(EUR, 159, 99),
33 | 24_081);
34 | public static final Product MONITOR_DESK_MOUNT =
35 | new Product(
36 | new ProductId("Q3W43CNC"),
37 | "Dual Monitor Desk Mount",
38 | "Ultra wide and longer arm fits most monitors",
39 | Money.of(EUR, 119, 90),
40 | 1_079);
41 |
42 | public static final Product LED_LIGHTS =
43 | new Product(
44 | new ProductId("WM3BPG3E"),
45 | "50ft Led Lights",
46 | "Enough lights to decorate an entire room",
47 | Money.of(EUR, 11, 69),
48 | 3_299);
49 |
50 | public static final List DEMO_PRODUCTS =
51 | List.of(PLASTIC_SHEETING, COMPUTER_MONITOR, MONITOR_DESK_MOUNT, LED_LIGHTS);
52 |
53 | private DemoProducts() {}
54 | }
55 |
--------------------------------------------------------------------------------
/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/inmemory/InMemoryCartRepository.java:
--------------------------------------------------------------------------------
1 | package eu.happycoders.shop.adapter.out.persistence.inmemory;
2 |
3 | import eu.happycoders.shop.application.port.out.persistence.CartRepository;
4 | import eu.happycoders.shop.model.cart.Cart;
5 | import eu.happycoders.shop.model.customer.CustomerId;
6 | import java.util.Map;
7 | import java.util.Optional;
8 | import java.util.concurrent.ConcurrentHashMap;
9 |
10 | /**
11 | * Persistence adapter: Stores carts in memory.
12 | *
13 | * @author Sven Woltmann
14 | */
15 | public class InMemoryCartRepository implements CartRepository {
16 |
17 | private final Map carts = new ConcurrentHashMap<>();
18 |
19 | @Override
20 | public void save(Cart cart) {
21 | carts.put(cart.id(), cart);
22 | }
23 |
24 | @Override
25 | public Optional findByCustomerId(CustomerId customerId) {
26 | return Optional.ofNullable(carts.get(customerId));
27 | }
28 |
29 | @Override
30 | public void deleteByCustomerId(CustomerId customerId) {
31 | carts.remove(customerId);
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/inmemory/InMemoryProductRepository.java:
--------------------------------------------------------------------------------
1 | package eu.happycoders.shop.adapter.out.persistence.inmemory;
2 |
3 | import eu.happycoders.shop.adapter.out.persistence.DemoProducts;
4 | import eu.happycoders.shop.application.port.out.persistence.ProductRepository;
5 | import eu.happycoders.shop.model.product.Product;
6 | import eu.happycoders.shop.model.product.ProductId;
7 | import java.util.List;
8 | import java.util.Locale;
9 | import java.util.Map;
10 | import java.util.Optional;
11 | import java.util.concurrent.ConcurrentHashMap;
12 |
13 | /**
14 | * Persistence adapter: Stores products in memory.
15 | *
16 | * @author Sven Woltmann
17 | */
18 | public class InMemoryProductRepository implements ProductRepository {
19 |
20 | private final Map products = new ConcurrentHashMap<>();
21 |
22 | public InMemoryProductRepository() {
23 | createDemoProducts();
24 | }
25 |
26 | private void createDemoProducts() {
27 | DemoProducts.DEMO_PRODUCTS.forEach(this::save);
28 | }
29 |
30 | @Override
31 | public void save(Product product) {
32 | products.put(product.id(), product);
33 | }
34 |
35 | @Override
36 | public Optional findById(ProductId productId) {
37 | return Optional.ofNullable(products.get(productId));
38 | }
39 |
40 | @Override
41 | public List findByNameOrDescription(String query) {
42 | String queryLowerCase = query.toLowerCase(Locale.ROOT);
43 | return products.values().stream()
44 | .filter(product -> matchesQuery(product, queryLowerCase))
45 | .toList();
46 | }
47 |
48 | private boolean matchesQuery(Product product, String query) {
49 | return product.name().toLowerCase(Locale.ROOT).contains(query)
50 | || product.description().toLowerCase(Locale.ROOT).contains(query);
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/jpa/CartJpaEntity.java:
--------------------------------------------------------------------------------
1 | package eu.happycoders.shop.adapter.out.persistence.jpa;
2 |
3 | import jakarta.persistence.CascadeType;
4 | import jakarta.persistence.Entity;
5 | import jakarta.persistence.Id;
6 | import jakarta.persistence.OneToMany;
7 | import jakarta.persistence.Table;
8 | import java.util.List;
9 | import lombok.Getter;
10 | import lombok.Setter;
11 |
12 | /**
13 | * JPA entity class for a shopping cart.
14 | *
15 | * @author Sven Woltmann
16 | */
17 | @Entity
18 | @Table(name = "Cart")
19 | @Getter
20 | @Setter
21 | public class CartJpaEntity {
22 |
23 | @Id private int customerId;
24 |
25 | @OneToMany(mappedBy = "cart", cascade = CascadeType.ALL, orphanRemoval = true)
26 | private List lineItems;
27 | }
28 |
--------------------------------------------------------------------------------
/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/jpa/CartLineItemJpaEntity.java:
--------------------------------------------------------------------------------
1 | package eu.happycoders.shop.adapter.out.persistence.jpa;
2 |
3 | import jakarta.persistence.Entity;
4 | import jakarta.persistence.GeneratedValue;
5 | import jakarta.persistence.Id;
6 | import jakarta.persistence.ManyToOne;
7 | import jakarta.persistence.Table;
8 | import lombok.Getter;
9 | import lombok.Setter;
10 |
11 | /**
12 | * JPA entity class for a shopping cart line item.
13 | *
14 | * @author Sven Woltmann
15 | */
16 | @Entity
17 | @Table(name = "CartLineItem")
18 | @Getter
19 | @Setter
20 | public class CartLineItemJpaEntity {
21 |
22 | @Id @GeneratedValue private Integer id;
23 |
24 | @ManyToOne private CartJpaEntity cart;
25 |
26 | @ManyToOne private ProductJpaEntity product;
27 |
28 | private int quantity;
29 | }
30 |
--------------------------------------------------------------------------------
/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/jpa/CartMapper.java:
--------------------------------------------------------------------------------
1 | package eu.happycoders.shop.adapter.out.persistence.jpa;
2 |
3 | import eu.happycoders.shop.model.cart.Cart;
4 | import eu.happycoders.shop.model.cart.CartLineItem;
5 | import eu.happycoders.shop.model.customer.CustomerId;
6 | import java.util.Optional;
7 |
8 | /**
9 | * Maps model carts and line items to JPA carts and line items - and vice versa.
10 | *
11 | * @author Sven Woltmann
12 | */
13 | final class CartMapper {
14 |
15 | private CartMapper() {}
16 |
17 | static CartJpaEntity toJpaEntity(Cart cart) {
18 | CartJpaEntity cartJpaEntity = new CartJpaEntity();
19 | cartJpaEntity.setCustomerId(cart.id().value());
20 |
21 | cartJpaEntity.setLineItems(
22 | cart.lineItems().stream().map(lineItem -> toJpaEntity(cartJpaEntity, lineItem)).toList());
23 |
24 | return cartJpaEntity;
25 | }
26 |
27 | static CartLineItemJpaEntity toJpaEntity(CartJpaEntity cartJpaEntity, CartLineItem lineItem) {
28 | ProductJpaEntity productJpaEntity = new ProductJpaEntity();
29 | productJpaEntity.setId(lineItem.product().id().value());
30 |
31 | CartLineItemJpaEntity entity = new CartLineItemJpaEntity();
32 | entity.setCart(cartJpaEntity);
33 | entity.setProduct(productJpaEntity);
34 | entity.setQuantity(lineItem.quantity());
35 |
36 | return entity;
37 | }
38 |
39 | static Optional toModelEntityOptional(CartJpaEntity cartJpaEntity) {
40 | if (cartJpaEntity == null) {
41 | return Optional.empty();
42 | }
43 |
44 | CustomerId customerId = new CustomerId(cartJpaEntity.getCustomerId());
45 | Cart cart = new Cart(customerId);
46 |
47 | for (CartLineItemJpaEntity lineItemJpaEntity : cartJpaEntity.getLineItems()) {
48 | cart.putProductIgnoringNotEnoughItemsInStock(
49 | ProductMapper.toModelEntity(lineItemJpaEntity.getProduct()),
50 | lineItemJpaEntity.getQuantity());
51 | }
52 |
53 | return Optional.of(cart);
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/jpa/EntityManagerFactoryFactory.java:
--------------------------------------------------------------------------------
1 | package eu.happycoders.shop.adapter.out.persistence.jpa;
2 |
3 | import jakarta.persistence.EntityManagerFactory;
4 | import jakarta.persistence.Persistence;
5 | import java.util.Map;
6 |
7 | /**
8 | * Factory for an EntityManagerFactory
for connecting to a MySQL database.
9 | *
10 | * @author Sven Woltmann
11 | */
12 | public final class EntityManagerFactoryFactory {
13 |
14 | private EntityManagerFactoryFactory() {}
15 |
16 | public static EntityManagerFactory createMySqlEntityManagerFactory(
17 | String jdbcUrl, String user, String password) {
18 | return Persistence.createEntityManagerFactory(
19 | "eu.happycoders.shop.adapter.out.persistence.jpa",
20 | Map.of(
21 | "hibernate.dialect",
22 | "org.hibernate.dialect.MySQLDialect",
23 | "hibernate.hbm2ddl.auto",
24 | "update",
25 | "jakarta.persistence.jdbc.driver",
26 | "com.mysql.jdbc.Driver",
27 | "jakarta.persistence.jdbc.url",
28 | jdbcUrl,
29 | "jakarta.persistence.jdbc.user",
30 | user,
31 | "jakarta.persistence.jdbc.password",
32 | password));
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/jpa/JpaCartRepository.java:
--------------------------------------------------------------------------------
1 | package eu.happycoders.shop.adapter.out.persistence.jpa;
2 |
3 | import eu.happycoders.shop.application.port.out.persistence.CartRepository;
4 | import eu.happycoders.shop.model.cart.Cart;
5 | import eu.happycoders.shop.model.customer.CustomerId;
6 | import jakarta.persistence.EntityManager;
7 | import jakarta.persistence.EntityManagerFactory;
8 | import java.util.Optional;
9 |
10 | /**
11 | * Persistence adapter: Stores carts via JPA in a database.
12 | *
13 | * @author Sven Woltmann
14 | */
15 | public class JpaCartRepository implements CartRepository {
16 |
17 | private final EntityManagerFactory entityManagerFactory;
18 |
19 | public JpaCartRepository(EntityManagerFactory entityManagerFactory) {
20 | this.entityManagerFactory = entityManagerFactory;
21 | }
22 |
23 | @Override
24 | public void save(Cart cart) {
25 | try (EntityManager entityManager = entityManagerFactory.createEntityManager()) {
26 | entityManager.getTransaction().begin();
27 | entityManager.merge(CartMapper.toJpaEntity(cart));
28 | entityManager.getTransaction().commit();
29 | }
30 | }
31 |
32 | @Override
33 | public Optional findByCustomerId(CustomerId customerId) {
34 | try (EntityManager entityManager = entityManagerFactory.createEntityManager()) {
35 | CartJpaEntity cartJpaEntity = entityManager.find(CartJpaEntity.class, customerId.value());
36 | return CartMapper.toModelEntityOptional(cartJpaEntity);
37 | }
38 | }
39 |
40 | @Override
41 | public void deleteByCustomerId(CustomerId customerId) {
42 | try (EntityManager entityManager = entityManagerFactory.createEntityManager()) {
43 | entityManager.getTransaction().begin();
44 |
45 | CartJpaEntity cartJpaEntity = entityManager.find(CartJpaEntity.class, customerId.value());
46 |
47 | if (cartJpaEntity != null) {
48 | entityManager.remove(cartJpaEntity);
49 | }
50 |
51 | entityManager.getTransaction().commit();
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/jpa/JpaProductRepository.java:
--------------------------------------------------------------------------------
1 | package eu.happycoders.shop.adapter.out.persistence.jpa;
2 |
3 | import eu.happycoders.shop.adapter.out.persistence.DemoProducts;
4 | import eu.happycoders.shop.application.port.out.persistence.ProductRepository;
5 | import eu.happycoders.shop.model.product.Product;
6 | import eu.happycoders.shop.model.product.ProductId;
7 | import jakarta.persistence.EntityManager;
8 | import jakarta.persistence.EntityManagerFactory;
9 | import jakarta.persistence.TypedQuery;
10 | import java.util.List;
11 | import java.util.Optional;
12 |
13 | /**
14 | * Persistence adapter: Stores products via JPA in a database.
15 | *
16 | * @author Sven Woltmann
17 | */
18 | public class JpaProductRepository implements ProductRepository {
19 |
20 | private final EntityManagerFactory entityManagerFactory;
21 |
22 | public JpaProductRepository(EntityManagerFactory entityManagerFactory) {
23 | this.entityManagerFactory = entityManagerFactory;
24 | createDemoProducts();
25 | }
26 |
27 | private void createDemoProducts() {
28 | DemoProducts.DEMO_PRODUCTS.forEach(this::save);
29 | }
30 |
31 | @Override
32 | public void save(Product product) {
33 | try (EntityManager entityManager = entityManagerFactory.createEntityManager()) {
34 | entityManager.getTransaction().begin();
35 | entityManager.merge(ProductMapper.toJpaEntity(product));
36 | entityManager.getTransaction().commit();
37 | }
38 | }
39 |
40 | @Override
41 | public Optional findById(ProductId productId) {
42 | try (EntityManager entityManager = entityManagerFactory.createEntityManager()) {
43 | ProductJpaEntity jpaEntity = entityManager.find(ProductJpaEntity.class, productId.value());
44 | return ProductMapper.toModelEntityOptional(jpaEntity);
45 | }
46 | }
47 |
48 | @Override
49 | public List findByNameOrDescription(String queryString) {
50 | try (EntityManager entityManager = entityManagerFactory.createEntityManager()) {
51 | TypedQuery query =
52 | entityManager
53 | .createQuery(
54 | "from ProductJpaEntity where name like :query or description like :query",
55 | ProductJpaEntity.class)
56 | .setParameter("query", "%" + queryString + "%");
57 |
58 | List entities = query.getResultList();
59 |
60 | return ProductMapper.toModelEntities(entities);
61 | }
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/jpa/ProductJpaEntity.java:
--------------------------------------------------------------------------------
1 | package eu.happycoders.shop.adapter.out.persistence.jpa;
2 |
3 | import jakarta.persistence.Column;
4 | import jakarta.persistence.Entity;
5 | import jakarta.persistence.Id;
6 | import jakarta.persistence.Table;
7 | import java.math.BigDecimal;
8 | import lombok.Getter;
9 | import lombok.Setter;
10 |
11 | /**
12 | * JPA entity class for a product.
13 | *
14 | * @author Sven Woltmann
15 | */
16 | @Entity
17 | @Table(name = "Product")
18 | @Getter
19 | @Setter
20 | public class ProductJpaEntity {
21 |
22 | @Id private String id;
23 |
24 | @Column(nullable = false)
25 | private String name;
26 |
27 | @Column(nullable = false)
28 | private String description;
29 |
30 | @Column(nullable = false)
31 | private String priceCurrency;
32 |
33 | @Column(nullable = false)
34 | private BigDecimal priceAmount;
35 |
36 | private int itemsInStock;
37 | }
38 |
--------------------------------------------------------------------------------
/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/jpa/ProductMapper.java:
--------------------------------------------------------------------------------
1 | package eu.happycoders.shop.adapter.out.persistence.jpa;
2 |
3 | import eu.happycoders.shop.model.money.Money;
4 | import eu.happycoders.shop.model.product.Product;
5 | import eu.happycoders.shop.model.product.ProductId;
6 | import java.util.Currency;
7 | import java.util.List;
8 | import java.util.Optional;
9 |
10 | /**
11 | * Maps a model product to a JPA product and vice versa.
12 | *
13 | * @author Sven Woltmann
14 | */
15 | final class ProductMapper {
16 |
17 | private ProductMapper() {}
18 |
19 | static ProductJpaEntity toJpaEntity(Product product) {
20 | ProductJpaEntity jpaEntity = new ProductJpaEntity();
21 |
22 | jpaEntity.setId(product.id().value());
23 | jpaEntity.setName(product.name());
24 | jpaEntity.setDescription(product.description());
25 | jpaEntity.setPriceCurrency(product.price().currency().getCurrencyCode());
26 | jpaEntity.setPriceAmount(product.price().amount());
27 | jpaEntity.setItemsInStock(product.itemsInStock());
28 |
29 | return jpaEntity;
30 | }
31 |
32 | static Optional toModelEntityOptional(ProductJpaEntity jpaEntity) {
33 | return Optional.ofNullable(jpaEntity).map(ProductMapper::toModelEntity);
34 | }
35 |
36 | static Product toModelEntity(ProductJpaEntity jpaEntity) {
37 | return new Product(
38 | new ProductId(jpaEntity.getId()),
39 | jpaEntity.getName(),
40 | jpaEntity.getDescription(),
41 | new Money(Currency.getInstance(jpaEntity.getPriceCurrency()), jpaEntity.getPriceAmount()),
42 | jpaEntity.getItemsInStock());
43 | }
44 |
45 | static List toModelEntities(List jpaEntities) {
46 | return jpaEntities.stream().map(ProductMapper::toModelEntity).toList();
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/adapter/src/main/resources/META-INF/persistence.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | eu.happycoders.shop.adapter.out.persistence.jpa.CartJpaEntity
4 | eu.happycoders.shop.adapter.out.persistence.jpa.CartLineItemJpaEntity
5 | eu.happycoders.shop.adapter.out.persistence.jpa.ProductJpaEntity
6 | true
7 |
8 |
9 |
--------------------------------------------------------------------------------
/adapter/src/test/java/eu/happycoders/shop/adapter/in/rest/HttpTestCommons.java:
--------------------------------------------------------------------------------
1 | package eu.happycoders.shop.adapter.in.rest;
2 |
3 | import static org.assertj.core.api.Assertions.assertThat;
4 |
5 | import io.restassured.path.json.JsonPath;
6 | import io.restassured.response.Response;
7 |
8 | public final class HttpTestCommons {
9 |
10 | // So the tests can run when the application runs on port 8080:
11 | public static final int TEST_PORT = 8082;
12 |
13 | private HttpTestCommons() {}
14 |
15 | public static void assertThatResponseIsError(
16 | Response response,
17 | jakarta.ws.rs.core.Response.Status expectedStatus,
18 | String expectedErrorMessage) {
19 | assertThat(response.getStatusCode()).isEqualTo(expectedStatus.getStatusCode());
20 |
21 | JsonPath json = response.jsonPath();
22 |
23 | assertThat(json.getInt("httpStatus")).isEqualTo(expectedStatus.getStatusCode());
24 | assertThat(json.getString("errorMessage")).isEqualTo(expectedErrorMessage);
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/adapter/src/test/java/eu/happycoders/shop/adapter/in/rest/cart/CartsControllerAssertions.java:
--------------------------------------------------------------------------------
1 | package eu.happycoders.shop.adapter.in.rest.cart;
2 |
3 | import static jakarta.ws.rs.core.Response.Status.OK;
4 | import static org.assertj.core.api.Assertions.assertThat;
5 |
6 | import eu.happycoders.shop.model.cart.Cart;
7 | import eu.happycoders.shop.model.cart.CartLineItem;
8 | import io.restassured.path.json.JsonPath;
9 | import io.restassured.response.Response;
10 |
11 | public final class CartsControllerAssertions {
12 |
13 | private CartsControllerAssertions() {}
14 |
15 | public static void assertThatResponseIsCart(Response response, Cart cart) {
16 | assertThat(response.statusCode()).isEqualTo(OK.getStatusCode());
17 |
18 | JsonPath json = response.jsonPath();
19 |
20 | for (int i = 0; i < cart.lineItems().size(); i++) {
21 | CartLineItem lineItem = cart.lineItems().get(i);
22 |
23 | String lineItemPrefix = "lineItems[%d].".formatted(i);
24 |
25 | assertThat(json.getString(lineItemPrefix + "productId"))
26 | .isEqualTo(lineItem.product().id().value());
27 | assertThat(json.getString(lineItemPrefix + "productName"))
28 | .isEqualTo(lineItem.product().name());
29 | assertThat(json.getString(lineItemPrefix + "price.currency"))
30 | .isEqualTo(lineItem.product().price().currency().getCurrencyCode());
31 | assertThat(json.getDouble(lineItemPrefix + "price.amount"))
32 | .isEqualTo(lineItem.product().price().amount().doubleValue());
33 | assertThat(json.getInt(lineItemPrefix + "quantity")).isEqualTo(lineItem.quantity());
34 | }
35 |
36 | assertThat(json.getInt("numberOfItems")).isEqualTo(cart.numberOfItems());
37 |
38 | if (cart.subTotal() != null) {
39 | assertThat(json.getString("subTotal.currency"))
40 | .isEqualTo(cart.subTotal().currency().getCurrencyCode());
41 | assertThat(json.getDouble("subTotal.amount"))
42 | .isEqualTo(cart.subTotal().amount().doubleValue());
43 | } else {
44 | assertThat(json.getString("subTotal")).isNull();
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/adapter/src/test/java/eu/happycoders/shop/adapter/in/rest/cart/CartsControllerTest.java:
--------------------------------------------------------------------------------
1 | package eu.happycoders.shop.adapter.in.rest.cart;
2 |
3 | import static eu.happycoders.shop.adapter.in.rest.HttpTestCommons.TEST_PORT;
4 | import static eu.happycoders.shop.adapter.in.rest.HttpTestCommons.assertThatResponseIsError;
5 | import static eu.happycoders.shop.adapter.in.rest.cart.CartsControllerAssertions.assertThatResponseIsCart;
6 | import static eu.happycoders.shop.model.money.TestMoneyFactory.euros;
7 | import static eu.happycoders.shop.model.product.TestProductFactory.createTestProduct;
8 | import static io.restassured.RestAssured.given;
9 | import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST;
10 | import static jakarta.ws.rs.core.Response.Status.NO_CONTENT;
11 | import static org.mockito.Mockito.mock;
12 | import static org.mockito.Mockito.verify;
13 | import static org.mockito.Mockito.when;
14 |
15 | import eu.happycoders.shop.application.port.in.cart.AddToCartUseCase;
16 | import eu.happycoders.shop.application.port.in.cart.EmptyCartUseCase;
17 | import eu.happycoders.shop.application.port.in.cart.GetCartUseCase;
18 | import eu.happycoders.shop.application.port.in.cart.ProductNotFoundException;
19 | import eu.happycoders.shop.model.cart.Cart;
20 | import eu.happycoders.shop.model.cart.NotEnoughItemsInStockException;
21 | import eu.happycoders.shop.model.customer.CustomerId;
22 | import eu.happycoders.shop.model.product.Product;
23 | import eu.happycoders.shop.model.product.ProductId;
24 | import io.restassured.response.Response;
25 | import jakarta.ws.rs.core.Application;
26 | import java.util.Set;
27 | import org.jboss.resteasy.plugins.server.undertow.UndertowJaxrsServer;
28 | import org.junit.jupiter.api.AfterAll;
29 | import org.junit.jupiter.api.BeforeAll;
30 | import org.junit.jupiter.api.BeforeEach;
31 | import org.junit.jupiter.api.Test;
32 | import org.mockito.Mockito;
33 |
34 | class CartsControllerTest {
35 |
36 | private static final CustomerId TEST_CUSTOMER_ID = new CustomerId(61157);
37 | private static final Product TEST_PRODUCT_1 = createTestProduct(euros(19, 99));
38 | private static final Product TEST_PRODUCT_2 = createTestProduct(euros(25, 99));
39 |
40 | private static final AddToCartUseCase addToCartUseCase = mock(AddToCartUseCase.class);
41 | private static final GetCartUseCase getCartUseCase = mock(GetCartUseCase.class);
42 | private static final EmptyCartUseCase emptyCartUseCase = mock(EmptyCartUseCase.class);
43 |
44 | private static UndertowJaxrsServer server;
45 |
46 | @BeforeAll
47 | static void init() {
48 | server =
49 | new UndertowJaxrsServer()
50 | .setPort(TEST_PORT)
51 | .start()
52 | .deploy(
53 | new Application() {
54 | @Override
55 | public Set