Hexagonal Architecture with Spring Boot

This commit is contained in:
Ramón Bailén Sánchez
2022-06-11 21:31:16 +02:00
parent 8b1f89b718
commit 6e77fa1789
32 changed files with 35 additions and 39 deletions

BIN
poc-hexagonal_architecture/.DS_Store vendored Normal file

Binary file not shown.

33
poc-hexagonal_architecture/.gitignore vendored Normal file
View File

@@ -0,0 +1,33 @@
HELP.md
target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/

Binary file not shown.

View File

@@ -0,0 +1,2 @@
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.4/apache-maven-3.8.4-bin.zip
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar

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() {
}
}