├── .circleci └── config.yml ├── .gitignore ├── README.md ├── application ├── build.gradle ├── settings.gradle └── src │ └── main │ ├── java │ └── com │ │ └── rdelgatte │ │ └── hexagonal │ │ ├── Hexagonal.java │ │ └── configuration │ │ └── ApplicationConfiguration.java │ └── resources │ └── application.yml ├── build.gradle ├── console-application ├── build.gradle └── src │ └── main │ ├── java │ └── com │ │ └── rdelgatte │ │ └── hexagonal │ │ ├── HexagonalRunner.java │ │ └── configuration │ │ └── ApplicationConfiguration.java │ └── resources │ └── application.yml ├── doc ├── demo.md ├── hexagonal-1.png ├── hexagonal-2.png ├── hexagonal-3.png ├── hexagonal-4.png └── uml.png ├── docker-compose.yml ├── domain ├── build.gradle ├── settings.gradle └── src │ ├── main │ └── java │ │ └── com │ │ └── rdelgatte │ │ └── hexagonal │ │ ├── api │ │ ├── CustomerService.java │ │ ├── CustomerServiceImpl.java │ │ ├── ProductService.java │ │ └── ProductServiceImpl.java │ │ ├── domain │ │ ├── Customer.java │ │ └── Product.java │ │ └── spi │ │ ├── CustomerRepository.java │ │ └── ProductRepository.java │ └── test │ └── java │ └── com │ └── rdelgatte │ └── hexagonal │ └── api │ ├── CustomerServiceImplTest.java │ └── ProductServiceImplTest.java ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── infrastructure ├── build.gradle ├── memory-persistence │ ├── build.gradle │ └── src │ │ ├── main │ │ └── java │ │ │ └── com │ │ │ └── rdelgatte │ │ │ └── hexagonal │ │ │ └── infrastructure │ │ │ └── memory │ │ │ ├── InMemoryCustomerRepository.java │ │ │ └── InMemoryProductRepository.java │ │ └── test │ │ └── java │ │ └── com │ │ └── rdelgatte │ │ └── hexagonal │ │ └── infrastructure │ │ └── memory │ │ ├── InMemoryCustomerRepositoryTest.java │ │ └── InMemoryProductRepositoryTest.java ├── postgres-persistence │ ├── build.gradle │ └── src │ │ ├── main │ │ └── java │ │ │ └── com │ │ │ └── rdelgatte │ │ │ └── hexagonal │ │ │ └── infrastructure │ │ │ └── postgres │ │ │ ├── dao │ │ │ ├── PostgresCustomer.java │ │ │ └── PostgresProduct.java │ │ │ └── repository │ │ │ ├── PostgresCustomerRepository.java │ │ │ ├── PostgresProductRepository.java │ │ │ ├── PostgresSpringDataCustomerRepository.java │ │ │ └── PostgresSpringDataProductRepository.java │ │ └── test │ │ └── java │ │ └── com │ │ └── rdelgatte │ │ └── hexagonal │ │ └── infrastructure │ │ └── postgres │ │ └── dao │ │ ├── PostgresCustomerTest.java │ │ └── PostgresProductTest.java ├── rest-client │ ├── build.gradle │ └── src │ │ ├── main │ │ └── java │ │ │ └── com │ │ │ └── rdelgatte │ │ │ └── hexagonal │ │ │ └── infrastructure │ │ │ └── restclient │ │ │ ├── configuration │ │ │ └── JacksonConfiguration.java │ │ │ └── controller │ │ │ ├── CustomerController.java │ │ │ └── ProductController.java │ │ └── test │ │ └── java │ │ └── com │ │ └── rdelgatte │ │ └── hexagonal │ │ └── infrastructure │ │ └── restclient │ │ └── controller │ │ ├── CustomerControllerTest.java │ │ └── ProductControllerTest.java └── settings.gradle └── settings.gradle /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Java Gradle CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-java/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: circleci/openjdk:11-jdk 11 | 12 | # Specify service dependencies here if necessary 13 | # CircleCI maintains a library of pre-built images 14 | # documented at https://circleci.com/docs/2.0/circleci-images/ 15 | # - image: circleci/postgres:9.4 16 | 17 | working_directory: ~/repo 18 | 19 | environment: 20 | # Customize the JVM maximum heap limit 21 | JVM_OPTS: -Xmx3200m 22 | TERM: dumb 23 | 24 | steps: 25 | - checkout 26 | 27 | # Download and cache dependencies 28 | - restore_cache: 29 | keys: 30 | - v1-dependencies-{{ checksum "build.gradle" }} 31 | # fallback to using the latest cache if no exact match is found 32 | - v1-dependencies- 33 | 34 | - run: gradle dependencies 35 | 36 | - save_cache: 37 | paths: 38 | - ~/.gradle 39 | key: v1-dependencies-{{ checksum "build.gradle" }} 40 | 41 | # run build! 42 | - run: gradle build -x test 43 | 44 | # run tests! 45 | - run: gradle test 46 | 47 | # run pitests! 48 | - run: gradle pitest 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /build/ 3 | 4 | # Ignore Gradle GUI config 5 | gradle-app.setting 6 | 7 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 8 | !gradle-wrapper.jar 9 | 10 | # Cache of project 11 | .gradletasknamecache 12 | 13 | # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 14 | # gradle/wrapper/gradle-wrapper.properties 15 | 16 | # Intellij files 17 | .idea 18 | 19 | build 20 | out -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Example of `hexagonal-architecture` implementation 2 | 3 | Some references about hexagonal architecture: 4 | - https://www.youtube.com/watch?v=th4AgBcrEHA (introduction by Alistair Cockburn) 5 | - https://www.youtube.com/watch?v=Hi5aDfRe-aE 6 | - https://en.wikipedia.org/wiki/Domain-driven_design 7 | - https://blog.xebia.fr/2016/03/16/perennisez-votre-metier-avec-larchitecture-hexagonale/ 8 | - https://blog.octo.com/architecture-hexagonale-trois-principes-et-un-exemple-dimplementation/ 9 | 10 | This example application has been built with: 11 | - JDK11 12 | - Gradle 5.* 13 | - Spring Boot 2 14 | 15 | ## Context 16 | 17 | The application is a basic example which aims to handle customers and products so we can: 18 | - create products 19 | - create customers 20 | - add products to customers' cart 21 | 22 | Here is an UML class diagram of our model: 23 | ![UML](doc/uml.png) 24 | 25 | The domain also exposes some interfaces to interact with customers and products: 26 | 27 | - `CustomerService.java` 28 | ``` 29 | public interface CustomerService { 30 | 31 | Customer signUp(String name); 32 | 33 | Option findCustomer(String name); 34 | 35 | Customer addProductToCart(String name, String productCode); 36 | 37 | Customer emptyCart(String name); 38 | } 39 | ``` 40 | - `ProductService.java` 41 | ``` 42 | public interface ProductService { 43 | 44 | Product createProduct(Product product); 45 | 46 | void deleteProduct(String code); 47 | 48 | List getAllProducts(); 49 | 50 | Option findProductByCode(String code); 51 | } 52 | ``` 53 | 54 | ## Start-up 55 | 56 | Run `docker-compose up -d` 57 | 58 | #### Products 59 | 60 | - Create a product: `curl --header "Content-Type: application/json" --request POST --data '{ "code": "1616", "label": "Easybreath", "price": 25.95 }' http://localhost:8080/products` 61 | - List products: `curl --header "Content-Type: application/json" --request GET http://localhost:8080/products` 62 | - Find product: `curl --header "Content-Type: application/json" --request GET http://localhost:8080/products/1616` 63 | 64 | If you try to run the create product statement twice with the same product code, you will get an exception handled by domain: 65 | ``` 66 | {"timestamp":"2018-10-30T09:55:10.837+0000","status":500,"error":"Internal Server Error","message":"Product 1616 already exists so you can't create it","path":"/products"} 67 | ``` 68 | 69 | #### Customers 70 | 71 | - Create a customer: `curl --header "Content-Type: application/json" --request POST --data 'rdelgatte' http://localhost:8080/customers` 72 | - Find customer by login: `curl --header "Content-Type: application/json" --request GET http://localhost:8080/customers/rdelgatte` 73 | - Add product to customer's cart: `curl --header "Content-Type: application/json" --request PATCH --data '1616' http://localhost:8080/customers/rdelgatte` 74 | 75 | ## Domain 76 | 77 | `domain` is implemented as a standalone Java module which has no dependencies to any framework (neither spring). 78 | 79 | Actually it has only two dependencies so we can use `Lombok` and `Vavr` as libraries to make data manipulation easier. 80 | 81 | As defined in hexagonal architecture, in `domain` you will only find the data model definition and *API* + *SPI* interface definitions. 82 | 83 | ## Infrastructure 84 | 85 | Here we will define the interactions over the domain so it implements these *ports* to implements the way to : 86 | - interact with the domain (triggering actions) 87 | - define where the domain gets its resources (persistence) 88 | 89 | To do so, there are multiple sub-modules under infrastructure. 90 | 91 | ### API (Application Provider Interfaces) 92 | 93 | It describes all the ports for everything that needs **to query the domain**. 94 | 95 | These interfaces are implemented by the domain. 96 | 97 | #### Rest client 98 | 99 | This module aims to expose some *rest* entry points to interact with products and customers. 100 | 101 | ### SPI 102 | 103 | It gathers all the ports required by the domain **to retrieve information or get some services from third parties**. 104 | 105 | These interfaces are defined in the domain and implemented by the right side of the infrastructure. 106 | 107 | #### In-memory persistence 108 | 109 | Through this implementation, domain data can be persisted in memory by implementing SPI ports for domain `ProductRepository` and `CustomerRepository`. 110 | Here is an example: 111 | 112 | ``` 113 | @Data 114 | @AllArgsConstructor 115 | @NoArgsConstructor 116 | @Wither 117 | public class InMemoryProductRepository implements ProductRepository { 118 | 119 | private List inMemoryProducts = List(); 120 | 121 | public Product addProduct(Product product) { 122 | this.inMemoryProducts = getInMemoryProducts().append(product); 123 | return product; 124 | } 125 | 126 | public void deleteProduct(UUID productId) { 127 | this.inMemoryProducts = getInMemoryProducts().filter(product -> !product.getId().equals(productId)); 128 | } 129 | 130 | public Option findProductByCode(String code) { 131 | return getInMemoryProducts().find(product -> product.getCode().equals(code)); 132 | } 133 | 134 | public List findAllProducts() { 135 | return getInMemoryProducts(); 136 | } 137 | } 138 | ``` 139 | 140 | #### Postgres persistence 141 | 142 | For Postgres database, add the following environment variables: 143 | 144 | ``` 145 | PG_PORT=5432 146 | PG_DB_NAME=hexagonal-db 147 | PG_USER_NAME=hexagonal 148 | PG_PASSWORD=hexagonal 149 | PG_HOST=localhost 150 | ``` 151 | 152 | ## Application (module `application`) 153 | 154 | This module contains the application which will instantiate any of the previously highlighted modules so it runs a stand-alone application with a specific configuration. 155 | 156 | In `ApplicationConfiguration`, we can find the definition of both `ProductService` and `CustomerService` adapters from the domain where we can decide which repository we should use (in memory | postgres). 157 | 158 | In the following example (default), we define the **persistence mode** for each service we want to use: 159 | - `ProductService` will use `InMemoryProductRepository` (meaning products will be persisted in memory) 160 | - `CustomerService` will use `InMemoryCustomerRepository` (meaning customers will be persisted in memory) 161 | 162 | ``` 163 | private static final InMemoryProductRepository inMemoryProductRepository = new InMemoryProductRepository(); 164 | 165 | private InMemoryCustomerRepository getCustomerRepository() { 166 | return new InMemoryCustomerRepository(); 167 | } 168 | 169 | @Bean 170 | CustomerService customerService() { 171 | return new CustomerServiceImpl(getCustomerRepository(), inMemoryProductRepository); 172 | } 173 | 174 | @Bean 175 | ProductService productService() { 176 | return new ProductServiceImpl(inMemoryProductRepository); 177 | } 178 | ``` 179 | 180 | If we want to use postgres-persistence for products, we need to change configuration to: 181 | ``` 182 | PostgresProductRepository postgresProductRepository( 183 | PostgresSpringDataProductRepository postgresSpringDataProductRepository) { 184 | return new PostgresProductRepository(postgresSpringDataProductRepository); 185 | } 186 | 187 | private InMemoryCustomerRepository getCustomerRepository() { 188 | return new InMemoryCustomerRepository(); 189 | } 190 | 191 | @Bean 192 | CustomerService customerService(PostgresSpringDataProductRepository postgresSpringDataProductRepository) { 193 | return new CustomerServiceImpl(getCustomerRepository(), 194 | postgresProductRepository(postgresSpringDataProductRepository)); 195 | } 196 | 197 | @Bean 198 | ProductService productService(PostgresSpringDataProductRepository postgresSpringDataProductRepository) { 199 | return new ProductServiceImpl(postgresProductRepository(postgresSpringDataProductRepository)); 200 | } 201 | ``` 202 | 203 | Note: if we want to use outside package beans, we need to explicit the package to configuration as below: 204 | ``` 205 | @ComponentScan(basePackages = { 206 | "com.rdelgatte.hexagonal.persistence.postgres", 207 | }) 208 | ``` 209 | 210 | ## Demo 211 | 212 | [See demo scenario](doc/demo.md) -------------------------------------------------------------------------------- /application/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext { 3 | springBootVersion = '2.1.0.RELEASE' 4 | } 5 | repositories { 6 | mavenCentral() 7 | } 8 | dependencies { 9 | classpath("org.springframework.boot:spring-boot-gradle-plugin:$springBootVersion") 10 | } 11 | } 12 | 13 | apply plugin: 'org.springframework.boot' 14 | apply plugin: 'io.spring.dependency-management' 15 | 16 | repositories { 17 | mavenCentral() 18 | } 19 | 20 | dependencies { 21 | implementation project(':domain') 22 | implementation project(':infrastructure:memory-persistence') 23 | implementation project(':infrastructure:postgres-persistence') 24 | implementation project(':infrastructure:rest-client') 25 | 26 | implementation "org.springframework.boot:spring-boot-starter-web:$springBootVersion" 27 | } 28 | // To avoid error when running gradle clean install for all modules - application is not installable 29 | install.enabled = false -------------------------------------------------------------------------------- /application/settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'application' 2 | 3 | -------------------------------------------------------------------------------- /application/src/main/java/com/rdelgatte/hexagonal/Hexagonal.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class Hexagonal { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(Hexagonal.class, args); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /application/src/main/java/com/rdelgatte/hexagonal/configuration/ApplicationConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.configuration; 2 | 3 | import com.rdelgatte.hexagonal.api.CustomerService; 4 | import com.rdelgatte.hexagonal.api.CustomerServiceImpl; 5 | import com.rdelgatte.hexagonal.api.ProductService; 6 | import com.rdelgatte.hexagonal.api.ProductServiceImpl; 7 | import com.rdelgatte.hexagonal.infrastructure.memory.InMemoryCustomerRepository; 8 | import com.rdelgatte.hexagonal.infrastructure.postgres.repository.PostgresProductRepository; 9 | import com.rdelgatte.hexagonal.infrastructure.postgres.repository.PostgresSpringDataProductRepository; 10 | import org.springframework.context.annotation.Bean; 11 | import org.springframework.context.annotation.Configuration; 12 | 13 | @Configuration 14 | public class ApplicationConfiguration { 15 | 16 | private PostgresProductRepository postgresProductRepository( 17 | PostgresSpringDataProductRepository postgresSpringDataProductRepository) { 18 | return new PostgresProductRepository(postgresSpringDataProductRepository); 19 | } 20 | 21 | private InMemoryCustomerRepository getCustomerRepository() { 22 | return new InMemoryCustomerRepository(); 23 | } 24 | 25 | @Bean 26 | CustomerService customerService(PostgresSpringDataProductRepository postgresSpringDataProductRepository) { 27 | return new CustomerServiceImpl(getCustomerRepository(), 28 | postgresProductRepository(postgresSpringDataProductRepository)); 29 | } 30 | 31 | @Bean 32 | ProductService productService(PostgresSpringDataProductRepository postgresSpringDataProductRepository) { 33 | return new ProductServiceImpl(postgresProductRepository(postgresSpringDataProductRepository)); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /application/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | # Postgres configuration 3 | datasource: 4 | url: jdbc:postgresql://${PG_HOST}:${PG_PORT}/${PG_DB_NAME} 5 | username: ${PG_USER_NAME} 6 | password: ${PG_PASSWORD} 7 | 8 | jpa: 9 | properties: 10 | hibernate: 11 | # The SQL dialect makes Hibernate generate better SQL for the chosen database 12 | dialect: org.hibernate.dialect.PostgreSQL9Dialect 13 | # Disable contextual creation (clob) not required and jdbc does not implement it (see LobCreatorBuilder) 14 | jdbc: 15 | lob: 16 | non_contextual_creation: true 17 | # Hibernate ddl auto (create, create-drop, validate, update) 18 | hibernate: 19 | ddl-auto: update -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext { 3 | junitVersion = '5.3.2' 4 | assertjVersion = '3.11.1' 5 | vavrVersion = '0.9.2' 6 | lombokVersion = '1.18.4' 7 | mockitoVersion = '2.23.0' 8 | pitestJunit5PluginVersion = '0.8' 9 | pitestPluginVersion = '1.3.0' 10 | } 11 | repositories { 12 | mavenCentral() 13 | } 14 | dependencies { 15 | classpath("info.solidsoft.gradle.pitest:gradle-pitest-plugin:$pitestPluginVersion") 16 | } 17 | } 18 | 19 | allprojects { 20 | group 'com.rdelgatte.hexagonal' 21 | version = '1.0-SNAPSHOT' 22 | 23 | repositories { 24 | mavenCentral() 25 | } 26 | 27 | plugins.withType(JavaPlugin) { 28 | dependencies { 29 | annotationProcessor "org.projectlombok:lombok:$lombokVersion" 30 | compileOnly "org.projectlombok:lombok:$lombokVersion" 31 | testImplementation "org.junit.jupiter:junit-jupiter-api:$junitVersion" 32 | testImplementation "org.junit.jupiter:junit-jupiter-engine:$junitVersion" 33 | implementation "io.vavr:vavr:$vavrVersion" 34 | testImplementation "org.assertj:assertj-core:$assertjVersion" 35 | testImplementation "org.mockito:mockito-core:$mockitoVersion" 36 | testImplementation "org.mockito:mockito-junit-jupiter:$mockitoVersion" 37 | testImplementation "org.pitest:pitest-junit5-plugin:$pitestJunit5PluginVersion" 38 | } 39 | } 40 | } 41 | 42 | subprojects { 43 | apply plugin: 'java' 44 | apply plugin: 'maven' 45 | apply plugin: 'maven-publish' 46 | apply plugin: 'jacoco' 47 | 48 | // Configuring jacoco version to use with JDK 11 49 | jacoco { 50 | toolVersion = "0.8.2" 51 | } 52 | 53 | // Configuring test to use Junit5 54 | test { 55 | useJUnitPlatform() 56 | } 57 | 58 | publishing { 59 | publications { 60 | mavenJava(MavenPublication) { 61 | from components.java 62 | } 63 | } 64 | } 65 | } 66 | 67 | 68 | -------------------------------------------------------------------------------- /console-application/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext { 3 | springBootVersion = '2.1.0.RELEASE' 4 | } 5 | repositories { 6 | mavenCentral() 7 | } 8 | dependencies { 9 | classpath("org.springframework.boot:spring-boot-gradle-plugin:$springBootVersion") 10 | } 11 | } 12 | 13 | apply plugin: 'org.springframework.boot' 14 | apply plugin: 'io.spring.dependency-management' 15 | 16 | repositories { 17 | mavenCentral() 18 | } 19 | 20 | dependencies { 21 | implementation project(':domain') 22 | implementation project(':infrastructure:memory-persistence') 23 | implementation project(':infrastructure:postgres-persistence') 24 | implementation project(':infrastructure:rest-client') 25 | 26 | implementation "org.springframework.boot:spring-boot-starter:$springBootVersion" 27 | } 28 | 29 | // To avoid error when running gradle clean install for all modules - application is not installable 30 | install.enabled = false -------------------------------------------------------------------------------- /console-application/src/main/java/com/rdelgatte/hexagonal/HexagonalRunner.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal; 2 | 3 | import static java.util.UUID.randomUUID; 4 | 5 | import com.rdelgatte.hexagonal.api.CustomerService; 6 | import com.rdelgatte.hexagonal.api.ProductService; 7 | import com.rdelgatte.hexagonal.domain.Customer; 8 | import com.rdelgatte.hexagonal.domain.Product; 9 | import io.vavr.CheckedFunction0; 10 | import io.vavr.control.Try; 11 | import java.math.BigDecimal; 12 | import lombok.AllArgsConstructor; 13 | import lombok.extern.slf4j.Slf4j; 14 | import org.springframework.boot.CommandLineRunner; 15 | import org.springframework.boot.SpringApplication; 16 | import org.springframework.boot.autoconfigure.SpringBootApplication; 17 | 18 | @SpringBootApplication 19 | @Slf4j 20 | @AllArgsConstructor 21 | public class HexagonalRunner implements CommandLineRunner { 22 | 23 | private ProductService productService; 24 | private CustomerService customerService; 25 | 26 | public static void main(String[] args) { 27 | SpringApplication.run(HexagonalRunner.class, args); 28 | } 29 | 30 | @Override 31 | public void run(String... args) { 32 | final Product product1 = new Product(randomUUID(), "1", "Free product", BigDecimal.valueOf(10.95)); 33 | final Product product2 = new Product(randomUUID(), "2", "Product we give you money to buy it", 34 | BigDecimal.valueOf(20.50)); 35 | final Product product3 = new Product(randomUUID(), "3", "Real product", BigDecimal.valueOf(13.05)); 36 | productAction(() -> productService.createProduct(product1)); 37 | productAction(() -> productService.createProduct(product2)); 38 | productAction(() -> productService.createProduct(product3)); 39 | 40 | String customerName = "Rémi"; 41 | customerAction(() -> customerService.signUp(customerName)); 42 | customerAction(() -> customerService.addProductToCart(customerName, "1")); 43 | customerAction(() -> customerService.addProductToCart(customerName, "2")); 44 | customerAction(() -> customerService.addProductToCart(customerName, "3")); 45 | } 46 | 47 | private void productAction(CheckedFunction0 productFunction) { 48 | Try.of(productFunction) 49 | .onFailure(throwable -> log.error(throwable.getMessage())) 50 | .map(Product::toString) 51 | .onSuccess(log::info); 52 | } 53 | 54 | private void customerAction(CheckedFunction0 customerFunction) { 55 | Try.of(customerFunction) 56 | .onFailure(throwable -> log.error(throwable.getMessage())) 57 | .map(Customer::toString) 58 | .onSuccess(log::info); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /console-application/src/main/java/com/rdelgatte/hexagonal/configuration/ApplicationConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.configuration; 2 | 3 | import com.rdelgatte.hexagonal.api.CustomerService; 4 | import com.rdelgatte.hexagonal.api.CustomerServiceImpl; 5 | import com.rdelgatte.hexagonal.api.ProductService; 6 | import com.rdelgatte.hexagonal.api.ProductServiceImpl; 7 | import com.rdelgatte.hexagonal.infrastructure.memory.InMemoryCustomerRepository; 8 | import com.rdelgatte.hexagonal.infrastructure.postgres.repository.PostgresProductRepository; 9 | import com.rdelgatte.hexagonal.infrastructure.postgres.repository.PostgresSpringDataProductRepository; 10 | import org.springframework.context.annotation.Bean; 11 | import org.springframework.context.annotation.Configuration; 12 | 13 | @Configuration 14 | public class ApplicationConfiguration { 15 | 16 | private PostgresProductRepository postgresProductRepository( 17 | PostgresSpringDataProductRepository postgresSpringDataProductRepository) { 18 | return new PostgresProductRepository(postgresSpringDataProductRepository); 19 | } 20 | 21 | private InMemoryCustomerRepository getCustomerRepository() { 22 | return new InMemoryCustomerRepository(); 23 | } 24 | 25 | @Bean 26 | CustomerService customerService(PostgresSpringDataProductRepository postgresSpringDataProductRepository) { 27 | return new CustomerServiceImpl(getCustomerRepository(), 28 | postgresProductRepository(postgresSpringDataProductRepository)); 29 | } 30 | 31 | @Bean 32 | ProductService productService(PostgresSpringDataProductRepository postgresSpringDataProductRepository) { 33 | return new ProductServiceImpl(postgresProductRepository(postgresSpringDataProductRepository)); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /console-application/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | # Postgres configuration 3 | datasource: 4 | url: jdbc:postgresql://${PG_HOST}:${PG_PORT}/${PG_DB_NAME} 5 | username: ${PG_USER_NAME} 6 | password: ${PG_PASSWORD} 7 | 8 | jpa: 9 | properties: 10 | hibernate: 11 | # The SQL dialect makes Hibernate generate better SQL for the chosen database 12 | dialect: org.hibernate.dialect.PostgreSQL9Dialect 13 | # Disable contextual creation (clob) not required and jdbc does not implement it (see LobCreatorBuilder) 14 | jdbc: 15 | lob: 16 | non_contextual_creation: true 17 | # Hibernate ddl auto (create, create-drop, validate, update) 18 | hibernate: 19 | ddl-auto: update 20 | logging: 21 | level.root: info 22 | pattern: 23 | console: "%clr(%-5p) %clr(--){blue} %clr(%m){faint}%n" -------------------------------------------------------------------------------- /doc/demo.md: -------------------------------------------------------------------------------- 1 | # Demo 2 | 3 | ## Step 1 - Domain 4 | 5 | It is a simple module which is composed of: 6 | - `domain`: Domain POJOs 7 | - `api`: Application Provider Interface - Interfaces which expose services to interact with the domain (customer sign-up, adding product to customer's cart, create product...) 8 | - `spi`: Service Provider Interface - Interfaces which expose services that are required by the domain to manage data (eg. persistence) 9 | 10 | ![Hexagonal schema](hexagonal-1.png) 11 | 12 | **NOTHING** should enter domain! It has no dependency to implementation frameworks (spring / jpa / ...). 13 | ![Hexagonal out flows](hexagonal-2.png) 14 | 15 | ![Hexagonal forbidden](hexagonal-3.png) 16 | 17 | Examples of modules to interact with the domain: 18 | ![Hexagonal schema](hexagonal-4.png) 19 | 20 | ## Step 2 - Memory-persistence module 21 | 22 | Module to implement the interfaces exposed by the domain (as SPI) so we store the `products` and `customers` in memory. 23 | To do so, we need to implement both repositories as below: 24 | 25 | ``` 26 | public class InMemoryProductRepository implements ProductRepository { 27 | 28 | private List inMemoryProducts = API.List(); 29 | 30 | public Product addProduct(Product product) { 31 | this.inMemoryProducts = getInMemoryProducts().append(product); 32 | return product; 33 | } 34 | 35 | public void deleteProduct(UUID productId) { 36 | this.inMemoryProducts = getInMemoryProducts().filter(product -> !product.getId().equals(productId)); 37 | } 38 | 39 | public Option findProductByCode(String code) { 40 | return getInMemoryProducts().find(product -> product.getCode().equals(code)); 41 | } 42 | 43 | public List findAllProducts() { 44 | return getInMemoryProducts(); 45 | } 46 | } 47 | ``` 48 | 49 | ## Step 3 - First application to use services 50 | 51 | Console application which uses dependencies to domain and this in-memory persistence module: 52 | - Quickly test business logic in an application 53 | - Fast feedback 54 | 55 | ## Step 4 - Changing business logic 56 | 57 | Ok we can now create product and customers but we can create product without price :-( 58 | 59 | Let's add a new rule: **Prevent product to be created without price | with price <= 0** 60 | 61 | ## Step 5 - Rest client 62 | 63 | Module to implement a spring boot rest client module to expose some endpoints using the domain services. 64 | 65 | It only contains: 66 | - `CustomerController.java`: controller to handle actions to do about customers (create / add product to cart / find) 67 | - `ProductControler.java`: controller to handle actions to do about products (create / update / find) 68 | 69 | --- 70 | **Note:** 71 | - *It is not necessary to implement all services exposed by the domain.* 72 | - *We can also transform the exposed items using DTO* 73 | --- 74 | 75 | ## Step 6 - Postgres 76 | 77 | Module to implement the persistence system using Postgres as for `memory-persistence`: 78 | ``` 79 | @Repository 80 | @AllArgsConstructor 81 | public class PostgresProductRepository implements ProductRepository { 82 | 83 | private PostgresSpringDataProductRepository postgresSpringDataProductRepository; 84 | 85 | @Override 86 | public Product addProduct(Product product) { 87 | return postgresSpringDataProductRepository.save(PostgresProduct.fromProduct(product)) 88 | .toProduct(); 89 | } 90 | 91 | @Override 92 | public void deleteProduct(UUID productId) { 93 | postgresSpringDataProductRepository.deleteById(productId); 94 | } 95 | 96 | @Override 97 | public Option findProductByCode(String code) { 98 | return ofOptional(postgresSpringDataProductRepository.findByCode(code).map(PostgresProduct::toProduct)); 99 | } 100 | 101 | @Override 102 | public List findAllProducts() { 103 | return ofAll(postgresSpringDataProductRepository.findAll()) 104 | .map(PostgresProduct::toProduct); 105 | } 106 | } 107 | ``` 108 | 109 | Here we need to handle DAO so objects stored in Postgres are defined as below: 110 | 111 | ``` 112 | @Entity 113 | @Data 114 | @AllArgsConstructor 115 | @NoArgsConstructor 116 | @Wither 117 | public class PostgresProduct { 118 | 119 | @Id 120 | private UUID id = randomUUID(); 121 | private String code = ""; 122 | private String label = ""; 123 | private BigDecimal price = ZERO; 124 | } 125 | ``` 126 | 127 | ## Step 7 - Web application 128 | 129 | Spring boot application which uses dependencies to the `domain` but also `postgres-persistence`, `memory-persistence` and `rest-client` so we can interact with the domain using `curl` commands and store data in-memory. 130 | 131 | - Create a product: `curl --header "Content-Type: application/json" --request POST --data '{ "code": "1616", "label": "Easybreath", "price": 25.95 }' http://localhost:8080/products` 132 | - List products: `curl --header "Content-Type: application/json" --request GET http://localhost:8080/products` 133 | - Find product: `curl --header "Content-Type: application/json" --request GET http://localhost:8080/products/1616` 134 | - Create a customer: `curl --header "Content-Type: application/json" --request POST --data 'rdelgatte' http://localhost:8080/customers` 135 | - Find customer by login: `curl --header "Content-Type: application/json" --request GET http://localhost:8080/customers/rdelgatte` 136 | - Add product to customer's cart: `curl --header "Content-Type: application/json" --request PATCH --data '1616' http://localhost:8080/customers/rdelgatte` 137 | 138 | ### Configuration / Dependencies 139 | We need to explicitly define the `ProductService` and `CustomerService` so we provide the persistence module to use for each. 140 | -------------------------------------------------------------------------------- /doc/hexagonal-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdelgatte/spring-hexagonal-example/126b09cbe17fc7e93f06b7e350c7e1f4f702d8a3/doc/hexagonal-1.png -------------------------------------------------------------------------------- /doc/hexagonal-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdelgatte/spring-hexagonal-example/126b09cbe17fc7e93f06b7e350c7e1f4f702d8a3/doc/hexagonal-2.png -------------------------------------------------------------------------------- /doc/hexagonal-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdelgatte/spring-hexagonal-example/126b09cbe17fc7e93f06b7e350c7e1f4f702d8a3/doc/hexagonal-3.png -------------------------------------------------------------------------------- /doc/hexagonal-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdelgatte/spring-hexagonal-example/126b09cbe17fc7e93f06b7e350c7e1f4f702d8a3/doc/hexagonal-4.png -------------------------------------------------------------------------------- /doc/uml.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdelgatte/spring-hexagonal-example/126b09cbe17fc7e93f06b7e350c7e1f4f702d8a3/doc/uml.png -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | postgres: 4 | image: postgres:11.2-alpine 5 | environment: 6 | - POSTGRES_PASSWORD=hexagonal 7 | - POSTGRES_USER=hexagonal 8 | - POSTGRES_DB=hexagonal-db 9 | ports: 10 | - 5432:5432 11 | -------------------------------------------------------------------------------- /domain/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'info.solidsoft.pitest' 2 | 3 | /** 4 | * Pitest configuration for mutation testing: 5 | * - Using last version of Pitest (for jdk11 support) 6 | * - Using plugin for junit5 support 7 | * - Export to XML (for Sonar) + HTML 8 | * - Overriding reports each run 9 | * - Target only services classes as domain and SPI are dumb POJOs 10 | */ 11 | pitest { 12 | pitestVersion = '1.4.3' 13 | testPlugin = 'junit5' 14 | outputFormats = ['XML', 'HTML'] 15 | timestampedReports = false 16 | threads = 4 17 | targetClasses = ['com.rdelgatte.hexagonal.api.*'] 18 | } -------------------------------------------------------------------------------- /domain/settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'domain' 2 | 3 | -------------------------------------------------------------------------------- /domain/src/main/java/com/rdelgatte/hexagonal/api/CustomerService.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.api; 2 | 3 | import com.rdelgatte.hexagonal.domain.Customer; 4 | import io.vavr.control.Option; 5 | 6 | public interface CustomerService { 7 | 8 | Customer signUp(String name); 9 | 10 | Option findCustomer(String name); 11 | 12 | Customer addProductToCart(String name, String productCode); 13 | 14 | Customer emptyCart(String name); 15 | } 16 | -------------------------------------------------------------------------------- /domain/src/main/java/com/rdelgatte/hexagonal/api/CustomerServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.api; 2 | 3 | import static io.vavr.API.List; 4 | 5 | import com.rdelgatte.hexagonal.domain.Customer; 6 | import com.rdelgatte.hexagonal.domain.Product; 7 | import com.rdelgatte.hexagonal.spi.CustomerRepository; 8 | import com.rdelgatte.hexagonal.spi.ProductRepository; 9 | import io.vavr.control.Option; 10 | import lombok.AllArgsConstructor; 11 | 12 | @AllArgsConstructor 13 | public class CustomerServiceImpl implements CustomerService { 14 | 15 | private final CustomerRepository customerRepository; 16 | private final ProductRepository productRepository; 17 | 18 | @Override 19 | public Option findCustomer(String login) { 20 | return customerRepository.findByLogin(login); 21 | } 22 | 23 | @Override 24 | public Customer signUp(String login) { 25 | if (login.isBlank()) { 26 | throw new IllegalArgumentException("Customer name should not be blank"); 27 | } 28 | if (customerRepository.findByLogin(login).isDefined()) { 29 | throw new IllegalArgumentException("Customer already exists so you can't sign in"); 30 | } 31 | return customerRepository.save(new Customer().withName(login)); 32 | } 33 | 34 | @Override 35 | public Customer addProductToCart(String login, String productCode) { 36 | Customer customer = customerRepository.findByLogin(login) 37 | .getOrElseThrow(() -> new IllegalArgumentException("The customer does not exist")); 38 | 39 | Product product = productRepository.findProductByCode(productCode) 40 | .getOrElseThrow(() -> new IllegalArgumentException("The product does not exist")); 41 | 42 | Customer customerToUpdate = customer.withCart(customer.getCart().append(product)); 43 | return customerRepository.save(customerToUpdate); 44 | } 45 | 46 | @Override 47 | public Customer emptyCart(String login) { 48 | Customer customerToUpdate = customerRepository.findByLogin(login) 49 | .map(customer -> customer.withCart(List())) 50 | .getOrElseThrow(() -> new IllegalArgumentException("The customer does not exist")); 51 | return customerRepository.save(customerToUpdate); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /domain/src/main/java/com/rdelgatte/hexagonal/api/ProductService.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.api; 2 | 3 | import com.rdelgatte.hexagonal.domain.Product; 4 | import io.vavr.collection.List; 5 | import io.vavr.control.Option; 6 | 7 | public interface ProductService { 8 | 9 | Product createProduct(Product product); 10 | 11 | void deleteProduct(String code); 12 | 13 | List getAllProducts(); 14 | 15 | Option findProductByCode(String code); 16 | } 17 | -------------------------------------------------------------------------------- /domain/src/main/java/com/rdelgatte/hexagonal/api/ProductServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.api; 2 | 3 | import static java.math.BigDecimal.ZERO; 4 | 5 | import com.rdelgatte.hexagonal.domain.Product; 6 | import com.rdelgatte.hexagonal.spi.ProductRepository; 7 | import io.vavr.collection.List; 8 | import io.vavr.control.Option; 9 | import lombok.AllArgsConstructor; 10 | import lombok.NonNull; 11 | 12 | @AllArgsConstructor 13 | public class ProductServiceImpl implements ProductService { 14 | 15 | private final ProductRepository productRepository; 16 | 17 | public Product createProduct(Product product) { 18 | if (product.getCode().isEmpty()) { 19 | throw new IllegalArgumentException("There is no code for the product"); 20 | } 21 | if (product.getPrice().compareTo(ZERO) <= 0) { 22 | throw new IllegalArgumentException("Product should be priced"); 23 | } 24 | Option productById = productRepository.findProductByCode(product.getCode()); 25 | if (productById.isDefined()) { 26 | throw new IllegalArgumentException( 27 | "Product " + product.getCode() + " already exists so you can't create it"); 28 | } 29 | return productRepository.addProduct(product); 30 | } 31 | 32 | public void deleteProduct(@NonNull String code) { 33 | Option productByCode = findProductByCode(code); 34 | if (productByCode.isEmpty()) { 35 | throw new IllegalArgumentException("Product " + code + " does not exist"); 36 | } 37 | productRepository.deleteProduct(productByCode.get().getId()); 38 | } 39 | 40 | public List getAllProducts() { 41 | return productRepository.findAllProducts(); 42 | } 43 | 44 | public Option findProductByCode(String code) { 45 | return productRepository.findProductByCode(code); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /domain/src/main/java/com/rdelgatte/hexagonal/domain/Customer.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.domain; 2 | 3 | import static io.vavr.API.List; 4 | import static java.util.UUID.randomUUID; 5 | 6 | import io.vavr.collection.List; 7 | import io.vavr.control.Option; 8 | import java.math.BigDecimal; 9 | import java.math.RoundingMode; 10 | import java.util.UUID; 11 | import lombok.AllArgsConstructor; 12 | import lombok.Data; 13 | import lombok.NoArgsConstructor; 14 | import lombok.experimental.Wither; 15 | 16 | @Data 17 | @AllArgsConstructor 18 | @NoArgsConstructor 19 | @Wither 20 | public class Customer { 21 | 22 | private UUID id = randomUUID(); 23 | private String name = ""; 24 | private List cart = List(); 25 | 26 | public BigDecimal getCartTotal() { 27 | return cart.map(Product::getPrice) 28 | .foldLeft(BigDecimal.ZERO, BigDecimal::add) 29 | .setScale(2, RoundingMode.CEILING); 30 | } 31 | 32 | public String toString() { 33 | return Option.when(cart.isEmpty(), () -> name) 34 | .getOrElse( 35 | name + " has products in cart (" + getCartTotal() + "€)= " + cart.map(Product::toString).map(s -> s + "\n") 36 | .fold("\n", String::concat)); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /domain/src/main/java/com/rdelgatte/hexagonal/domain/Product.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.domain; 2 | 3 | import static java.util.UUID.randomUUID; 4 | 5 | import java.math.BigDecimal; 6 | import java.util.UUID; 7 | import lombok.AllArgsConstructor; 8 | import lombok.Data; 9 | import lombok.NoArgsConstructor; 10 | import lombok.experimental.Wither; 11 | 12 | @Data 13 | @AllArgsConstructor 14 | @NoArgsConstructor 15 | @Wither 16 | public class Product { 17 | 18 | private UUID id = randomUUID(); 19 | private String code = ""; 20 | private String label = ""; 21 | private BigDecimal price = BigDecimal.ZERO; 22 | 23 | public String toString() { 24 | return code + ": " + label + " (price: " + price.toString() + "€)"; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /domain/src/main/java/com/rdelgatte/hexagonal/spi/CustomerRepository.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.spi; 2 | 3 | import com.rdelgatte.hexagonal.domain.Customer; 4 | import io.vavr.collection.List; 5 | import io.vavr.control.Option; 6 | import java.util.UUID; 7 | 8 | public interface CustomerRepository { 9 | 10 | Customer save(Customer customer); 11 | 12 | Option findById(UUID id); 13 | 14 | Option findByLogin(String login); 15 | 16 | List findAll(); 17 | } 18 | -------------------------------------------------------------------------------- /domain/src/main/java/com/rdelgatte/hexagonal/spi/ProductRepository.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.spi; 2 | 3 | import com.rdelgatte.hexagonal.domain.Product; 4 | import io.vavr.collection.List; 5 | import io.vavr.control.Option; 6 | import java.util.UUID; 7 | 8 | public interface ProductRepository { 9 | 10 | Product addProduct(Product product); 11 | 12 | void deleteProduct(UUID productId); 13 | 14 | Option findProductByCode(String code); 15 | 16 | List findAllProducts(); 17 | } 18 | -------------------------------------------------------------------------------- /domain/src/test/java/com/rdelgatte/hexagonal/api/CustomerServiceImplTest.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.api; 2 | 3 | import static io.vavr.API.List; 4 | import static io.vavr.API.None; 5 | import static io.vavr.API.Option; 6 | import static java.util.UUID.randomUUID; 7 | import static org.assertj.core.api.Assertions.assertThat; 8 | import static org.junit.jupiter.api.Assertions.assertThrows; 9 | import static org.junit.jupiter.api.Assertions.assertTrue; 10 | import static org.mockito.ArgumentMatchers.any; 11 | import static org.mockito.Mockito.verify; 12 | import static org.mockito.Mockito.verifyNoMoreInteractions; 13 | import static org.mockito.Mockito.verifyZeroInteractions; 14 | import static org.mockito.Mockito.when; 15 | 16 | import com.rdelgatte.hexagonal.domain.Customer; 17 | import com.rdelgatte.hexagonal.domain.Product; 18 | import com.rdelgatte.hexagonal.spi.CustomerRepository; 19 | import com.rdelgatte.hexagonal.spi.ProductRepository; 20 | import java.math.BigDecimal; 21 | import java.util.UUID; 22 | import org.junit.jupiter.api.BeforeEach; 23 | import org.junit.jupiter.api.Test; 24 | import org.junit.jupiter.api.extension.ExtendWith; 25 | import org.mockito.ArgumentCaptor; 26 | import org.mockito.Captor; 27 | import org.mockito.Mock; 28 | import org.mockito.junit.jupiter.MockitoExtension; 29 | 30 | @ExtendWith(MockitoExtension.class) 31 | class CustomerServiceImplTest { 32 | 33 | private static final UUID ANY_PRODUCT_ID = randomUUID(); 34 | private static final UUID ANY_OTHER_PRODUCT_ID = randomUUID(); 35 | private static final String ANY_PRODUCT_CODE = "ANY_PRODUCT_CODE"; 36 | private static final String ANY_OTHER_PRODUCT_CODE = "ANY_OTHER_PRODUCT_CODE"; 37 | private static final String ANY_LABEL = "ANY_LABEL"; 38 | private static final String ANY_OTHER_LABEL = "ANY_OTHER_LABEL"; 39 | private static final Product ANY_PRODUCT = new Product(ANY_PRODUCT_ID, ANY_PRODUCT_CODE, ANY_LABEL, 40 | BigDecimal.valueOf(13.40)); 41 | private static final Product ANY_OTHER_PRODUCT = new Product(ANY_OTHER_PRODUCT_ID, ANY_OTHER_PRODUCT_CODE, 42 | ANY_OTHER_LABEL, BigDecimal.valueOf(5.23)); 43 | private static final UUID ANY_CUSTOMER_ID = randomUUID(); 44 | private static final String ANY_NAME = "ANY_NAME"; 45 | private static final Customer ANY_CUSTOMER = new Customer(ANY_CUSTOMER_ID, ANY_NAME, List()); 46 | 47 | private CustomerServiceImpl cut; 48 | @Mock 49 | private CustomerRepository customerRepositoryMock; 50 | @Mock 51 | private ProductRepository productRepositoryMock; 52 | @Captor 53 | private ArgumentCaptor customerCaptor; 54 | 55 | @BeforeEach 56 | void setUp() { 57 | cut = new CustomerServiceImpl(customerRepositoryMock, productRepositoryMock); 58 | } 59 | 60 | /** 61 | * {@link CustomerServiceImpl#signUp(String)} 62 | */ 63 | @Test 64 | void customerAlreadyExists_throwsException() { 65 | when(customerRepositoryMock.findByLogin(ANY_NAME)).thenReturn(Option(ANY_CUSTOMER)); 66 | 67 | IllegalArgumentException illegalArgumentException = assertThrows(IllegalArgumentException.class, 68 | () -> cut.signUp(ANY_NAME)); 69 | assertThat(illegalArgumentException.getMessage()).isEqualTo("Customer already exists so you can't sign in"); 70 | verifyNoMoreInteractions(customerRepositoryMock); 71 | verifyZeroInteractions(productRepositoryMock); 72 | } 73 | 74 | @Test 75 | void blankLogin_throwsException() { 76 | IllegalArgumentException illegalArgumentException = assertThrows(IllegalArgumentException.class, 77 | () -> cut.signUp("")); 78 | 79 | assertThat(illegalArgumentException.getMessage()).isEqualTo("Customer name should not be blank"); 80 | verifyNoMoreInteractions(customerRepositoryMock); 81 | verifyZeroInteractions(productRepositoryMock); 82 | } 83 | 84 | @Test 85 | void validUnknownCustomer_signIn() { 86 | Customer expected = new Customer().withName(ANY_NAME); 87 | when(customerRepositoryMock.findByLogin(ANY_NAME)).thenReturn(None()); 88 | when(customerRepositoryMock.save(any())).thenReturn(expected); 89 | 90 | Customer customer = cut.signUp(ANY_NAME); 91 | 92 | verify(customerRepositoryMock).save(customerCaptor.capture()); 93 | assertThat(customerCaptor.getValue().getName()).isEqualTo(ANY_NAME); 94 | assertThat(customerCaptor.getValue().getCart()).isEqualTo(List()); 95 | assertThat(customer.getName()).isEqualTo(ANY_NAME); 96 | verifyZeroInteractions(productRepositoryMock); 97 | } 98 | 99 | /** 100 | * {@link CustomerServiceImpl#addProductToCart(String, String)} 101 | */ 102 | 103 | @Test 104 | void unknownCustomer_addProductToCart_throwsIllegalArgumentException() { 105 | when(customerRepositoryMock.findByLogin(ANY_NAME)).thenReturn(None()); 106 | 107 | IllegalArgumentException illegalArgumentException = assertThrows(IllegalArgumentException.class, 108 | () -> cut.addProductToCart(ANY_NAME, ANY_PRODUCT_CODE)); 109 | assertThat(illegalArgumentException.getMessage()).isEqualTo("The customer does not exist"); 110 | verifyNoMoreInteractions(customerRepositoryMock); 111 | verifyZeroInteractions(productRepositoryMock); 112 | } 113 | 114 | @Test 115 | void unknownProduct_addProductToCart_throwsIllegalArgumentException() { 116 | when(customerRepositoryMock.findByLogin(ANY_NAME)).thenReturn(Option(ANY_CUSTOMER)); 117 | when(productRepositoryMock.findProductByCode(ANY_PRODUCT_CODE)).thenReturn(None()); 118 | 119 | IllegalArgumentException illegalArgumentException = assertThrows(IllegalArgumentException.class, 120 | () -> cut.addProductToCart(ANY_NAME, ANY_PRODUCT_CODE)); 121 | assertThat(illegalArgumentException.getMessage()).isEqualTo("The product does not exist"); 122 | verifyNoMoreInteractions(customerRepositoryMock); 123 | verifyNoMoreInteractions(productRepositoryMock); 124 | } 125 | 126 | @Test 127 | void existingProductAndCustomer_addProductToCart_returnsUpdatedCustomer() { 128 | Customer expected = ANY_CUSTOMER.withCart(List(ANY_PRODUCT)); 129 | when(customerRepositoryMock.findByLogin(ANY_NAME)).thenReturn(Option(ANY_CUSTOMER)); 130 | when(productRepositoryMock.findProductByCode(ANY_PRODUCT_CODE)).thenReturn(Option(ANY_PRODUCT)); 131 | when(customerRepositoryMock.save(any())).thenReturn(expected); 132 | 133 | Customer customer = cut.addProductToCart(ANY_NAME, ANY_PRODUCT_CODE); 134 | verify(customerRepositoryMock).save(customerCaptor.capture()); 135 | Customer savedCustomer = customerCaptor.getValue(); 136 | assertThat(savedCustomer.getCart()).containsExactly(ANY_PRODUCT); 137 | assertThat(customer.getCart()).containsExactly(ANY_PRODUCT); 138 | } 139 | 140 | @Test 141 | void existingProductAndCustomerWithCart_addProductToCart_returnsUpdatedCustomer() { 142 | Customer existing = ANY_CUSTOMER.withCart(List(ANY_PRODUCT)); 143 | Customer expected = existing.withCart(List(ANY_PRODUCT, ANY_OTHER_PRODUCT)); 144 | when(customerRepositoryMock.findByLogin(ANY_NAME)).thenReturn(Option(existing)); 145 | when(productRepositoryMock.findProductByCode(ANY_OTHER_PRODUCT_CODE)).thenReturn(Option(ANY_OTHER_PRODUCT)); 146 | when(customerRepositoryMock.save(any())).thenReturn(expected); 147 | 148 | Customer customer = cut.addProductToCart(ANY_NAME, ANY_OTHER_PRODUCT_CODE); 149 | verify(customerRepositoryMock).save(customerCaptor.capture()); 150 | Customer savedCustomer = customerCaptor.getValue(); 151 | assertThat(savedCustomer.getCart()).containsExactly(ANY_PRODUCT, ANY_OTHER_PRODUCT); 152 | assertThat(customer.getCart()).containsExactly(ANY_PRODUCT, ANY_OTHER_PRODUCT); 153 | } 154 | 155 | /** 156 | * {@link CustomerServiceImpl#emptyCart(String)} 157 | */ 158 | 159 | @Test 160 | void unknownCustomer_emptyCart_throwsIllegalArgumentException() { 161 | when(customerRepositoryMock.findByLogin(ANY_NAME)).thenReturn(None()); 162 | 163 | IllegalArgumentException illegalArgumentException = assertThrows(IllegalArgumentException.class, 164 | () -> cut.emptyCart(ANY_NAME)); 165 | assertThat(illegalArgumentException.getMessage()).isEqualTo("The customer does not exist"); 166 | verifyNoMoreInteractions(customerRepositoryMock); 167 | } 168 | 169 | @Test 170 | void existingCustomer_emptyCart_returnsCustomerWithEmptiedCart() { 171 | Customer expected = ANY_CUSTOMER.withCart(List()); 172 | when(customerRepositoryMock.findByLogin(ANY_NAME)).thenReturn(Option(ANY_CUSTOMER)); 173 | when(customerRepositoryMock.save(any())).thenReturn(expected); 174 | 175 | Customer customer = cut.emptyCart(ANY_NAME); 176 | assertThat(customer).isEqualTo(expected); 177 | verify(customerRepositoryMock).save(expected); 178 | } 179 | 180 | /** 181 | * {@link CustomerServiceImpl#findCustomer(String)} 182 | */ 183 | 184 | @Test 185 | void unknownCustomer_findCustomer_returnsNone() { 186 | when(customerRepositoryMock.findByLogin(ANY_NAME)).thenReturn(None()); 187 | 188 | assertTrue(cut.findCustomer(ANY_NAME).isEmpty()); 189 | } 190 | 191 | @Test 192 | void existingCustomer_findCustomer_returnsCustomer() { 193 | when(customerRepositoryMock.findByLogin(ANY_NAME)).thenReturn(Option(ANY_CUSTOMER)); 194 | 195 | assertThat(cut.findCustomer(ANY_NAME)).isEqualTo(Option(ANY_CUSTOMER)); 196 | } 197 | 198 | /** 199 | * {@link Customer#getCartTotal()} 200 | */ 201 | 202 | @Test 203 | void emptyCart_getCartTotal_returnsZero() { 204 | assertThat(ANY_CUSTOMER.withCart(List()).getCartTotal()).isZero(); 205 | } 206 | 207 | @Test 208 | void cartWithProducts_getCartTotal_returnsSumOfProductPrices() { 209 | assertThat(ANY_CUSTOMER.withCart(List(ANY_PRODUCT, ANY_OTHER_PRODUCT)).getCartTotal()) 210 | .isEqualTo(BigDecimal.valueOf(18.63)); 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /domain/src/test/java/com/rdelgatte/hexagonal/api/ProductServiceImplTest.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.api; 2 | 3 | 4 | import static io.vavr.API.List; 5 | import static io.vavr.API.None; 6 | import static io.vavr.API.Option; 7 | import static org.assertj.core.api.Assertions.assertThat; 8 | import static org.junit.jupiter.api.Assertions.assertThrows; 9 | import static org.mockito.Mockito.verify; 10 | import static org.mockito.Mockito.verifyNoMoreInteractions; 11 | import static org.mockito.Mockito.verifyZeroInteractions; 12 | import static org.mockito.Mockito.when; 13 | 14 | import com.rdelgatte.hexagonal.domain.Product; 15 | import com.rdelgatte.hexagonal.spi.ProductRepository; 16 | import java.math.BigDecimal; 17 | import org.assertj.core.api.Assertions; 18 | import org.junit.jupiter.api.BeforeEach; 19 | import org.junit.jupiter.api.Test; 20 | import org.junit.jupiter.api.extension.ExtendWith; 21 | import org.mockito.Mock; 22 | import org.mockito.junit.jupiter.MockitoExtension; 23 | 24 | @ExtendWith(MockitoExtension.class) 25 | class ProductServiceImplTest { 26 | 27 | private static final String ANY_PRODUCT_CODE = "ANY_PRODUCT_CODE"; 28 | private static final String ANY_PRODUCT_LABEL = "ANY_PRODUCT_LABEL"; 29 | private static final Product ANY_PRODUCT = new Product() 30 | .withCode(ANY_PRODUCT_CODE) 31 | .withLabel(ANY_PRODUCT_LABEL) 32 | .withPrice(BigDecimal.TEN); 33 | private ProductServiceImpl cut; 34 | @Mock 35 | private ProductRepository productRepositoryMock; 36 | 37 | @BeforeEach 38 | void setUp() { 39 | cut = new ProductServiceImpl(productRepositoryMock); 40 | } 41 | 42 | /** 43 | * {@link ProductServiceImpl#createProduct(Product)} 44 | */ 45 | @Test 46 | void productAlreadyExists_throwsException() { 47 | when(productRepositoryMock.findProductByCode(ANY_PRODUCT_CODE)).thenReturn(Option(ANY_PRODUCT)); 48 | 49 | IllegalArgumentException illegalArgumentException = assertThrows(IllegalArgumentException.class, 50 | () -> cut.createProduct(ANY_PRODUCT)); 51 | assertThat(illegalArgumentException.getMessage()) 52 | .isEqualTo("Product " + ANY_PRODUCT.getCode() + " already exists so you can't create it"); 53 | verifyNoMoreInteractions(productRepositoryMock); 54 | } 55 | 56 | @Test 57 | void productWithoutCode_throwsException() { 58 | IllegalArgumentException illegalArgumentException = assertThrows(IllegalArgumentException.class, 59 | () -> cut.createProduct(new Product())); 60 | 61 | assertThat(illegalArgumentException.getMessage()).isEqualTo("There is no code for the product"); 62 | verifyZeroInteractions(productRepositoryMock); 63 | } 64 | 65 | @Test 66 | void zeroPricedProduct_createProduct_throwsException() { 67 | IllegalArgumentException illegalArgumentException = assertThrows(IllegalArgumentException.class, 68 | () -> cut.createProduct(ANY_PRODUCT.withPrice(BigDecimal.ZERO))); 69 | 70 | assertThat(illegalArgumentException.getMessage()).isEqualTo("Product should be priced"); 71 | verifyZeroInteractions(productRepositoryMock); 72 | } 73 | 74 | @Test 75 | void unknownValidProduct_createProduct() { 76 | when(productRepositoryMock.findProductByCode(ANY_PRODUCT_CODE)).thenReturn(None()); 77 | when(productRepositoryMock.addProduct(ANY_PRODUCT)).thenReturn(ANY_PRODUCT); 78 | 79 | Assertions.assertThat(cut.createProduct(ANY_PRODUCT)).isEqualTo(ANY_PRODUCT); 80 | verify(productRepositoryMock).addProduct(ANY_PRODUCT); 81 | } 82 | 83 | /** 84 | * {@link ProductServiceImpl#findProductByCode(String)} 85 | */ 86 | @Test 87 | void unknownProduct_returnsNone() { 88 | when(productRepositoryMock.findProductByCode(ANY_PRODUCT_CODE)).thenReturn(None()); 89 | 90 | Assertions.assertThat(cut.findProductByCode(ANY_PRODUCT_CODE)).isEqualTo(None()); 91 | verifyNoMoreInteractions(productRepositoryMock); 92 | } 93 | 94 | @Test 95 | void existingProduct_returnsProduct() { 96 | when(productRepositoryMock.findProductByCode(ANY_PRODUCT_CODE)).thenReturn(Option(ANY_PRODUCT)); 97 | 98 | Assertions.assertThat(cut.findProductByCode(ANY_PRODUCT_CODE)).isEqualTo(Option(ANY_PRODUCT)); 99 | } 100 | 101 | /** 102 | * {@link ProductServiceImpl#deleteProduct(String)} 103 | */ 104 | @Test 105 | void deleteUnknownProduct_throwsException() { 106 | when(productRepositoryMock.findProductByCode(ANY_PRODUCT_CODE)).thenReturn(None()); 107 | 108 | IllegalArgumentException illegalArgumentException = assertThrows(IllegalArgumentException.class, 109 | () -> cut.deleteProduct(ANY_PRODUCT_CODE)); 110 | assertThat(illegalArgumentException.getMessage()).isEqualTo("Product " + ANY_PRODUCT_CODE + " does not exist"); 111 | verifyNoMoreInteractions(productRepositoryMock); 112 | } 113 | 114 | @Test 115 | void deleteNoCode_throwsException() { 116 | assertThrows(NullPointerException.class, () -> cut.deleteProduct(null)); 117 | verifyZeroInteractions(productRepositoryMock); 118 | } 119 | 120 | @Test 121 | void deleteExistingProduct_deleteProduct() { 122 | when(productRepositoryMock.findProductByCode(ANY_PRODUCT_CODE)).thenReturn(Option(ANY_PRODUCT)); 123 | 124 | cut.deleteProduct(ANY_PRODUCT_CODE); 125 | 126 | verify(productRepositoryMock).deleteProduct(ANY_PRODUCT.getId()); 127 | } 128 | 129 | /** 130 | * {@link ProductServiceImpl#getAllProducts()} 131 | */ 132 | @Test 133 | void getAllProducts_returnProducts() { 134 | when(productRepositoryMock.findAllProducts()).thenReturn(List(ANY_PRODUCT)); 135 | 136 | Assertions.assertThat(cut.getAllProducts()).containsExactly(ANY_PRODUCT); 137 | } 138 | } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdelgatte/spring-hexagonal-example/126b09cbe17fc7e93f06b7e350c7e1f4f702d8a3/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Mon Mar 04 10:59:37 CET 2019 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.10-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /infrastructure/build.gradle: -------------------------------------------------------------------------------- 1 | subprojects { 2 | group 'com.rdelgatte.hexagonal.infrastructure' 3 | 4 | dependencies { 5 | implementation project(':domain') 6 | } 7 | 8 | apply plugin: 'info.solidsoft.pitest' 9 | 10 | /** 11 | * Pitest configuration for mutation testing: 12 | * - Using last version of Pitest (for jdk11 support) 13 | * - Using plugin for junit5 support 14 | * - Export to XML (for Sonar) + HTML 15 | * - Overriding reports each run 16 | */ 17 | pitest { 18 | pitestVersion = '1.4.3' 19 | testPlugin = 'junit5' 20 | outputFormats = ['XML', 'HTML'] 21 | timestampedReports = false 22 | threads = 4 23 | } 24 | } 25 | 26 | // danube-infrastructure is just a parent level with no standalone value 27 | install.enabled = false 28 | publish.enabled = false -------------------------------------------------------------------------------- /infrastructure/memory-persistence/build.gradle: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdelgatte/spring-hexagonal-example/126b09cbe17fc7e93f06b7e350c7e1f4f702d8a3/infrastructure/memory-persistence/build.gradle -------------------------------------------------------------------------------- /infrastructure/memory-persistence/src/main/java/com/rdelgatte/hexagonal/infrastructure/memory/InMemoryCustomerRepository.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.infrastructure.memory; 2 | 3 | import static io.vavr.API.List; 4 | 5 | import com.rdelgatte.hexagonal.domain.Customer; 6 | import com.rdelgatte.hexagonal.spi.CustomerRepository; 7 | import io.vavr.collection.List; 8 | import io.vavr.control.Option; 9 | import java.util.UUID; 10 | import lombok.AllArgsConstructor; 11 | import lombok.Data; 12 | import lombok.NoArgsConstructor; 13 | import lombok.experimental.Wither; 14 | 15 | @Data 16 | @AllArgsConstructor 17 | @NoArgsConstructor 18 | @Wither 19 | public class InMemoryCustomerRepository implements CustomerRepository { 20 | 21 | private List customers = List(); 22 | 23 | public Customer save(Customer customer) { 24 | Option maybeCustomer = findByLogin(customer.getName()); 25 | if (maybeCustomer.isEmpty()) { 26 | this.customers = getCustomers().append(customer); 27 | } else { 28 | this.customers = getCustomers().remove(maybeCustomer.get()).append(customer); 29 | } 30 | return customer; 31 | } 32 | 33 | public Option findById(UUID id) { 34 | return getCustomers().find(customer -> id.equals(customer.getId())); 35 | } 36 | 37 | public Option findByLogin(String login) { 38 | return getCustomers().find(customer -> login.equals(customer.getName())); 39 | } 40 | 41 | public List findAll() { 42 | return getCustomers(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /infrastructure/memory-persistence/src/main/java/com/rdelgatte/hexagonal/infrastructure/memory/InMemoryProductRepository.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.infrastructure.memory; 2 | 3 | import com.rdelgatte.hexagonal.domain.Product; 4 | import com.rdelgatte.hexagonal.spi.ProductRepository; 5 | import io.vavr.API; 6 | import io.vavr.collection.List; 7 | import io.vavr.control.Option; 8 | import java.util.UUID; 9 | import lombok.AllArgsConstructor; 10 | import lombok.Data; 11 | import lombok.NoArgsConstructor; 12 | import lombok.experimental.Wither; 13 | 14 | @Data 15 | @AllArgsConstructor 16 | @NoArgsConstructor 17 | @Wither 18 | public class InMemoryProductRepository implements ProductRepository { 19 | 20 | private List inMemoryProducts = API.List(); 21 | 22 | public Product addProduct(Product product) { 23 | this.inMemoryProducts = getInMemoryProducts().append(product); 24 | return product; 25 | } 26 | 27 | public void deleteProduct(UUID productId) { 28 | this.inMemoryProducts = getInMemoryProducts().filter(product -> !product.getId().equals(productId)); 29 | } 30 | 31 | public Option findProductByCode(String code) { 32 | return getInMemoryProducts().find(product -> product.getCode().equals(code)); 33 | } 34 | 35 | public List findAllProducts() { 36 | return getInMemoryProducts(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /infrastructure/memory-persistence/src/test/java/com/rdelgatte/hexagonal/infrastructure/memory/InMemoryCustomerRepositoryTest.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.infrastructure.memory; 2 | 3 | import static io.vavr.API.List; 4 | import static io.vavr.API.None; 5 | import static io.vavr.API.Option; 6 | import static java.math.BigDecimal.ZERO; 7 | import static java.util.UUID.randomUUID; 8 | import static org.assertj.core.api.Assertions.assertThat; 9 | 10 | import com.rdelgatte.hexagonal.domain.Customer; 11 | import com.rdelgatte.hexagonal.domain.Product; 12 | import java.util.UUID; 13 | import org.junit.jupiter.api.BeforeEach; 14 | import org.junit.jupiter.api.Test; 15 | 16 | class InMemoryCustomerRepositoryTest { 17 | 18 | private static final UUID ANY_CUSTOMER_ID = randomUUID(); 19 | private static final String ANY_NAME = "ANY_NAME"; 20 | private static final Customer ANY_CUSTOMER = new Customer(ANY_CUSTOMER_ID, ANY_NAME, List()); 21 | private static final UUID ANY_OTHER_CUSTOMER_ID = randomUUID(); 22 | private static final String ANY_OTHER_LOGIN = "ANY_OTHER_LOGIN"; 23 | private static final Customer ANY_OTHER_CUSTOMER = new Customer(ANY_OTHER_CUSTOMER_ID, ANY_OTHER_LOGIN, List()); 24 | private static final UUID ANY_PRODUCT_ID = randomUUID(); 25 | private static final String ANY_PRODUCT_CODE = "ANY_PRODUCT_CODE"; 26 | private static final String ANY_LABEL = "ANY_LABEL"; 27 | private static final Product ANY_PRODUCT = new Product(ANY_PRODUCT_ID, ANY_PRODUCT_CODE, ANY_LABEL, ZERO); 28 | private InMemoryCustomerRepository cut; 29 | 30 | @BeforeEach 31 | void setUp() { 32 | cut = new InMemoryCustomerRepository(List()); 33 | } 34 | 35 | /** 36 | * {@link InMemoryCustomerRepository#save(Customer)} 37 | */ 38 | @Test 39 | void unknownCustomer_save_addCustomerToRepository() { 40 | assertThat(cut.save(ANY_CUSTOMER)).isEqualTo(ANY_CUSTOMER); 41 | assertThat(cut.getCustomers()).isEqualTo(List(ANY_CUSTOMER)); 42 | } 43 | 44 | @Test 45 | void existingCustomer_save_updateCustomerToRepository() { 46 | cut = new InMemoryCustomerRepository().withCustomers(List(ANY_CUSTOMER)); 47 | Customer updatedCustomer = ANY_CUSTOMER.withCart(List(ANY_PRODUCT)); 48 | assertThat(cut.save(updatedCustomer)).isEqualTo(updatedCustomer); 49 | assertThat(cut.getCustomers()).isEqualTo(List(updatedCustomer)); 50 | } 51 | 52 | /** 53 | * {@link InMemoryCustomerRepository#findById(UUID)} 54 | */ 55 | @Test 56 | void unknownCustomer_findById_returnsNone() { 57 | assertThat(cut.findById(randomUUID())).isEqualTo(None()); 58 | } 59 | 60 | @Test 61 | void existingCustomer_findById_returnsCustomer() { 62 | cut = new InMemoryCustomerRepository().withCustomers(List(ANY_CUSTOMER)); 63 | assertThat(cut.findById(ANY_CUSTOMER_ID)).isEqualTo(Option(ANY_CUSTOMER)); 64 | } 65 | 66 | /** 67 | * {@link InMemoryCustomerRepository#findByLogin(String)} 68 | */ 69 | @Test 70 | void unknownCustomer_findByLogin_returnsNone() { 71 | assertThat(cut.findByLogin(ANY_NAME)).isEqualTo(None()); 72 | } 73 | 74 | @Test 75 | void existingCustomer_findByLogin_returnsCustomer() { 76 | cut = new InMemoryCustomerRepository().withCustomers(List(ANY_CUSTOMER)); 77 | assertThat(cut.findByLogin(ANY_NAME)).isEqualTo(Option(ANY_CUSTOMER)); 78 | } 79 | 80 | /** 81 | * {@link InMemoryCustomerRepository#findAll()} 82 | */ 83 | @Test 84 | void emptyRepository_findAll_returnsEmptyList() { 85 | assertThat(cut.findAll()).isEqualTo(List()); 86 | } 87 | 88 | @Test 89 | void customers_findAll_returnsAllCustomers() { 90 | cut = new InMemoryCustomerRepository().withCustomers(List(ANY_CUSTOMER, ANY_OTHER_CUSTOMER)); 91 | assertThat(cut.findAll()).isEqualTo(List(ANY_CUSTOMER, ANY_OTHER_CUSTOMER)); 92 | } 93 | } -------------------------------------------------------------------------------- /infrastructure/memory-persistence/src/test/java/com/rdelgatte/hexagonal/infrastructure/memory/InMemoryProductRepositoryTest.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.infrastructure.memory; 2 | 3 | import static io.vavr.API.List; 4 | import static io.vavr.API.None; 5 | import static io.vavr.API.Option; 6 | import static org.assertj.core.api.Assertions.assertThat; 7 | 8 | import com.rdelgatte.hexagonal.domain.Product; 9 | import java.util.UUID; 10 | import org.junit.jupiter.api.BeforeEach; 11 | import org.junit.jupiter.api.Test; 12 | 13 | class InMemoryProductRepositoryTest { 14 | 15 | private InMemoryProductRepository cut; 16 | private Product product; 17 | 18 | @BeforeEach 19 | void setUp() { 20 | cut = new InMemoryProductRepository(); 21 | product = new Product() 22 | .withCode("1234") 23 | .withLabel("My awesome product"); 24 | } 25 | 26 | /** 27 | * {@link InMemoryProductRepository#addProduct(Product)} 28 | */ 29 | @Test 30 | void addProductAndAssert() { 31 | assertThat(cut.addProduct(product)).isEqualTo(product); 32 | assertThat(cut.getInMemoryProducts()).isEqualTo(List(product)); 33 | } 34 | 35 | /** 36 | * {@link InMemoryProductRepository#deleteProduct(UUID)} 37 | */ 38 | @Test 39 | void deleteExistingProduct() { 40 | cut.addProduct(product); 41 | cut.deleteProduct(product.getId()); 42 | 43 | assertThat(cut.findProductByCode(product.getCode())).isEmpty(); 44 | } 45 | 46 | /** 47 | * {@link InMemoryProductRepository#findProductByCode(String)} 48 | */ 49 | @Test 50 | void findExistingProduct() { 51 | cut.addProduct(product); 52 | 53 | assertThat(cut.findProductByCode(product.getCode())).isEqualTo(Option(product)); 54 | } 55 | 56 | @Test 57 | void findUnknownProduct() { 58 | assertThat(cut.findProductByCode(product.getCode())).isEqualTo(None()); 59 | } 60 | 61 | } -------------------------------------------------------------------------------- /infrastructure/postgres-persistence/build.gradle: -------------------------------------------------------------------------------- 1 | def springBootVersion = '2.1.0.RELEASE' 2 | def postgresVersion = '42.2.5' 3 | 4 | dependencies { 5 | implementation "org.springframework.boot:spring-boot-starter-data-jpa:$springBootVersion" 6 | implementation "org.postgresql:postgresql:$postgresVersion" 7 | } -------------------------------------------------------------------------------- /infrastructure/postgres-persistence/src/main/java/com/rdelgatte/hexagonal/infrastructure/postgres/dao/PostgresCustomer.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.infrastructure.postgres.dao; 2 | 3 | import static io.vavr.collection.List.ofAll; 4 | import static java.util.UUID.randomUUID; 5 | 6 | import com.rdelgatte.hexagonal.domain.Customer; 7 | import java.util.List; 8 | import java.util.UUID; 9 | import javax.persistence.Entity; 10 | import javax.persistence.Id; 11 | import javax.persistence.ManyToMany; 12 | import lombok.AllArgsConstructor; 13 | import lombok.Data; 14 | import lombok.NoArgsConstructor; 15 | import lombok.experimental.Wither; 16 | 17 | @Entity 18 | @Data 19 | @AllArgsConstructor 20 | @NoArgsConstructor 21 | @Wither 22 | public class PostgresCustomer { 23 | 24 | @Id 25 | private UUID id = randomUUID(); 26 | private String name = ""; 27 | @ManyToMany 28 | private List cart = List.of(); 29 | 30 | public static PostgresCustomer fromCustomer(Customer customer) { 31 | return new PostgresCustomer() 32 | .withId(customer.getId()) 33 | .withName(customer.getName()) 34 | .withCart(customer.getCart() 35 | .map(PostgresProduct::fromProduct) 36 | .toJavaList() 37 | ); 38 | } 39 | 40 | public Customer toCustomer() { 41 | return new Customer() 42 | .withId(id) 43 | .withName(name) 44 | .withCart(ofAll(cart).map(PostgresProduct::toProduct)); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /infrastructure/postgres-persistence/src/main/java/com/rdelgatte/hexagonal/infrastructure/postgres/dao/PostgresProduct.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.infrastructure.postgres.dao; 2 | 3 | import static java.math.BigDecimal.ZERO; 4 | import static java.util.UUID.randomUUID; 5 | 6 | import com.rdelgatte.hexagonal.domain.Product; 7 | import java.math.BigDecimal; 8 | import java.util.UUID; 9 | import javax.persistence.Entity; 10 | import javax.persistence.Id; 11 | import lombok.AllArgsConstructor; 12 | import lombok.Data; 13 | import lombok.NoArgsConstructor; 14 | import lombok.experimental.Wither; 15 | 16 | @Entity 17 | @Data 18 | @AllArgsConstructor 19 | @NoArgsConstructor 20 | @Wither 21 | public class PostgresProduct { 22 | 23 | @Id 24 | private UUID id = randomUUID(); 25 | private String code = ""; 26 | private String label = ""; 27 | private BigDecimal price = ZERO; 28 | 29 | public static PostgresProduct fromProduct(Product product) { 30 | return new PostgresProduct() 31 | .withId(product.getId()) 32 | .withCode(product.getCode()) 33 | .withLabel(product.getLabel()) 34 | .withPrice(product.getPrice()); 35 | } 36 | 37 | public Product toProduct() { 38 | return new Product(id, code, label, price); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /infrastructure/postgres-persistence/src/main/java/com/rdelgatte/hexagonal/infrastructure/postgres/repository/PostgresCustomerRepository.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.infrastructure.postgres.repository; 2 | 3 | import static io.vavr.collection.List.ofAll; 4 | import static io.vavr.control.Option.ofOptional; 5 | 6 | import com.rdelgatte.hexagonal.domain.Customer; 7 | import com.rdelgatte.hexagonal.infrastructure.postgres.dao.PostgresCustomer; 8 | import com.rdelgatte.hexagonal.spi.CustomerRepository; 9 | import io.vavr.collection.List; 10 | import io.vavr.control.Option; 11 | import java.util.UUID; 12 | import lombok.AllArgsConstructor; 13 | import org.springframework.stereotype.Repository; 14 | 15 | /** 16 | * Implements the SPI {@link CustomerRepository} as Postgres provider. 17 | */ 18 | @Repository 19 | @AllArgsConstructor 20 | public class PostgresCustomerRepository implements CustomerRepository { 21 | 22 | private PostgresSpringDataCustomerRepository postgresSpringDataCustomerRepository; 23 | 24 | @Override 25 | public Customer save(Customer customer) { 26 | return postgresSpringDataCustomerRepository.save(PostgresCustomer.fromCustomer(customer)).toCustomer(); 27 | } 28 | 29 | @Override 30 | public Option findById(UUID id) { 31 | return ofOptional(postgresSpringDataCustomerRepository.findById(id).map(PostgresCustomer::toCustomer)); 32 | } 33 | 34 | @Override 35 | public Option findByLogin(String login) { 36 | return ofOptional(postgresSpringDataCustomerRepository.findByName(login).map(PostgresCustomer::toCustomer)); 37 | } 38 | 39 | @Override 40 | public List findAll() { 41 | return ofAll(postgresSpringDataCustomerRepository.findAll()).map(PostgresCustomer::toCustomer); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /infrastructure/postgres-persistence/src/main/java/com/rdelgatte/hexagonal/infrastructure/postgres/repository/PostgresProductRepository.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.infrastructure.postgres.repository; 2 | 3 | import static io.vavr.collection.List.ofAll; 4 | import static io.vavr.control.Option.ofOptional; 5 | 6 | import com.rdelgatte.hexagonal.domain.Product; 7 | import com.rdelgatte.hexagonal.infrastructure.postgres.dao.PostgresProduct; 8 | import com.rdelgatte.hexagonal.spi.ProductRepository; 9 | import io.vavr.collection.List; 10 | import io.vavr.control.Option; 11 | import java.util.UUID; 12 | import lombok.AllArgsConstructor; 13 | import org.springframework.stereotype.Repository; 14 | 15 | /** 16 | * Implements the SPI {@link ProductRepository} as Postgres provider. 17 | */ 18 | @Repository 19 | @AllArgsConstructor 20 | public class PostgresProductRepository implements ProductRepository { 21 | 22 | private PostgresSpringDataProductRepository postgresSpringDataProductRepository; 23 | 24 | @Override 25 | public Product addProduct(Product product) { 26 | return postgresSpringDataProductRepository.save(PostgresProduct.fromProduct(product)) 27 | .toProduct(); 28 | } 29 | 30 | @Override 31 | public void deleteProduct(UUID productId) { 32 | postgresSpringDataProductRepository.deleteById(productId); 33 | } 34 | 35 | @Override 36 | public Option findProductByCode(String code) { 37 | return ofOptional(postgresSpringDataProductRepository.findByCode(code).map(PostgresProduct::toProduct)); 38 | } 39 | 40 | @Override 41 | public List findAllProducts() { 42 | return ofAll(postgresSpringDataProductRepository.findAll()) 43 | .map(PostgresProduct::toProduct); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /infrastructure/postgres-persistence/src/main/java/com/rdelgatte/hexagonal/infrastructure/postgres/repository/PostgresSpringDataCustomerRepository.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.infrastructure.postgres.repository; 2 | 3 | import com.rdelgatte.hexagonal.infrastructure.postgres.dao.PostgresCustomer; 4 | import java.util.Optional; 5 | import java.util.UUID; 6 | import org.springframework.data.jpa.repository.JpaRepository; 7 | 8 | public interface PostgresSpringDataCustomerRepository extends JpaRepository { 9 | 10 | Optional findByName(String name); 11 | } 12 | -------------------------------------------------------------------------------- /infrastructure/postgres-persistence/src/main/java/com/rdelgatte/hexagonal/infrastructure/postgres/repository/PostgresSpringDataProductRepository.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.infrastructure.postgres.repository; 2 | 3 | import com.rdelgatte.hexagonal.infrastructure.postgres.dao.PostgresProduct; 4 | import java.util.Optional; 5 | import java.util.UUID; 6 | import org.springframework.data.jpa.repository.JpaRepository; 7 | 8 | public interface PostgresSpringDataProductRepository extends JpaRepository { 9 | 10 | Optional findByCode(String code); 11 | } 12 | -------------------------------------------------------------------------------- /infrastructure/postgres-persistence/src/test/java/com/rdelgatte/hexagonal/infrastructure/postgres/dao/PostgresCustomerTest.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.infrastructure.postgres.dao; 2 | 3 | import static io.vavr.API.List; 4 | import static java.util.UUID.randomUUID; 5 | import static org.assertj.core.api.Assertions.assertThat; 6 | 7 | import com.rdelgatte.hexagonal.domain.Customer; 8 | import com.rdelgatte.hexagonal.domain.Product; 9 | import java.math.BigDecimal; 10 | import java.util.List; 11 | import java.util.UUID; 12 | import org.junit.jupiter.api.Test; 13 | 14 | class PostgresCustomerTest { 15 | 16 | 17 | private static final UUID ANY_PRODUCT_ID = randomUUID(); 18 | private static final String ANY_PRODUCT_CODE = "ANY_PRODUCT_CODE"; 19 | private static final String ANY_LABEL = "ANY_LABEL"; 20 | private static final Product ANY_PRODUCT = new Product(ANY_PRODUCT_ID, ANY_PRODUCT_CODE, ANY_LABEL, BigDecimal.ONE); 21 | private static final PostgresProduct ANY_POSTGRES_PRODUCT = new PostgresProduct(ANY_PRODUCT_ID, ANY_PRODUCT_CODE, 22 | ANY_LABEL, BigDecimal.ONE); 23 | private static final UUID ANY_CUSTOMER_ID = randomUUID(); 24 | private static final String ANY_NAME = "ANY_NAME"; 25 | private static final Customer ANY_CUSTOMER = new Customer(ANY_CUSTOMER_ID, ANY_NAME, List(ANY_PRODUCT)); 26 | private static final PostgresCustomer ANY_POSTGRES_CUSTOMER = new PostgresCustomer(ANY_CUSTOMER_ID, ANY_NAME, 27 | List.of(ANY_POSTGRES_PRODUCT)); 28 | 29 | @Test 30 | void postgresCustomer_toCustomer_returnsCustomer() { 31 | assertThat(ANY_POSTGRES_CUSTOMER.toCustomer()).isEqualTo(ANY_CUSTOMER); 32 | } 33 | 34 | @Test 35 | void product_fromCustomer_returnsPostgresCustomer() { 36 | assertThat(PostgresCustomer.fromCustomer(ANY_CUSTOMER)).isEqualTo(ANY_POSTGRES_CUSTOMER); 37 | } 38 | } -------------------------------------------------------------------------------- /infrastructure/postgres-persistence/src/test/java/com/rdelgatte/hexagonal/infrastructure/postgres/dao/PostgresProductTest.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.infrastructure.postgres.dao; 2 | 3 | import static java.util.UUID.randomUUID; 4 | import static org.assertj.core.api.Assertions.assertThat; 5 | 6 | import com.rdelgatte.hexagonal.domain.Product; 7 | import java.math.BigDecimal; 8 | import java.util.UUID; 9 | import org.junit.jupiter.api.Test; 10 | 11 | class PostgresProductTest { 12 | 13 | private static final UUID ANY_PRODUCT_ID = randomUUID(); 14 | private static final String ANY_PRODUCT_CODE = "ANY_PRODUCT_CODE"; 15 | private static final String ANY_LABEL = "ANY_LABEL"; 16 | private static final Product ANY_PRODUCT = new Product(ANY_PRODUCT_ID, ANY_PRODUCT_CODE, ANY_LABEL, BigDecimal.TEN); 17 | 18 | private static final PostgresProduct ANY_POSTGRES_PRODUCT = new PostgresProduct(ANY_PRODUCT_ID, ANY_PRODUCT_CODE, 19 | ANY_LABEL, BigDecimal.TEN); 20 | 21 | @Test 22 | void postgresProduct_toProduct_returnsProduct() { 23 | assertThat(ANY_POSTGRES_PRODUCT.toProduct()).isEqualTo(ANY_PRODUCT); 24 | } 25 | 26 | @Test 27 | void product_fromProduct_returnsPostgresProduct() { 28 | assertThat(PostgresProduct.fromProduct(ANY_PRODUCT)).isEqualTo(ANY_POSTGRES_PRODUCT); 29 | } 30 | } -------------------------------------------------------------------------------- /infrastructure/rest-client/build.gradle: -------------------------------------------------------------------------------- 1 | def springVersion = '2.1.0.RELEASE' 2 | def jaxbVersion = '2.3.0' 3 | def javaxVersion = '1.2.0' 4 | 5 | dependencies { 6 | implementation "org.springframework.boot:spring-boot-starter-web:$springVersion" 7 | implementation "javax.xml.bind:jaxb-api:$jaxbVersion" 8 | implementation "com.sun.xml.bind:jaxb-impl:$jaxbVersion" 9 | implementation "com.sun.xml.bind:jaxb-core:$jaxbVersion" 10 | implementation "org.glassfish.jaxb:jaxb-runtime:$jaxbVersion" 11 | implementation "com.sun.activation:javax.activation:$javaxVersion" 12 | implementation "io.vavr:vavr-jackson:$vavrVersion" 13 | } -------------------------------------------------------------------------------- /infrastructure/rest-client/src/main/java/com/rdelgatte/hexagonal/infrastructure/restclient/configuration/JacksonConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.infrastructure.restclient.configuration; 2 | 3 | import com.fasterxml.jackson.annotation.JsonInclude.Include; 4 | import com.fasterxml.jackson.databind.DeserializationFeature; 5 | import com.fasterxml.jackson.databind.Module; 6 | import com.fasterxml.jackson.databind.ObjectMapper; 7 | import com.fasterxml.jackson.databind.PropertyNamingStrategy; 8 | import com.fasterxml.jackson.databind.SerializationFeature; 9 | import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; 10 | import io.vavr.jackson.datatype.VavrModule; 11 | import org.springframework.context.annotation.Bean; 12 | import org.springframework.context.annotation.Configuration; 13 | import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; 14 | 15 | /** 16 | * All additional Jackson modules (modules used by Spring to marshal / unmarshal): 17 | * 18 | * - {@link VavrModule} 19 | * - {@link JavaTimeModule} 20 | */ 21 | @Configuration 22 | public class JacksonConfiguration { 23 | 24 | @Bean 25 | public Module vavr() { 26 | return new VavrModule(); 27 | } 28 | 29 | @Bean 30 | public Module javaTime() { 31 | return new JavaTimeModule(); 32 | } 33 | 34 | @Bean 35 | public MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter() { 36 | MappingJackson2HttpMessageConverter jsonConverter = new MappingJackson2HttpMessageConverter(); 37 | jsonConverter.setObjectMapper(objectMapper()); 38 | return jsonConverter; 39 | } 40 | 41 | @Bean 42 | public ObjectMapper objectMapper() { 43 | ObjectMapper objectMapper = new ObjectMapper(); 44 | objectMapper.registerModules(vavr(), javaTime()); 45 | objectMapper.setPropertyNamingStrategy(PropertyNamingStrategy.LOWER_CAMEL_CASE); 46 | objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); 47 | objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); 48 | objectMapper.setSerializationInclusion(Include.NON_EMPTY); 49 | 50 | return objectMapper; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /infrastructure/rest-client/src/main/java/com/rdelgatte/hexagonal/infrastructure/restclient/controller/CustomerController.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.infrastructure.restclient.controller; 2 | 3 | import com.rdelgatte.hexagonal.api.CustomerService; 4 | import com.rdelgatte.hexagonal.domain.Customer; 5 | import io.vavr.control.Option; 6 | import lombok.AllArgsConstructor; 7 | import org.springframework.web.bind.annotation.GetMapping; 8 | import org.springframework.web.bind.annotation.PatchMapping; 9 | import org.springframework.web.bind.annotation.PathVariable; 10 | import org.springframework.web.bind.annotation.PostMapping; 11 | import org.springframework.web.bind.annotation.RequestBody; 12 | import org.springframework.web.bind.annotation.RequestMapping; 13 | import org.springframework.web.bind.annotation.RestController; 14 | 15 | @RestController 16 | @RequestMapping(CustomerController.BASE_PATH) 17 | @AllArgsConstructor 18 | class CustomerController { 19 | 20 | static final String BASE_PATH = "customers"; 21 | private static final String RESOURCE_PATH = "{name}"; 22 | private final CustomerService customerService; 23 | 24 | @PostMapping 25 | void signUp(@RequestBody String name) { 26 | customerService.signUp(name); 27 | } 28 | 29 | @GetMapping(RESOURCE_PATH) 30 | Option find(@PathVariable String name) { 31 | return customerService.findCustomer(name); 32 | } 33 | 34 | @PatchMapping(RESOURCE_PATH) 35 | void addProductToCart(@PathVariable String name, @RequestBody String productCode) { 36 | customerService.addProductToCart(name, productCode); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /infrastructure/rest-client/src/main/java/com/rdelgatte/hexagonal/infrastructure/restclient/controller/ProductController.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.infrastructure.restclient.controller; 2 | 3 | import com.rdelgatte.hexagonal.api.ProductService; 4 | import com.rdelgatte.hexagonal.domain.Product; 5 | import io.vavr.collection.List; 6 | import io.vavr.control.Option; 7 | import lombok.AllArgsConstructor; 8 | import org.springframework.web.bind.annotation.DeleteMapping; 9 | import org.springframework.web.bind.annotation.GetMapping; 10 | import org.springframework.web.bind.annotation.PathVariable; 11 | import org.springframework.web.bind.annotation.PostMapping; 12 | import org.springframework.web.bind.annotation.RequestBody; 13 | import org.springframework.web.bind.annotation.RequestMapping; 14 | import org.springframework.web.bind.annotation.RestController; 15 | 16 | @RestController 17 | @RequestMapping(ProductController.BASE_PATH) 18 | @AllArgsConstructor 19 | public class ProductController { 20 | 21 | static final String BASE_PATH = "products"; 22 | private static final String RESOURCE_PATH = "{code}"; 23 | private final ProductService productService; 24 | 25 | @PostMapping 26 | Product createProduct(@RequestBody Product product) { 27 | return productService.createProduct(product); 28 | } 29 | 30 | @GetMapping 31 | List findAll() { 32 | return productService.getAllProducts(); 33 | } 34 | 35 | @GetMapping(RESOURCE_PATH) 36 | Option find(@PathVariable String code) { 37 | return productService.findProductByCode(code); 38 | } 39 | 40 | @DeleteMapping(RESOURCE_PATH) 41 | void delete(@PathVariable String code) { 42 | productService.deleteProduct(code); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /infrastructure/rest-client/src/test/java/com/rdelgatte/hexagonal/infrastructure/restclient/controller/CustomerControllerTest.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.infrastructure.restclient.controller; 2 | 3 | import static io.vavr.API.List; 4 | import static io.vavr.API.Option; 5 | import static java.util.UUID.randomUUID; 6 | import static org.assertj.core.api.Assertions.assertThat; 7 | import static org.mockito.Mockito.verify; 8 | import static org.mockito.Mockito.when; 9 | 10 | import com.rdelgatte.hexagonal.api.CustomerService; 11 | import com.rdelgatte.hexagonal.domain.Customer; 12 | import org.junit.jupiter.api.BeforeEach; 13 | import org.junit.jupiter.api.Test; 14 | import org.junit.jupiter.api.extension.ExtendWith; 15 | import org.mockito.Mock; 16 | import org.mockito.junit.jupiter.MockitoExtension; 17 | 18 | @ExtendWith(MockitoExtension.class) 19 | class CustomerControllerTest { 20 | 21 | private static final String ANY_NAME = "ANY_NAME"; 22 | private static final String ANY_PRODUCT_CODE = "ANY_PRODUCT_CODE"; 23 | private static final Customer ANY_CUSTOMER = new Customer(randomUUID(), ANY_NAME, List()); 24 | private CustomerController cut; 25 | @Mock 26 | private CustomerService customerServiceMock; 27 | 28 | @BeforeEach 29 | void setUp() { 30 | cut = new CustomerController(customerServiceMock); 31 | } 32 | 33 | /** 34 | * {@link CustomerController#signUp(String)} 35 | */ 36 | @Test 37 | void signup() { 38 | cut.signUp(ANY_NAME); 39 | 40 | verify(customerServiceMock).signUp(ANY_NAME); 41 | } 42 | 43 | /** 44 | * {@link CustomerController#addProductToCart(String, String)} 45 | */ 46 | @Test 47 | void addProductToCart() { 48 | cut.addProductToCart(ANY_NAME, ANY_PRODUCT_CODE); 49 | 50 | verify(customerServiceMock).addProductToCart(ANY_NAME, ANY_PRODUCT_CODE); 51 | } 52 | 53 | /** 54 | * {@link CustomerController#find(String)} 55 | */ 56 | @Test 57 | void find() { 58 | when(customerServiceMock.findCustomer(ANY_NAME)).thenReturn(Option(ANY_CUSTOMER)); 59 | 60 | assertThat(cut.find(ANY_NAME)).isEqualTo(Option(ANY_CUSTOMER)); 61 | verify(customerServiceMock).findCustomer(ANY_NAME); 62 | } 63 | } -------------------------------------------------------------------------------- /infrastructure/rest-client/src/test/java/com/rdelgatte/hexagonal/infrastructure/restclient/controller/ProductControllerTest.java: -------------------------------------------------------------------------------- 1 | package com.rdelgatte.hexagonal.infrastructure.restclient.controller; 2 | 3 | import static io.vavr.API.List; 4 | import static io.vavr.API.None; 5 | import static io.vavr.API.Option; 6 | import static java.util.UUID.randomUUID; 7 | import static org.assertj.core.api.Assertions.assertThat; 8 | import static org.mockito.Mockito.verify; 9 | import static org.mockito.Mockito.when; 10 | 11 | import com.rdelgatte.hexagonal.api.ProductService; 12 | import com.rdelgatte.hexagonal.domain.Product; 13 | import java.math.BigDecimal; 14 | import org.junit.jupiter.api.BeforeEach; 15 | import org.junit.jupiter.api.Test; 16 | import org.junit.jupiter.api.extension.ExtendWith; 17 | import org.mockito.Mock; 18 | import org.mockito.junit.jupiter.MockitoExtension; 19 | 20 | @ExtendWith(MockitoExtension.class) 21 | class ProductControllerTest { 22 | 23 | private Product product; 24 | private ProductController cut; 25 | @Mock 26 | private ProductService productServiceMock; 27 | 28 | @BeforeEach 29 | void setUp() { 30 | cut = new ProductController(productServiceMock); 31 | product = new Product(randomUUID(), "1616", "Easybreath", BigDecimal.valueOf(29.95)); 32 | } 33 | 34 | /** 35 | * {@link ProductController#createProduct(Product)} 36 | */ 37 | @Test 38 | void createProduct() { 39 | when(productServiceMock.createProduct(product)).thenReturn(product); 40 | 41 | cut.createProduct(product); 42 | verify(productServiceMock).createProduct(product); 43 | } 44 | 45 | /** 46 | * {@link ProductController#findAll()} 47 | */ 48 | @Test 49 | void findAllProducts() { 50 | when(productServiceMock.getAllProducts()).thenReturn(List(product)); 51 | 52 | assertThat(cut.findAll()).containsExactly(product); 53 | } 54 | 55 | /** 56 | * {@link ProductController#find(String)} 57 | */ 58 | @Test 59 | void findExistingProduct() { 60 | when(productServiceMock.findProductByCode(product.getCode())).thenReturn(Option(product)); 61 | 62 | assertThat(cut.find(product.getCode())).isEqualTo(Option(product)); 63 | } 64 | 65 | @Test 66 | void findUnknownProduct() { 67 | when(productServiceMock.findProductByCode(product.getCode())).thenReturn(None()); 68 | 69 | assertThat(cut.find(product.getCode())).isEqualTo(None()); 70 | } 71 | 72 | /** 73 | * {@link ProductController#delete(String)} 74 | */ 75 | @Test 76 | void deleteProduct() { 77 | cut.delete(product.getCode()); 78 | 79 | verify(productServiceMock).deleteProduct(product.getCode()); 80 | } 81 | } -------------------------------------------------------------------------------- /infrastructure/settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'infrastructure' 2 | 3 | include 'memory-persistence' 4 | include 'postgres-persistence' 5 | include 'rest-client' -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'hexagonal' 2 | 3 | include 'domain' 4 | include 'infrastructure:rest-client' 5 | include 'infrastructure:memory-persistence' 6 | include 'infrastructure:postgres-persistence' 7 | include 'application' 8 | include 'console-application' 9 | 10 | --------------------------------------------------------------------------------