Hexagonal Architecture with Spring Boot

This commit is contained in:
Ramón Bailén Sánchez
2022-06-11 21:28:41 +02:00
parent 1bb915ac74
commit 8b1f89b718
28 changed files with 739 additions and 0 deletions

39
.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
#Maven
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
release.properties
# Eclipse
.project
.classpath
.settings/
bin/
# IntelliJ
.idea
*.ipr
*.iml
*.iws
# NetBeans
nb-configuration.xml
# Visual Studio Code
.vscode
.factorypath
# OSX
.DS_Store
# Vim
*.swp
*.swo
# patch
*.orig
*.rej
# Local environment
.env

View File

@@ -0,0 +1,84 @@
{
"info": {
"_postman_id": "5cddaba1-07d1-4b8e-8574-da160697c2f6",
"name": "Hexagonal Architecture",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "Create product",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"name": "Content-Type",
"value": "application/json",
"type": "text"
},
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\r\n\t\"name\": \"MacBook Pro\",\r\n\t\"description\": \"14-inch MacBook Pro model\"\r\n}"
},
"url": {
"raw": "http://localhost:8080/v1/products",
"protocol": "http",
"host": [
"localhost"
],
"port": "8080",
"path": [
"v1",
"products"
]
}
},
"response": []
},
{
"name": "Find product",
"request": {
"method": "GET",
"header": [
{
"key": "Content-Type",
"name": "Content-Type",
"value": "application/json",
"type": "text"
},
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"url": {
"raw": "http://localhost:8080/v1/products/:id",
"protocol": "http",
"host": [
"localhost"
],
"port": "8080",
"path": [
"v1",
"products",
":id"
],
"variable": [
{
"key": "id",
"value": "1"
}
]
}
},
"response": []
}
]
}

95
pom.xml Normal file
View File

@@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.3</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>es.rbailen.sample</groupId>
<artifactId>hexagonalarchitecture</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>poc-hexagonal_architecture</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>11</java.version>
<org.mapstruct.version>1.4.2.Final</org.mapstruct.version>
<lombok.version>1.18.22</lombok.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</path>
</annotationProcessorPaths>
<compilerArgs>
<compilerArg>
-Amapstruct.suppressGeneratorTimestamp=true
</compilerArg>
<compilerArg>
-Amapstruct.suppressGeneratorVersionInfoComment=true
</compilerArg>
<compilerArg>
-Amapstruct.defaultComponentModel=spring
</compilerArg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,13 @@
package es.rbailen.sample.hexagonalarchitecture;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class HexagonalArchitectureApplication {
public static void main(String[] args) {
SpringApplication.run(HexagonalArchitectureApplication.class, args);
}
}

View File

@@ -0,0 +1,9 @@
package es.rbailen.sample.hexagonalarchitecture.application.ports.input;
import es.rbailen.sample.hexagonalarchitecture.domain.model.Product;
public interface CreateProductUseCase {
Product createProduct(Product product);
}

View File

@@ -0,0 +1,9 @@
package es.rbailen.sample.hexagonalarchitecture.application.ports.input;
import es.rbailen.sample.hexagonalarchitecture.domain.model.Product;
public interface GetProductUseCase {
Product getProductById(Long id);
}

View File

@@ -0,0 +1,9 @@
package es.rbailen.sample.hexagonalarchitecture.application.ports.output;
import es.rbailen.sample.hexagonalarchitecture.domain.event.ProductCreatedEvent;
public interface ProductEventPublisher {
void publishProductCreatedEvent(ProductCreatedEvent event);
}

View File

@@ -0,0 +1,13 @@
package es.rbailen.sample.hexagonalarchitecture.application.ports.output;
import es.rbailen.sample.hexagonalarchitecture.domain.model.Product;
import java.util.Optional;
public interface ProductOutputPort {
Product saveProduct(Product product);
Optional<Product> getProductById(Long id);
}

View File

@@ -0,0 +1,24 @@
package es.rbailen.sample.hexagonalarchitecture.domain.event;
import lombok.*;
import java.time.LocalDateTime;
@Builder
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ProductCreatedEvent {
private Long id;
private LocalDateTime date;
public ProductCreatedEvent(Long id) {
this.id = id;
this.date = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,9 @@
package es.rbailen.sample.hexagonalarchitecture.domain.exception;
public class ProductNotFound extends RuntimeException {
public ProductNotFound(String message) {
super(message);
}
}

View File

@@ -0,0 +1,22 @@
package es.rbailen.sample.hexagonalarchitecture.domain.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Builder
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class Product {
private Long id;
private String name;
private String description;
}

View File

@@ -0,0 +1,39 @@
package es.rbailen.sample.hexagonalarchitecture.domain.service;
import es.rbailen.sample.hexagonalarchitecture.application.ports.input.GetProductUseCase;
import es.rbailen.sample.hexagonalarchitecture.application.ports.output.ProductEventPublisher;
import es.rbailen.sample.hexagonalarchitecture.application.ports.output.ProductOutputPort;
import es.rbailen.sample.hexagonalarchitecture.domain.event.ProductCreatedEvent;
import es.rbailen.sample.hexagonalarchitecture.domain.exception.ProductNotFound;
import es.rbailen.sample.hexagonalarchitecture.domain.model.Product;
import es.rbailen.sample.hexagonalarchitecture.application.ports.input.CreateProductUseCase;
import lombok.AllArgsConstructor;
import java.util.Optional;
@AllArgsConstructor
public class ProductService implements CreateProductUseCase, GetProductUseCase {
private final ProductOutputPort productOutputPort;
private final ProductEventPublisher productEventPublisher;
@Override
public Product createProduct(Product product) {
product = productOutputPort.saveProduct(product);
productEventPublisher.publishProductCreatedEvent(new ProductCreatedEvent(product.getId()));
return product;
}
@Override
public Product getProductById(Long id) {
Optional<Product> product = productOutputPort.getProductById(id);
if(product.isEmpty()) {
throw new ProductNotFound("Product not found with id " + id);
}
return product.get();
}
}

View File

@@ -0,0 +1,30 @@
package es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.config;
import es.rbailen.sample.hexagonalarchitecture.domain.service.ProductService;
import es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.output.eventpublisher.ProductEventPublisherAdapter;
import es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.output.persistence.ProductPersistenceAdapter;
import es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.output.persistence.mapper.ProductPersistenceMapper;
import es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.output.persistence.repository.ProductRepository;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class BeanConfiguration {
@Bean
public ProductPersistenceAdapter productPersistenceAdapter(ProductRepository productRepository, ProductPersistenceMapper productPersistenceMapper) {
return new ProductPersistenceAdapter(productRepository, productPersistenceMapper);
}
@Bean
public ProductEventPublisherAdapter productEventPublisherAdapter(ApplicationEventPublisher applicationEventPublisher) {
return new ProductEventPublisherAdapter(applicationEventPublisher);
}
@Bean
public ProductService productService(ProductPersistenceAdapter productPersistenceAdapter, ProductEventPublisherAdapter productEventPublisherAdapter) {
return new ProductService(productPersistenceAdapter, productEventPublisherAdapter);
}
}

View File

@@ -0,0 +1,17 @@
package es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.input.eventlistener;
import es.rbailen.sample.hexagonalarchitecture.domain.event.ProductCreatedEvent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
@Slf4j
public class ProductEventListenerAdapter {
@EventListener
public void handle(ProductCreatedEvent event){
log.info("Product created with id " + event.getId() + " at " + event.getDate());
}
}

View File

@@ -0,0 +1,45 @@
package es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.input.rest;
import es.rbailen.sample.hexagonalarchitecture.application.ports.input.GetProductUseCase;
import es.rbailen.sample.hexagonalarchitecture.domain.model.Product;
import es.rbailen.sample.hexagonalarchitecture.application.ports.input.CreateProductUseCase;
import es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.input.rest.data.request.ProductCreateRequest;
import es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.input.rest.data.response.ProductCreateResponse;
import es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.input.rest.data.response.ProductQueryResponse;
import es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.input.rest.mapper.ProductRestMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
@RestController
@RequestMapping("/v1")
@RequiredArgsConstructor
public class ProductRestAdapter {
private final CreateProductUseCase createProductUseCase;
private final GetProductUseCase getProductUseCase;
private final ProductRestMapper productRestMapper;
@PostMapping(value = "/products")
public ResponseEntity<ProductCreateResponse> createProduct(@RequestBody @Valid ProductCreateRequest productCreateRequest){
// Request to domain
Product product = productRestMapper.toProduct(productCreateRequest);
product = createProductUseCase.createProduct(product);
// Domain to response
return new ResponseEntity<>(productRestMapper.toProductCreateResponse(product), HttpStatus.CREATED);
}
@GetMapping(value = "/products/{id}")
public ResponseEntity<ProductQueryResponse> getProduct(@PathVariable Long id){
Product product = getProductUseCase.getProductById(id);
return new ResponseEntity<>(productRestMapper.toProductQueryResponse(product), HttpStatus.OK);
}
}

View File

@@ -0,0 +1,20 @@
package es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.input.rest.data.request;
import lombok.*;
import javax.validation.constraints.NotEmpty;
@Builder
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class ProductCreateRequest {
@NotEmpty(message = "Name may not be empty")
private String name;
@NotEmpty(message = "Description may not be empty")
private String description;
}

View File

@@ -0,0 +1,18 @@
package es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.input.rest.data.response;
import lombok.*;
@Builder
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class ProductCreateResponse {
private Long id;
private String name;
private String description;
}

View File

@@ -0,0 +1,18 @@
package es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.input.rest.data.response;
import lombok.*;
@Builder
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class ProductQueryResponse {
private Long id;
private String name;
private String description;
}

View File

@@ -0,0 +1,18 @@
package es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.input.rest.mapper;
import es.rbailen.sample.hexagonalarchitecture.domain.model.Product;
import es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.input.rest.data.response.ProductQueryResponse;
import es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.input.rest.data.request.ProductCreateRequest;
import es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.input.rest.data.response.ProductCreateResponse;
import org.mapstruct.Mapper;
@Mapper
public interface ProductRestMapper {
Product toProduct(ProductCreateRequest productCreateRequest);
ProductCreateResponse toProductCreateResponse(Product product);
ProductQueryResponse toProductQueryResponse(Product product);
}

View File

@@ -0,0 +1,50 @@
package es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.output.customizedexception;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import es.rbailen.sample.hexagonalarchitecture.domain.exception.ProductNotFound;
import es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.output.customizedexception.data.response.ExceptionResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@ControllerAdvice
@RestController
public class CustomizedExceptionAdapter extends ResponseEntityExceptionHandler {
@ExceptionHandler(Exception.class)
public final ResponseEntity<Object> handleAllExceptions(Exception ex, WebRequest request) {
ExceptionResponse exceptionResponse = new ExceptionResponse(LocalDateTime.now(), ex.getMessage(), Arrays.asList(request.getDescription(false)));
return new ResponseEntity(exceptionResponse, HttpStatus.INTERNAL_SERVER_ERROR);
}
@ExceptionHandler(ProductNotFound.class)
public final ResponseEntity<Object> handleUserNotFoundException(ProductNotFound ex, WebRequest request) {
ExceptionResponse exceptionResponse = new ExceptionResponse(LocalDateTime.now(), ex.getMessage(), Arrays.asList(request.getDescription(false)));
return new ResponseEntity(exceptionResponse, HttpStatus.NOT_FOUND);
}
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {
List<String> errors = new ArrayList<String>();
ex.getBindingResult().getAllErrors().stream().forEach(error -> {
errors.add(error.getDefaultMessage());
});
ExceptionResponse exceptionResponse = new ExceptionResponse(LocalDateTime.now(), "Validation Failed", errors);
return new ResponseEntity(exceptionResponse, HttpStatus.BAD_REQUEST);
}
}

View File

@@ -0,0 +1,22 @@
package es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.output.customizedexception.data.response;
import lombok.*;
import java.time.LocalDateTime;
import java.util.List;
@Builder
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class ExceptionResponse {
private LocalDateTime date;
private String message;
private List<String> details;
}

View File

@@ -0,0 +1,18 @@
package es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.output.eventpublisher;
import es.rbailen.sample.hexagonalarchitecture.application.ports.output.ProductEventPublisher;
import es.rbailen.sample.hexagonalarchitecture.domain.event.ProductCreatedEvent;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
@RequiredArgsConstructor
public class ProductEventPublisherAdapter implements ProductEventPublisher {
private final ApplicationEventPublisher applicationEventPublisher;
@Override
public void publishProductCreatedEvent(ProductCreatedEvent event) {
applicationEventPublisher.publishEvent(event);
}
}

View File

@@ -0,0 +1,38 @@
package es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.output.persistence;
import es.rbailen.sample.hexagonalarchitecture.application.ports.output.ProductOutputPort;
import es.rbailen.sample.hexagonalarchitecture.domain.model.Product;
import es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.output.persistence.entity.ProductEntity;
import es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.output.persistence.mapper.ProductPersistenceMapper;
import es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.output.persistence.repository.ProductRepository;
import lombok.RequiredArgsConstructor;
import java.util.Optional;
@RequiredArgsConstructor
public class ProductPersistenceAdapter implements ProductOutputPort {
private final ProductRepository productRepository;
private final ProductPersistenceMapper productPersistenceMapper;
@Override
public Product saveProduct(Product product) {
ProductEntity productEntity = productPersistenceMapper.toProductEntity(product);
productEntity = productRepository.save(productEntity);
return productPersistenceMapper.toProduct(productEntity);
}
@Override
public Optional<Product> getProductById(Long id) {
Optional<ProductEntity> productEntity = productRepository.findById(id);
if(productEntity.isEmpty()) {
return Optional.empty();
}
Product product = productPersistenceMapper.toProduct(productEntity.get());
return Optional.of(product);
}
}

View File

@@ -0,0 +1,26 @@
package es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.output.persistence.entity;
import lombok.*;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
@Entity
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class ProductEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String description;
}

View File

@@ -0,0 +1,14 @@
package es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.output.persistence.mapper;
import es.rbailen.sample.hexagonalarchitecture.domain.model.Product;
import es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.output.persistence.entity.ProductEntity;
import org.mapstruct.Mapper;
@Mapper
public interface ProductPersistenceMapper {
ProductEntity toProductEntity(Product product);
Product toProduct(ProductEntity productEntity);
}

View File

@@ -0,0 +1,10 @@
package es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.output.persistence.repository;
import es.rbailen.sample.hexagonalarchitecture.infrastructure.adapters.output.persistence.entity.ProductEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ProductRepository extends JpaRepository<ProductEntity, Long> {
}

View File

@@ -0,0 +1,17 @@
server:
port: 8080
logging:
level:
es.rbailen.sample.hexagonalarchitecture: info
spring:
jpa:
show-sql: false
h2:
console:
enabled: true
datasource:
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=- 1;DB_CLOSE_ON_EXIT=FALSE
username: test
password: test

View File

@@ -0,0 +1,13 @@
package es.rbailen.sample.hexagonalarchitecture;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class HexagonalArchitectureApplicationTests {
@Test
void contextLoads() {
}
}