La arquitectura hexagonal tiene las siguientes capas:
- infraestructure: es la capa que se conecta con el exterior como la base de datos o los controladores.
- application: es la capa intermediaria que se conecta con la infraestructura.
- domain: es el núcleo, el core o la capa del negocio.
Este proyecto tiene la siguiente estructura:
- client: es la capa cliente por ejemplo para usar las tablas, payments o shopping externo (en este caso no es necesario).
- authentication: conecta todos los microservicios rest mediante un token, así es más seguro la comunicación (no es el caso, pero sería recomendable, se refuerza la seguridad).
- database: se usan los repositorios jpa para realizar consultas a la base de datos en memoria h2.
- domain: es la capa que se encarga de solucionar la lógica de negocio.
- entity: son todas las entidades de la lógica del dominio.
- web: es el starter, el servicio web y tiene la configuración de la base de datos h2 (application.properties).
En este proyecto se ha dispersado las capas que tiene teóricamente la arquitectura hexagonal de forma modularizada, así la lógica del negocio está desacoplada con el exterior. Es decir, si se realiza algún cambio en el exterior no tiene que afectar en la lógica del negocio.
En la capa de infraestructura estarían los módulos database
, authentication
y client
.
En la capa de aplicación estaría el módulo web
.
En la capa del dominio entran los módulos domain
y entity
.
Se ha usado mockito para realizar los tests unitarios desde un fichero json.
Los ficheros json están en el módulo web
en la carpeta resources
y después en el paquete json
.
- entity: no tiene dependencia de ningún módulo, ya que son las entidades de la lógica del dominio.
- domain:
- Se relaciona con el módulo
entity
, ya que necesita las entidades de la lógica del dominio para el negocio. - Depende del módulo
database
para que se puedan insertar y obtener los datos de una base de datos en memoria, que es la capa de infraestructura de la arquitectura hexagonal.
- Se relaciona con el módulo
- database: tiene la relación con el módulo
entity
para manejar los datos. - web: tiene dependencia con el módulo
domain
para el manejo del negocio y dar respuestas a las peticiones.
Son las entidades de la lógica del dominio como Prices
, Brands
y Products
. Además, se ha añadido la entidad Error
para cuando se generan fallos como las excepciones.
La entidad Prices
contiene las anotaciones @Entity
, @Id
, @ManyToOne
y @JoinColumn
para la persistencia de datos en JPA. La anotación @Entity
sirve para que la aplicación sepa que es una entidad y que debe crear la tabla según las propiedades que tenga la clase. La anotación @Id
sirve para definir el identificador de la clase. La anotación @ManyToOne
es una relación de mucho a uno (N:1) para que cree en la tabla esa relación con una id
de otra entidad mediante la anotación @JoinColumn
:
@Entity
public class Prices {
@Id
private int priceId;
@ManyToOne
@JoinColumn(name = "brand_id")
private Brands brand;
}
Aparte de las anotaciones de JPA la clase Prices
tiene las anotaciones de Lombok reducir el código repetitivo como @Getter
, @ToString
, @RequiredArgsConstructor
y @AllArgsConstructor
.
Se usa los repositorios JPA para realizar consultas a la base de datos en memoria h2. Se han creado los repositorios necesarios por cada entidad. En el caso de la interfaz PriceRepository
se extiende el repositorio JPA para utilizar la entidad Prices
:
@Repository
@Transactional
public interface PricesRepository extends JpaRepository<Prices, Integer> {
List<Prices> findPricesByStartDateGreaterThanEqualAndEndDateLessThanEqualAndBrandAndProduct(LocalDateTime startDate, LocalDateTime endDate, Brands brand, Products product);
}
La anotación transaccional proporciona a la aplicación la capacidad de controlar declarativamente las transacciones en beans o entidades, así como las clases definidas.
Como se figura en el código se está realizando una consulta a la base de datos con una fecha de inicio, fecha de finalización, marca id y producto id. Esta consulta devuelve una lista de precios según los parámetros que vengan, siempre y cuando coincida que sea mayor o igual que la fecha de inicio, menor o igual que la fecha de finalización, y que coincidan tanto el identificador de marca como la del producto.
Se encarga de solucionar la lógica de negocio con los servicios necesarios por cada entidad. El módulo domain
contiene los paquetes services
, exception
y utils
.
En el paquete services
se han creado los servicios necesarios por cada entidad. La clase BrandsServices
tiene una anotación @Service
y @Slf4j
. La primera es para que Spring Boot sepa que es un servicio y la segunda sirve para ver los registros de los logs.
Esta clase tiene una inyección de dependencias en el constructor del repositorio y la clase Brands
:
@Slf4j
@Service
public class BrandsServices {
private Brands brand;
private BrandsRepository brandsRepository;
public BrandsServices(BrandsRepository brandsRepository, Brands brand){
this.brandsRepository = brandsRepository;
this.brand = brand;
}
}
La clase BrandsService
contiene la anotación @PostConstuct
para inicializar la tarea de guardar la marca mediante el repositorio inyectado en el constructor:
@PostConstruct
public Brands initBrand(){
brand = generateBrand();
return brandsRepository.save(brand);
}
La clase PricesService
tiene inyectado PricesRepository
, BrandsService
, ProductsService
, Brands
y Products
en el constructor de la clase. Como en la clase anterior, se está usando la anotación @PostConstruct
para que se guarden las marcas y los productos en sus tablas correspondientes, y la lista de los precios en la tabla de los precios:
@PostConstruct
public void initPrices(){
brand = brandsServices.initBrand();
product = productsService.initProduct();
pricesRepository.saveAll(Stream.of(new Prices(1, brand,
Utility.getLocalDateTimeFromString(Constants.START_DATE_STRING_1, Constants.FORMAT_DATE_TIME_YYYY_MM_DD_HH_MM_SS),
Utility.getLocalDateTimeFromString(Constants.END_DATE_STRING_1, Constants.FORMAT_DATE_TIME_YYYY_MM_DD_HH_MM_SS),
1, product, 0, 35.50, Constants.CURR),
new Prices(2, brand,
Utility.getLocalDateTimeFromString(Constants.START_DATE_STRING_2, Constants.FORMAT_DATE_TIME_YYYY_MM_DD_HH_MM_SS),
Utility.getLocalDateTimeFromString(Constants.END_DATE_STRING_2, Constants.FORMAT_DATE_TIME_YYYY_MM_DD_HH_MM_SS),
2, product, 1, 25.45, Constants.CURR),
new Prices(3, brand,
Utility.getLocalDateTimeFromString(Constants.START_DATE_STRING_3, Constants.FORMAT_DATE_TIME_YYYY_MM_DD_HH_MM_SS),
Utility.getLocalDateTimeFromString(Constants.END_DATE_STRING_3, Constants.FORMAT_DATE_TIME_YYYY_MM_DD_HH_MM_SS),
3, product, 1, 30.50, Constants.CURR),
new Prices(4, brand,
Utility.getLocalDateTimeFromString(Constants.START_DATE_STRING_4, Constants.FORMAT_DATE_TIME_YYYY_MM_DD_HH_MM_SS),
Utility.getLocalDateTimeFromString(Constants.END_DATE_STRING_4, Constants.FORMAT_DATE_TIME_YYYY_MM_DD_HH_MM_SS),
4, product, 1, 38.95, Constants.CURR)
)
.collect(Collectors.toList()));
}
Mediante esta clase se podrá consultar los precios según la fecha de inicio, la fecha de finalización, la marca y el producto. Aquí hay un ejemplo sobre cómo se consulta la lista de los precios en la base de datos mediante el repositorio de JPA:
public List<Prices> getPricesBetweenDatesAndBrandAndProduct(String startDate, String endDate, int brandId, int productId) throws ServiceException{
log.info(Constants.LOG_SERVICE, startDate, endDate, brandId, productId);
if (Objects.isNull(startDate) || startDate.isEmpty()) {
throw new ServiceException.Builder(String.valueOf(ServiceErrorCatalog.START_DATE_IS_NOT_CORRRECT))
.withHttpStatus(HttpStatus.BAD_REQUEST).build();
}
if (Objects.isNull(endDate) || endDate.isEmpty()) {
throw new ServiceException.Builder(String.valueOf(ServiceErrorCatalog.END_DATE_IS_NOT_CORRRECT))
.withHttpStatus(HttpStatus.BAD_REQUEST).build();
}
if (Objects.isNull(brandId)) {
throw new ServiceException.Builder(String.valueOf(ServiceErrorCatalog.BRAND_ID_IS_NOT_CORRRECT))
.withHttpStatus(HttpStatus.BAD_REQUEST).build();
}
if (Objects.isNull(productId)) {
throw new ServiceException.Builder(String.valueOf(ServiceErrorCatalog.PRODUCT_ID_IS_NOT_CORRRECT))
.withHttpStatus(HttpStatus.BAD_REQUEST).build();
}
LocalDateTime localStartDate = Utility.getLocalDateTimeFromString(startDate, Constants.FORMAT_DATE_TIME_YYYY_MM_DD_HH_MM_SS);
LocalDateTime localEndDate = Utility.getLocalDateTimeFromString(endDate, Constants.FORMAT_DATE_TIME_YYYY_MM_DD_HH_MM_SS);
return pricesRepository.findPricesByStartDateGreaterThanEqualAndEndDateLessThanEqualAndBrandAndProduct(localStartDate, localEndDate, brand, product);
}
Este módulo contiene el arranque de la aplicación, los controladores y la configuración de la base de datos desde el fichero application.properties
. En el fichero pom.xml
es donde figura cuál es la clase principal:
<properties>
<start-class>com.inditex.web.MainApplication</start-class>
</properties>
La configuración de la base de datos de H2 está dentro de la carpeta resources
en el fichero de application.properties
:
spring.h2.console.enabled=true
spring.datasource.url=jdbc:h2:mem:test
spring.datasource.generate-unique-name=false
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
# Resuelve el nombre de las columnas y de las tablas, no afecta a las relaciones
spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
DB_CLOSE_ON_EXIT=FALSE
spring.jpa.defer-datasource-initialization = true
# muestra los logs de sql
spring.jpa.show-sql = true
logging.level.web=DEBUG
server.port=8082
Dentro del módulo también está el controlador llamado PricesController
con la anotación @RestController
, @RequestMapping
, @Slf4j
y @Validated
. Este controlador simplemente tiene inyectado el servicio de los precios en el constructor, y tiene un único endpoint para que se consulten los datos de los precios según los parámetros obtenidos de la petición:
@Slf4j
@RestController
@RequestMapping(Constants.BASE_MAPPING)
@Validated
public class PricesController {
private PricesService pricesService;
public PricesController(PricesService pricesService){
this.pricesService = pricesService;
}
@GetMapping(Constants.PATH_PRICES_FILTER_BY_DATES_BRAND_PRODUCT)
public ResponseEntity<List<Prices>> getPricesByDatesAndBrandAndProduct(
@NotBlank @RequestParam(value = "startDate") String startDate,
@NotBlank @RequestParam(value = "endDate") String endDate,
@RequestParam(value = "brandId") int brandId,
@RequestParam(value = "productId") int productId) throws ServiceException {
log.info(Constants.LOG_CONTROLLER, startDate, endDate, brandId, productId);
List<Prices> pricesList = null;
try {
pricesList = pricesService.getPricesBetweenDatesAndBrandAndProduct(startDate, endDate, brandId, productId);
}
catch (ServiceException e) {
throw new ServiceException(e.getCode(), e.getHttpStatus(), e.getMessage(), e.getCause(), e.getParams());
}
return ResponseEntity.ok(pricesList);
}
}
Según se figura en el código del controlador el endpoint puede lanzar una excepción de servicio o puede devolver una lista de precios, tanto vacía como rellena.
Se han creado los tests para las clases PricesService
y PricesController
. Se ha usado para realizar los tests junit.jupiter
y mockito
.
En el caso de PricesServiceTest
se ha creado un método llamado setUp
para mockear las clases que se instancian en el servicio como PricesRepository
, BrandsService
, ProductsService
, Brands
, Products
y ServiceException
:
@BeforeEach
public void setUp() {
mockedPricesRepository = mock(PricesRepository.class);
mockedBrandsServices = mock(BrandsServices.class);
mockedProductsService = mock(ProductsService.class);
mockedBrand = mock(Brands.class);
mockedProduct = mock(Products.class);
mockedPricesService = new PricesService(mockedPricesRepository, mockedBrandsServices, mockedProductsService, mockedBrand, mockedProduct);
mockedServiceException = getServiceExceptionByErrorCodeAndStatus(ServiceErrorCatalog.START_DATE_IS_NOT_CORRRECT.name(),
HttpStatus.INTERNAL_SERVER_ERROR, ServiceErrorCatalog.START_DATE_IS_NOT_CORRRECT.getMessage());
}
Ejemplo de un caso unitario cuando la fecha de inicio, la fecha de finalización, la marca id y el producto id tengan un valor concreto:
@Test
void test1_getPricesAt10ByDate14BrandId1AndProductId35435_should_call_pricesRepository() {
System.out.println(Constants.LOG_SERVICE_TEST1);
// Given
LocalDateTime startDate = Utility.getLocalDateTimeFromString(Constants.TEST_1_START_DATE_STRING, Constants.FORMAT_DATE_TIME_YYYY_MM_DD_HH_MM_SS);
LocalDateTime endDate = Utility.getLocalDateTimeFromString(Constants.END_DATE_STRING, Constants.FORMAT_DATE_TIME_YYYY_MM_DD_HH_MM_SS);
// When
mockedPricesService.getPricesBetweenDatesAndBrandAndProduct(Constants.TEST_1_START_DATE_STRING, Constants.END_DATE_STRING, Constants.BRAND_ID, Constants.PRODUCT_ID);
// Then
verify(mockedPricesRepository).findPricesByStartDateGreaterThanEqualAndEndDateLessThanEqualAndBrandAndProduct(startDate, endDate, mockedBrand, mockedProduct);
System.out.println();
}
Otro caso de uso es cuando la fecha de inicio sea vacío:
@Test
void getPricesByStartDateEmptyAndEndDateAndBrandId1AndProductId35435_should_throw_ServiceException() {
System.out.println(Constants.LOG_SERVICE_TEST_SERVICE_EXCEPTION);
// Given
mockedPricesService = mock(PricesService.class);
doThrow(mockedServiceException).when(mockedPricesService).getPricesBetweenDatesAndBrandAndProduct(Constants.EMPTY, Constants.END_DATE_STRING, Constants.BRAND_ID, Constants.PRODUCT_ID);
// Then
ServiceException serviceException = assertThrows(ServiceException.class,
() -> mockedPricesService.getPricesBetweenDatesAndBrandAndProduct(Constants.EMPTY, Constants.END_DATE_STRING, Constants.BRAND_ID, Constants.PRODUCT_ID));
// Then
assertNotNull(serviceException);
assertNotNull(serviceException.getMessage());
assertEquals(ServiceErrorCatalog.START_DATE_IS_NOT_CORRRECT.getMessage(), serviceException.getMessage());
System.out.println();
}
En el caso del controlador se han utilizado los ficheros json cargados por cada caso que se inyecta con la anotación @Autowired
y antes de cada test se mockean tanto el servicio como la excepción:
public class PricesControllerTest {
@Autowired
private JsonToObjectsCreator json;
@Mock
private PricesService mockedPricesService;
private PricesController pricesController;
@Mock
private ServiceException mockedServiceException;
@BeforeEach
public void setUp() {
openMocks(this);
pricesController = new PricesController(mockedPricesService);
json = new JsonToObjectsCreator();
}
}
En uno de los casos unitarios, se obtiene la petición y la respuesta de los ficheros json. Se comprueba si la respuesta no viene nula, si el cuerpo de la respuesta no viene nula, si devuelve el tamaño correspondiente de la lista y si los datos por cada lista las devuelve como es debido:
public class PricesControllerTest {
@Test
void Test1_Given_StartDateAt10Date14AndEndDateAndBrandAndProduct_When_getPrices_Then_returns_PricesList() throws Exception {
System.out.println(Constants.LOG_CONTROLLER_TEST1);
// Given
PriceRqTest rq1 = json.test1PriceRqAt10Date14();
List<Prices> rsList1 = json.test1PriceRsAt10Date14();
// When
when(mockedPricesService.getPricesBetweenDatesAndBrandAndProduct(rq1.getStartDate(), rq1.getEndDate(),
rq1.getBrandId(), rq1.getProductId())).thenReturn(rsList1);
ResponseEntity<List<Prices>> responseEntity = pricesController.getPricesByDatesAndBrandAndProduct(rq1.getStartDate(),
rq1.getEndDate(), rq1.getBrandId(), rq1.getProductId());
responseEntity.getBody().stream()
.map(price -> price.toString())
.forEach(System.out::println);
System.out.println();
// Then
assertNotNull(responseEntity);
assertNotNull(responseEntity.getBody());
assertEquals(rsList1.size(), responseEntity.getBody().size());
assertEquals(rsList1.get(0), responseEntity.getBody().get(0));
assertEquals(rsList1.get(1), responseEntity.getBody().get(1));
assertEquals(rsList1.get(2), responseEntity.getBody().get(2));
assertEquals(rsList1.get(3), responseEntity.getBody().get(3));
System.out.println();
}
}
Otro caso unitario es cuando la fecha de iniciación es vacía y lanza la excepción ServiceException
:
@Test
void Given_StartDateEmptyAndEndDateAndBrandId1AndProductId35435_When_getPrice_Then_throws_ServiceException() throws ServiceException {
System.out.println(Constants.LOG_CONTROLLER_TEST_SERVICE_EXCEPTION);
// When
doThrow(mockedServiceException).when(mockedPricesService).getPricesBetweenDatesAndBrandAndProduct(Constants.EMPTY, Constants.END_DATE_STRING,
Constants.BRAND_ID, Constants.PRODUCT_ID);
ServiceException serviceException = assertThrows(ServiceException.class, () -> pricesController.getPricesByDatesAndBrandAndProduct(Constants.EMPTY,
Constants.END_DATE_STRING, Constants.BRAND_ID, Constants.PRODUCT_ID));
// Then
assertNotNull(serviceException);
System.out.println();
}
Dependencia | Descripción |
---|---|
Spring Boot Starter Web | Arranque de la aplicación |
Spring Boot Starter JPA | Persiste datos en almacenes SQL con Java Persistence API mediante Spring Data e Hibernate. |
Spring Boot DevTools | Herramienta de desarrollo como ver los logs. |
Spring Boot Starter Test | Se realizan los test unitarios. |
H2 | Base de datos en memoria. |
Lombok | Biblioteca de anotaciones Java que ayuda a reducir el código repetitivo. |
Jackson Datatype Joda | Instancia de ObjectMapper para serializar respuestas y deserializar solicitudes. |
Jackson Datatype Jsr310 | Ofrece una forma sencilla e intuitiva de crear un ObjectMapper. |
Jackson Databind | Biblioteca de utilidades para ayudar con el desarrollo de la funcionalidad de E/S. |
Common io | Convierte JSON a objetos y admite una fácil conversión a Map desde datos JSON. |