Estructuración de un Proyecto con Arquitectura Hexagonal en Spring Boot

Cesar A. Fernandez B.

Cesar A. Fernandez B.

Sr. Software Engineer

13 de mayo de 2025 22 min
Artículo

Estructuración de un Proyecto con Arquitectura Hexagonal en Spring Boot

Muchachones, Desarrollen aplicaciones mantenibles y escalables con una base sólida.

Tecnologías y Herramientas

  • Java 17+
  • Spring Boot 3+
  • Maven o Gradle (usaremos Maven para la estructura, pero puedes adaptarlo)
  • H2 Database (para persistencia en memoria, fácil de configurar)
  • JUnit 5, Mockito, AssertJ (para testing)

1. Configuración Inicial del Proyecto

Mis Bros, primero crean un nuevo proyecto Spring Boot utilizando Spring Initializr (start.spring.io) o tu IDE preferido (En mi caso Intellij). Asegúrate de incluir las siguientes dependencias:

  • Spring Web
  • Spring Data JPA
  • H2 Database
  • Lombok (opcional, pero útil para reducir boilerplate)
  • Spring Boot DevTools (opcional)
  • JUnit Jupiter, Mockito, AssertJ (ya vienen con
    spring-boot-starter-test
    )

Ejemplo

pom.xml
(fragmento con dependencias clave):

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
	    <groupId>org.springframework.boot</groupId>
	    <artifactId>spring-boot-starter-test</artifactId>
	    <scope>test</scope>
	</dependency>
    <dependency>
	    <groupId>org.mockito</groupId>
	    <artifactId>mockito-junit-jupiter</artifactId>
	    <scope>test</scope>
    </dependency>
    <dependency>
	    <groupId>org.assertj</groupId>
	    <artifactId>assertj-core</artifactId>
	    <scope>test</scope>
    </dependency>
</dependencies>

2. Estructura de Carpetas (Hexagonal)

Aca esta el chiste muchachones, La arquitectura hexagonal divide el código en tres capas principales:

  1. Dominio (Core):

    • Contiene entidades, reglas de negocio y puertos (interfaces).

    • Ejemplocom.cesarlead.inventory.domain

  2. Aplicación:

    • Implementa casos de uso y orquesta el flujo entre el dominio y la infraestructura.

    • Ejemplocom.cesarlead.inventory.application

  3. Infraestructura:

    • Adaptadores para bases de datos, APIs REST, mensajería, etc.

    • Ejemplocom.cesarlead.inventory.infrastructure

Diagrama de la estructura Hexagonal

Aquí la estructura que seguiremos dentro de src/main/java/com/cesarlead/inventory/:

Estructura de carpetas

Principio Clave: La dependencia siempre apunta hacia adentro. El Dominio no conoce la Aplicación o Infraestructura. La Aplicación conoce el Dominio, pero no la Infraestructura. La Infraestructura conoce la Aplicación y el Dominio.


3. Capa de Dominio

Esta capa contiene la lógica de negocio central. No debe tener dependencias de frameworks externos (Spring, JPA, etc.).

3.1. Modelo de Dominio: Product.java

Representa un producto en nuestro inventario. Es inmutable y contiene validaciones básicas.

package com.cesarlead.inventory.domain.model;

import java.util.Objects;

// Usamos récord (Java 14+) o clase final para inmutabilidad
public final class Product { // Si no usas récord

    private final String sku; // Stock Keeping Unit - Identificador único
    private final String name;
    private final int quantity;

    public Product(String sku, String name, int quantity) {
        // Validaciones de invariantes del dominio en el constructor
        this.sku = Objects.requireNonNull(sku, "SKU no puede ser null");
        this.name = Objects.requireNonNull(name, "Nombre no puede ser null");
        if (quantity < 0) {
            throw new IllegalArgumentException("Cantidad no puede ser negativa");
        }
        this.quantity = quantity;
    }

    // Método de dominio para actualizar la cantidad (devuelve nueva instancia)
    public Product withQuantity(int newQuantity) {
         if (newQuantity < 0) {
            throw new IllegalArgumentException("Nueva cantidad no puede ser negativa");
        }
        return new Product(this.sku, this.name, newQuantity);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Product product = (Product) o;
        return quantity == product.quantity &&
               Objects.equals(sku, product.sku) &&
               Objects.equals(name, product.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(sku, name, quantity);
    }

    @Override
    public String toString() {
        return "Product{" +
               "sku='" + sku + '\'' +
               ", name='" + name + '\'' +
               ", quantity=" + quantity +
               '}';
    }
}

Reflexión:

  • SOLID (S - Single Responsibility): La clase Product solo se encarga de representar y validar el estado de un producto. No conoce la lógica de guardado o presentación.
  • Inmutabilidad: Usar final y no tener setters hace que la clase sea segura para hilos y más predecible. Las actualizaciones se manejan creando nuevas instancias (withQuantity).
  • Validación: Las validaciones en el constructor aseguran que nunca tengamos un objeto Product inválido.

3.2. Excepción de Dominio: ProductNotFoundException.java

Una excepción específica para manejar un error de negocio conocido.

package com.cesarlead.inventory.domain.exception;

// Hereda de RuntimeException para no forzar 'throws' en los métodos de puerto/servicio
public class ProductNotFoundException extends RuntimeException {

    public ProductNotFoundException(String sku) {
        super("Producto con SKU '" + sku + "' no encontrado");
    }
}

4. Capa de Aplicación (Puertos y Servicio)

Esta capa define los contratos (puertos) para interactuar con la aplicación y contiene la implementación de los casos de uso.

4.1. Puertos de Entrada: ProductServicePort.java

Define la interfaz que los adaptadores de entrada (ej. REST controller) usarán para interactuar con la lógica de la aplicación. Piensa en esto como la "API" de tu capa de aplicación.

package com.cesarlead.inventory.application.ports.input;

import com.cesarlead.inventory.application.ports.input.dto.CreateProductCommand;
import com.cesarlead.inventory.application.ports.input.dto.ProductDto;

import java.util.List;

// Este es un Puerto de Entrada (Driven Port)
public interface ProductServicePort {

    // Usa DTOs específicos para la entrada/salida de la aplicación si es necesario
    ProductDto createProduct(CreateProductCommand command);
    ProductDto getBySku(String sku);
    List<ProductDto> listAll();
}

Nota sobre DTOs: Aquí usamos DTOs (CreateProductCommand, ProductDto) para la comunicación a través del puerto. Esto desacopla la capa de aplicación del modelo de dominio, permitiendo que los adaptadores de entrada (REST, por ejemplo) usen estructuras de datos que les sean convenientes, sin exponer directamente el modelo de dominio. CreateProductCommand es un "comando" que encapsula los datos necesarios para una acción. ProductDto es un objeto de transferencia de datos para representar el producto hacia afuera.

DTOs de Entrada:

CreateProductCommand.java

package com.cesarlead.inventory.application.ports.input.dto;

// Record para un DTO simple
public record CreateProductCommand(String sku, String name, int quantity) {}

ProductDto.java

package com.cesarlead.inventory.application.ports.input.dto;

import com.cesarlead.inventory.domain.model.Product;

// DTO de salida que representa un producto
public record ProductDto(String sku, String name, int quantity) {

    // Método de fábrica para mapear del modelo de dominio al DTO
    public static ProductDto fromDomain(Product product) {
        return new ProductDto(product.sku(), product.getName(), product.getQuantity());
    }
}

4.2. Puertos de Salida: ProductPersistencePort.java

Define la interfaz que la lógica de la aplicación (el servicio) usará para interactuar con la infraestructura (ej. base de datos). Piensa en esto como un "Plugin Point" para la infraestructura.

package com.cesarlead.inventory.application.ports.output;

import com.cesarlead.inventory.domain.model.Product;

import java.util.List;
import java.util.Optional;

// Este es un Puerto de Salida (Driving Port)
public interface ProductPersistencePort {

    Product save(Product product);
    Optional<Product> findBySku(String sku);
    List<Product> findAll();
}

Reflexión sobre Puertos:

  • SOLID (D - Dependency Inversion): La lógica de la aplicación (ProductService) no depende de implementaciones concretas (ej. JpaProductRepository), sino de abstracciones (interfaces ProductServicePort, ProductPersistencePort). Esto permite cambiar la tecnología de persistencia o el adaptador de entrada sin modificar la lógica central del negocio.
  • SOLID (I - Interface Segregation): Las interfaces son finas y específicas para su propósito (servicio vs. persistencia).
  • Acoplamiento Débil: Los puertos promueven un bajo acoplamiento entre la lógica de negocio y los detalles técnicos.

4.3. Servicio de Aplicación: ProductService.java

Implementa el puerto de entrada ProductServicePort. Contiene la orquestación de la lógica de negocio, utilizando los puertos de salida para interactuar con la infraestructura.

package com.cesarlead.inventory.application.service;

import com.cesarlead.inventory.application.ports.input.ProductServicePort;
import com.cesarlead.inventory.application.ports.input.dto.CreateProductCommand;
import com.cesarlead.inventory.application.ports.input.dto.ProductDto;
import com.cesarlead.inventory.application.ports.output.ProductPersistencePort;
import com.cesarlead.inventory.domain.exception.ProductNotFoundException;
import com.cesarlead.inventory.domain.model.Product;
import org.springframework.stereotype.Service; // Anotación de Spring para inyección de dependencias

import java.util.List;
import java.util.stream.Collectors;

// Implementa el puerto de entrada
@Service // Spring lo reconoce como un componente gestionable
public class ProductService implements ProductServicePort {

    // Depende del puerto de salida (abstracción), NO de la implementación concreta
    private final ProductPersistencePort persistence;

    // Inyección de dependencias por constructor (preferible en Spring)
    public ProductService(ProductPersistencePort persistence) {
        this.persistence = persistence;
    }

    @Override
    public ProductDto createProduct(CreateProductCommand cmd) {
        // Mapea el comando de entrada al modelo de dominio
        Product toSave = new Product(cmd.sku(), cmd.name(), cmd.quantity());

        // Usa el puerto de salida para interactuar con la persistencia
        Product saved = persistence.save(toSave);

        // Mapea el modelo de dominio guardado al DTO de salida
        return ProductDto.fromDomain(saved);
    }

    @Override
    public ProductDto getBySku(String sku) {
        // Usa el puerto de salida
        Product product = persistence.findBySku(sku)
            .orElseThrow(() -> new ProductNotFoundException(sku)); // Lógica de negocio: si no existe, lanza excepción de dominio

        // Mapea al DTO de salida
        return ProductDto.fromDomain(product);
    }

    @Override
    public List<ProductDto> listAll() {
        // Usa el puerto de salida
        List<Product> products = persistence.findAll();

        // Mapea la lista de modelos de dominio a una lista de DTOs de salida
        return products.stream()
            .map(ProductDto::fromDomain)
            .collect(Collectors.toList());
    }
}

Reflexión:

  • Este servicio contiene la lógica de orquestación. No implementa reglas de negocio complejas (esas van en el modelo de dominio si aplican), sino que coordina las acciones necesarias para completar un caso de uso (ej. crear, obtener, listar).
  • Depende solo de interfaces (ProductPersistencePort). Esto facilita las pruebas unitarias (veremos más adelante) y permite cambiar la implementación de persistencia sin tocar este código.
  • Maneja la excepción de negocio (ProductNotFoundException) que viene del dominio o que es lanzada al no encontrar algo a través del puerto de salida.

5. Capa de Infraestructura (Adaptadores)

Esta capa contiene las implementaciones concretas de los puertos. Aquí es donde interactuamos con frameworks, bases de datos, sistemas externos, etc.

5.1. Adaptadores de Persistencia (Salida)

Implementan el puerto ProductPersistencePort usando Spring Data JPA y H2.

ProductEntity.java

package com.cesarlead.inventory.infrastructure.adapters.output.persistence;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter; // Usamos Lombok para reducir boilerplate en la entidad JPA

@Entity // Indica que es una entidad JPA
@Table(name = "products") // Nombre de la tabla en la BBDD
@Getter // Genera getters
@Setter // Genera setters (necesarios para JPA/Hibernate)
@NoArgsConstructor // Constructor sin argumentos (necesario para JPA)
public class ProductEntity {

    @Id // Indica que este campo es la clave primaria
    private String sku; // Usamos el SKU como clave primaria
    private String name;
    private int quantity;

    // JPA necesita un constructor sin argumentos, los setters son para que pueda hidratar el objeto.
    // Esto contrasta con el modelo de dominio inmutable, que es intencional.
}

ProductRepository.java

package com.cesarlead.inventory.infrastructure.adapters.output.persistence;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; // Opcional, pero aclara la intención

// Spring Data JPA generará la implementación en tiempo de ejecución
@Repository
public interface ProductRepository extends JpaRepository<ProductEntity, String> {
    // Spring Data JPA proporciona implementaciones para save(), findById(), findAll() automáticamente
    // Puedes añadir métodos custom si son necesarios (ej. findByNameIgnoringCase)
}

ProductPersistenceMapper.java

Encargado de mapear entre la entidad JPA (ProductEntity) y el modelo de dominio (Product).

package com.cesarlead.inventory.infrastructure.adapters.output.persistence.mapper;

import com.cesarlead.inventory.domain.model.Product;
import com.cesarlead.inventory.infrastructure.adapters.output.persistence.ProductEntity;
import org.springframework.stereotype.Component; // Puede ser un componente Spring si necesitas inyectarlo

// Usamos métodos static para un mapeo sencillo
@Component // Opcional, si prefieres inyectar el mapper. Si no, usa solo métodos estáticos.
public class ProductPersistenceMapper {

    // Mapea de Entidad JPA a Modelo de Dominio
    public static Product toDomain(ProductEntity entity) {
        if (entity == null) {
            return null;
        }
        // Creamos una nueva instancia del modelo de dominio
        return new Product(entity.getSku(), entity.getName(), entity.getQuantity());
    }

    // Mapea de Modelo de Dominio a Entidad JPA
    public static ProductEntity toEntity(Product domain) {
        if (domain == null) {
            return null;
        }
        ProductEntity entity = new ProductEntity();
        entity.setSku(domain.getSku());
        entity.setName(domain.getName());
        entity.setQuantity(domain.getQuantity());
        // Usamos setters para la entidad JPA mutable
        return entity;
    }

    // Si usas Spring Component, puedes inyectar y usar métodos no estáticos:
    /*
    public Product toDomain(ProductEntity entity) {
        // ... implementación
    }
    */
}

ProductPersistenceAdapter.java

Implementa el puerto de salida ProductPersistencePort, actuando como la "puerta" entre la lógica de negocio y el repositorio JPA.

package com.cesarlead.inventory.infrastructure.adapters.output.persistence;

import com.cesarlead.inventory.application.ports.output.ProductPersistencePort;
import com.cesarlead.inventory.domain.model.Product;
import com.cesarlead.inventory.infrastructure.adapters.output.persistence.mapper.ProductPersistenceMapper;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

// Este componente implementa el puerto de salida
@Component // Componente gestionado por Spring
public class ProductPersistenceAdapter implements ProductPersistencePort {

    // Depende del repositorio Spring Data JPA (detalle de infraestructura)
    private final ProductRepository repository;
    private final ProductPersistenceMapper mapper; // Inyectamos el mapper si es un componente

    // Inyección por constructor
    public ProductPersistenceAdapter(ProductRepository repository, ProductPersistenceMapper mapper) {
        this.repository = repository;
        this.mapper = mapper; // Asignamos el mapper inyectado
    }

    @Override
    public Product save(Product product) {
        // Mapea del modelo de dominio a la entidad JPA
        ProductEntity entityToSave = mapper.toEntity(product); // Usamos el mapper inyectado
        // Usa el repositorio JPA para guardar
        ProductEntity savedEntity = repository.save(entityToSave);
        // Mapea de la entidad JPA guardada al modelo de dominio y lo devuelve
        return mapper.toDomain(savedEntity); // Usamos el mapper inyectado
    }

    @Override
    public Optional<Product> findBySku(String sku) {
        // Usa el repositorio JPA
        Optional<ProductEntity> entityOptional = repository.findById(sku);
        // Mapea el resultado (si existe) de entidad JPA a modelo de dominio
        return entityOptional.map(mapper::toDomain); // Usamos el mapper inyectado
    }

    @Override
    public List<Product> findAll() {
        // Usa el repositorio JPA
        List<ProductEntity> entities = repository.findAll();
        // Mapea la lista de entidades JPA a una lista de modelos de dominio
        return entities.stream()
            .map(mapper::toDomain) // Usamos el mapper inyectado
            .collect(Collectors.toList());
    }
}

Reflexión:

  • Este adaptador conoce las clases específicas de JPA (ProductEntity, ProductRepository), pero el ProductService no.
  • Implementa la interfaz del puerto (ProductPersistencePort), cumpliendo el contrato que el ProductService espera.
  • El uso de un mapper desacopla la lógica de mapeo de la lógica del adaptador.

5.2. Configuración de Persistencia

Configuramos Spring Data JPA para que escanee nuestros repositorios.

PersistenceConfig.java

package com.cesarlead.inventory.infrastructure.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;

// Configuración para habilitar Spring Data JPA y especificar dónde buscar repositorios
@Configuration
@EnableJpaRepositories(basePackages = "com.cesarlead.inventory.infrastructure.adapters.output.persistence")
public class PersistenceConfig {
    // No necesitas definir Beans aquí si Spring Boot Autoconfiguration ya los crea
    // (DataSource, EntityManagerFactory, TransactionManager), lo cual es el caso
    // con las dependencias de starter-data-jpa y h2.
    // Esta clase solo es necesaria para @EnableJpaRepositories si no está en la clase principal @SpringBootApplication
}

src/main/resources/application.yml (o application.properties)

Configuramos la base de datos H2 en memoria.

spring:
  datasource:
    url: jdbc:h2:mem:invdb;DB_CLOSE_DELAY=-1 # Base de datos en memoria, no se cierra
    driver-class-name: org.h2.Driver
    username: sa
    password: password
  jpa:
    hibernate:
      ddl-auto: update # Crea o actualiza el esquema de la BBDD automáticamente
    show-sql: true # Muestra las consultas SQL en consola
    properties:
      hibernate:
        format_sql: true # Formatea el SQL mostrado
  h2:
    console:
      enabled: true # Habilita la consola web de H2 (útil para debug)
      path: /h2-console # Path para acceder a la consola (ej: http://localhost:8080/h2-console)

5.3. Adaptadores de Entrada (REST)

Implementan el puerto de entrada ProductServicePort, exponiendo la funcionalidad a través de una API REST.

DTOs de Entrada/Salida (REST):

Estos DTOs están diseñados específicamente para la API REST.

CreateProductRequest.java

package com.cesarlead.inventory.infrastructure.adapters.input.rest.dto;

// DTO para la solicitud de creación de producto
public record CreateProductRequest(String sku, String name, int quantity) {}

ProductResponse.java

package com.cesarlead.inventory.infrastructure.adapters.input.rest.dto;

import com.cesarlead.inventory.application.ports.input.dto.ProductDto;

// DTO para la respuesta de producto
public record ProductResponse(String sku, String name, int quantity) {

    // Método de fábrica para mapear desde el DTO de la capa de aplicación
    public static ProductResponse fromDto(ProductDto applicationDto) {
        return new ProductResponse(
            applicationDto.sku(),
            applicationDto.name(),
            applicationDto.quantity()
        );
    }
}

Mapper (REST):

Mapea entre los DTOs de la API REST y los DTOs/Comandos de la capa de aplicación.

ProductRestMapper.java

package com.cesarlead.inventory.infrastructure.adapters.input.rest.mapper;

import com.cesarlead.inventory.application.ports.input.dto.CreateProductCommand;
import com.cesarlead.inventory.infrastructure.adapters.input.rest.dto.CreateProductRequest;
import org.springframework.stereotype.Component;

@Component // Puede ser un componente Spring
public class ProductRestMapper {

    // Mapea de Request REST a Comando de la capa de aplicación
    public CreateProductCommand toCommand(CreateProductRequest request) {
        if (request == null) {
            return null;
        }
        return new CreateProductCommand(request.sku(), request.name(), request.quantity());
    }

    // No necesitamos un mapper para ProductDto -> ProductResponse si usamos el método de fábrica en ProductResponse
}

ProductRestAdapter.java

El controlador REST que actúa como el adaptador de entrada principal.

package com.cesarlead.inventory.infrastructure.adapters.input.rest;

import com.cesarlead.inventory.application.ports.input.ProductServicePort;
import com.cesarlead.inventory.application.ports.input.dto.ProductDto;
import com.cesarlead.inventory.domain.exception.ProductNotFoundException;
import com.cesarlead.inventory.infrastructure.adapters.input.rest.dto.CreateProductRequest;
import com.cesarlead.inventory.infrastructure.adapters.input.rest.dto.ProductResponse;
import com.cesarlead.inventory.infrastructure.adapters.input.rest.mapper.ProductRestMapper;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.net.URI;
import java.util.List;
import java.util.stream.Collectors;

@RestController // Indica que es un controlador REST de Spring
@RequestMapping("/api/products") // Path base para los endpoints
public class ProductRestAdapter {

    // Depende del puerto de entrada (interfaz), NO de la implementación concreta del servicio
    private final ProductServicePort service;
    private final ProductRestMapper mapper;

    // Inyección por constructor
    public ProductRestAdapter(ProductServicePort service, ProductRestMapper mapper) {
        this.service = service;
        this.mapper = mapper;
    }

    @PostMapping // Mapea a solicitudes POST /api/products
    public ResponseEntity<ProductResponse> create(@RequestBody CreateProductRequest request) {
        // Mapea la solicitud REST al comando de la capa de aplicación
        var command = mapper.toCommand(request);
        // Llama al servicio de la capa de aplicación a través de su puerto
        ProductDto productDto = service.createProduct(command);
        // Mapea el DTO de la capa de aplicación a la respuesta REST
        var response = ProductResponse.fromDto(productDto);

        // Devuelve una respuesta HTTP 201 Created con la URI del recurso creado
        return ResponseEntity
            .created(URI.create("/api/products/" + response.sku())) // Construye la URI del nuevo recurso
            .body(response);
    }

    @GetMapping("/{sku}") // Mapea a solicitudes GET /api/products/{sku}
    public ProductResponse getBySku(@PathVariable String sku) {
        // Llama al servicio a través de su puerto
        ProductDto productDto = service.getBySku(sku);
        // Mapea el DTO a la respuesta REST
        return ProductResponse.fromDto(productDto);
    }

    @GetMapping // Mapea a solicitudes GET /api/products
    public List<ProductResponse> listAll() {
        // Llama al servicio
        List<ProductDto> productDtos = service.listAll();
        // Mapea la lista de DTOs a una lista de respuestas REST
        return productDtos.stream()
            .map(ProductResponse::fromDto)
            .collect(Collectors.toList());
    }

    // Manejo de excepciones específicas para la API REST
    @ExceptionHandler(ProductNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND) // Devuelve 404 Not Found
    public String handleProductNotFoundException(ProductNotFoundException ex) {
        return ex.getMessage(); // Devuelve el mensaje de la excepción
    }

    // Puedes añadir más @ExceptionHandler para otros tipos de excepciones (ej. Validaciones)
}

Reflexión:

  • El ProductRestAdapter es un detalle de la infraestructura. No contiene lógica de negocio compleja. Su trabajo es recibir peticiones, mapear datos a un formato que la capa de aplicación entienda (DTOs/comandos), llamar al servicio de aplicación a través de su puerto, recibir la respuesta, mapearla a un formato REST (DTOs de respuesta) y enviarla de vuelta.
  • Maneja excepciones específicas de la capa de aplicación/dominio (ProductNotFoundException) y las traduce a respuestas HTTP apropiadas (ej. 404 Not Found).
  • Depende solo de la interfaz ProductServicePort. Si decidieras, por ejemplo, exponer la funcionalidad a través de un adaptador de gRPC en lugar de REST, el ProductService no necesitaría cambios.

6. Inyección de Dependencias (Spring)

Spring Boot Autoconfiguration y las anotaciones (@Service, @Component, @Repository) se encargan en gran medida de cablear los componentes.

  • Spring creará una instancia de ProductPersistenceAdapter porque implementa ProductPersistencePort y está anotada con @Component. Spring Data JPA creará una instancia de ProductRepository.
  • Spring creará una instancia de ProductService porque implementa ProductServicePort y está anotada con @Service. Al construirla, Spring inyectará la instancia de ProductPersistenceAdapter porque implementa ProductPersistencePort (la dependencia declarada en el constructor de ProductService).
  • Spring creará instancias de ProductRestMapper y ProductPersistenceMapper porque están anotadas con @Component.
  • Spring creará una instancia de ProductRestAdapter porque está anotada con @RestController. Al construirla, Spring inyectará la instancia de ProductService (que implementa ProductServicePort) y la instancia de ProductRestMapper.

7. Pruebas

Este es un apartado muchachones que muchos evitan, jaja jaja por los PM diciendo hay que entregar o los tiempos estan muy ajustados, etc, pero las pruebas son fundamentales para asegurar la calidad y la mantenibilidad de la aplicación. En arquitectura hexagonal, probamos las diferentes capas de forma aislada y en conjunto.

7.1. Pruebas Unitarias del Servicio de Aplicación (ProductServiceTest.java)

Probamos la lógica del caso de uso (ProductService) sin depender de la implementación real de la persistencia. Usamos Mockito para "simular" (mock) el comportamiento del puerto de salida (ProductPersistencePort).

package com.cesarlead.inventory.application.service;

import com.cesarlead.inventory.application.ports.input.dto.CreateProductCommand;
import com.cesarlead.inventory.application.ports.output.ProductPersistencePort;
import com.cesarlead.inventory.domain.exception.ProductNotFoundException;
import com.cesarlead.inventory.domain.model.Product;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks; // Inyecta mocks en esta instancia
import org.mockito.Mock; // Crea un mock de esta dependencia
import org.mockito.MockitoAnnotations; // Inicializa mocks

import java.util.Optional;
import java.util.List;

import static org.assertj.core.api.Assertions.*; // Para aserciones más legibles
import static org.mockito.Mockito.*; // Para definir comportamiento de mocks

class ProductServiceTest {

    @Mock // Mockeamos la dependencia de salida (el puerto de persistencia)
    ProductPersistencePort persistence;

    @InjectMocks // La instancia de ProductService donde se inyectarán los mocks
    ProductService service;

    @BeforeEach // Se ejecuta antes de cada test
    void setUp() {
        // Inicializa los mocks anotados
        MockitoAnnotations.openMocks(this);
    }

    @Test
    void createProduct_shouldSaveProductAndReturnDto() {
        // Arrange (Preparar)
        var command = new CreateProductCommand("SKU123", "Laptop", 10);
        var domainProduct = new Product("SKU123", "Laptop", 10); // El objeto dominio esperado
        // Definir el comportamiento del mock: cuando se llame save() con un Product igual a domainProduct, devolver domainProduct
        when(persistence.save(any(Product.class))).thenReturn(domainProduct); // Usamos any() porque la instancia exacta puede variar ligeramente

        // Act (Actuar)
        var resultDto = service.createProduct(command);

        // Assert (Asegurar)
        // Verificamos que el puerto de persistencia fue llamado con el Product esperado
        verify(persistence).save(argThat(p -> p.getSku().equals("SKU123") && p.getName().equals("Laptop") && p.getQuantity() == 10));
        // Verificamos el contenido del DTO retornado
        assertThat(resultDto).isNotNull();
        assertThat(resultDto.getSku()).isEqualTo("SKU123");
        assertThat(resultDto.getName()).isEqualTo("Laptop");
        assertThat(resultDto.getQuantity()).isEqualTo(10);
    }

    @Test
    void getBySku_whenProductExists_shouldReturnProductDto() {
        // Arrange
        String sku = "SKU456";
        var domainProduct = new Product(sku, "Mouse", 5);
        // Mock: cuando se llame findBySku() con "SKU456", devolver Optional.of(domainProduct)
        when(persistence.findBySku(sku)).thenReturn(Optional.of(domainProduct));

        // Act
        var resultDto = service.getBySku(sku);

        // Assert
        // Verificamos que el puerto fue llamado
        verify(persistence).findBySku(sku);
        // Verificamos el DTO retornado
        assertThat(resultDto).isNotNull();
        assertThat(resultDto.getSku()).isEqualTo(sku);
        assertThat(resultDto.getName()).isEqualTo("Mouse");
        assertThat(resultDto.getQuantity()).isEqualTo(5);
    }

    @Test
    void getBySku_whenProductDoesNotExist_shouldThrowNotFoundException() {
        // Arrange
        String sku = "SKU-NON-EXISTENT";
        // Mock: cuando se llame findBySku() con este SKU, devolver Optional.empty()
        when(persistence.findBySku(sku)).thenReturn(Optional.empty());

        // Act & Assert
        // Verificamos que se lanza la excepción esperada
        assertThatThrownBy(() -> service.getBySku(sku))
            .isInstanceOf(ProductNotFoundException.class)
            .hasMessageContaining(sku); // Verificamos el mensaje de la excepción

        // Verificamos que el puerto fue llamado
        verify(persistence).findBySku(sku);
        // Verificamos que NO se llamó a save() u otros métodos (opcional pero buena práctica)
        verifyNoMoreInteractions(persistence);
    }

     @Test
    void listAll_shouldReturnListOfProductDtos() {
        // Arrange
        var product1 = new Product("SKU001", "Keyboard", 10);
        var product2 = new Product("SKU002", "Monitor", 5);
        List<Product> domainProducts = List.of(product1, product2);
        // Mock: cuando se llame findAll(), devolver la lista de productos de dominio
        when(persistence.findAll()).thenReturn(domainProducts);

        // Act
        List<com.cesarlead.inventory.application.ports.input.dto.ProductDto> resultDtos = service.listAll();

        // Assert
        // Verificamos que el puerto fue llamado
        verify(persistence).findAll();
        // Verificamos la lista de DTOs retornada
        assertThat(resultDtos)
            .hasSize(2)
            .extracting("sku", "name", "quantity") // Extraemos campos para comparar fácilmente
            .containsExactlyInAnyOrder(
                tuple("SKU001", "Keyboard", 10),
                tuple("SKU002", "Monitor", 5)
            );
         verifyNoMoreInteractions(persistence);
    }
}

Principios de Testing:

  • Pruebas Unitarias: Aislamos la unidad bajo prueba (ProductService) mockeando sus dependencias directas (ProductPersistencePort).
  • Verificación de Comportamiento: No solo verificamos el resultado retornado, sino también que el servicio interactuó correctamente con sus dependencias (ej. verify(persistence).save(...)).
  • AssertJ: Proporciona una sintaxis más fluida y expresiva para las aserciones.

7.2. Pruebas de Integración del Adaptador REST (ProductRestAdapterIntegrationTest.java)

Probamos el adaptador REST en un entorno más cercano a la ejecución real, con el contexto de Spring Boot cargado. Esto prueba que el adaptador puede comunicarse correctamente con la capa de aplicación a través de su puerto y que el cableado de Spring funciona.

package com.cesarlead.inventory.infrastructure.adapters.input.rest;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; // Configura MockMvc
import org.springframework.boot.test.context.SpringBootTest; // Carga el contexto de Spring Boot
import org.springframework.http.MediaType; // Para tipos de contenido HTTP
import org.springframework.test.web.servlet.MockMvc; // Para realizar solicitudes HTTP simuladas
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; // Para construir solicitudes
import org.springframework.test.web.servlet.result.MockMvcResultMatchers; // Para verificar resultados

// Carga el contexto completo de Spring Boot para pruebas de integración
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) // Inicia el servidor en un puerto aleatorio
@AutoConfigureMockMvc // Configura MockMvc para realizar llamadas HTTP simuladas
class ProductRestAdapterIntegrationTest {

    @Autowired
    private MockMvc mvc; // Inyecta MockMvc

    @Test
    void createAndGetProduct_shouldSucceed() throws Exception {
        // Paso 1: Crear un producto (POST)
        String createProductRequestBody = "{\"sku\":\"INT-TEST-001\",\"name\":\"Integration Test Product\",\"quantity\":99}";

        mvc.perform(MockMvcRequestBuilders.post("/api/products") // Realiza un POST a /api/products
                .contentType(MediaType.APPLICATION_JSON) // Indica que el cuerpo es JSON
                .content(createProductRequestBody)) // Establece el cuerpo de la solicitud
                .andExpect(MockMvcResultMatchers.status().isCreated()) // Espera un código de estado 201 Created
                .andExpect(MockMvcResultMatchers.header().exists("Location")) // Espera que exista el header Location
                .andExpect(MockMvcResultMatchers.jsonPath("$.sku").value("INT-TEST-001")); // Verifica un campo en la respuesta JSON

        // Paso 2: Obtener el producto creado (GET)
        mvc.perform(MockMvcRequestBuilders.get("/api/products/INT-TEST-001")) // Realiza un GET a /api/products/{sku}
                .andExpect(MockMvcResultMatchers.status().isOk()) // Espera un código de estado 200 OK
                .andExpect(MockMvcResultMatchers.jsonPath("$.sku").value("INT-TEST-001")) // Verifica campos en la respuesta JSON
                .andExpect(MockMvcResultMatchers.jsonPath("$.name").value("Integration Test Product"))
                .andExpect(MockMvcResultMatchers.jsonPath("$.quantity").value(99));

         // Paso 3: Intentar obtener un producto que no existe
        mvc.perform(MockMvcRequestBuilders.get("/api/products/NON-EXISTENT-SKU"))
                .andExpect(MockMvcResultMatchers.status().isNotFound()); // Espera un código de estado 404 Not Found
    }

     @Test
    void listAllProducts_shouldReturnList() throws Exception {
         // Creamos algunos productos primero (la BBDD en memoria se limpia entre tests con Spring Boot Test por defecto)
         String product1 = "{\"sku\":\"LIST-001\",\"name\":\"Item 1\",\"quantity\":10}";
         String product2 = "{\"sku\":\"LIST-002\",\"name\":\"Item 2\",\"quantity\":20}";

         mvc.perform(MockMvcRequestBuilders.post("/api/products")
                 .contentType(MediaType.APPLICATION_JSON)
                 .content(product1))
             .andExpect(MockMvcResultMatchers.status().isCreated());

        mvc.perform(MockMvcRequestBuilders.post("/api/products")
                .contentType(MediaType.APPLICATION_JSON)
                .content(product2))
            .andExpect(MockMvcResultMatchers.status().isCreated());


         // Listar todos los productos (GET)
         mvc.perform(MockMvcRequestBuilders.get("/api/products"))
             .andExpect(MockMvcResultMatchers.status().isOk())
             .andExpect(MockMvcResultMatchers.jsonPath("$").isArray()) // Espera una respuesta que sea un array JSON
             .andExpect(MockMvcResultMatchers.jsonPath("$.length()").value(2)) // Espera que el array tenga 2 elementos
             // Puedes verificar contenido más específico si es necesario
             .andExpect(MockMvcResultMatchers.jsonPath("$[0].sku").isIn("LIST-001", "LIST-002")) // Verifica que los SKUs esperados estén presentes
             .andExpect(MockMvcResultMatchers.jsonPath("$[1].sku").isIn("LIST-001", "LIST-002"));
    }
}

Principios de Testing:

  • Pruebas de Integración: Probamos cómo interactúan las capas (Adaptador REST, Servicio de Aplicación, Adaptador de Persistencia con H2 en memoria).
  • MockMvc: Nos permite simular peticiones HTTP sin necesidad de levantar un servidor real y hacer llamadas de red. Es más rápido que una prueba end-to-end.
  • Base de Datos en Memoria: H2 facilita las pruebas de integración de persistencia, ya que cada test puede empezar con una base de datos limpia.

8. Consideraciones Adicionales y Mejores Prácticas

  • Validación de Entrada: Usa @Valid y Bean Validation en tus DTOs de entrada REST (CreateProductRequest) para validar los datos tan pronto como llegan al adaptador. Esto es una responsabilidad del adaptador de entrada.
  • Mappers: Para mapeos más complejos o para reducir el boilerplate del mapeo manual, considera usar bibliotecas como MapStruct. MapStruct genera código de mapeo en tiempo de compilación, lo que es eficiente y seguro en tipos.
  • Observabilidad: Implementa logging, métricas (Micrometer con Prometheus/Grafana) y tracing (Spring Cloud Sleuth/Micrometer Tracing con Zipkin/Jaeger) para entender el comportamiento de tu aplicación en producción.
  • Seguridad: Protege tus endpoints REST con autenticación y autorización (Spring Security es el estándar).
  • Manejo Global de Excepciones: Considera usar @ControllerAdvice y @ExceptionHandler o ProblemDetail (Spring Boot 3) para un manejo centralizado y consistente de errores HTTP.
  • Asincronía/Eventos: Para sistemas más complejos, podrías introducir un puerto de salida para publicar eventos (ej. "Producto creado") y adaptadores que implementen este puerto (ej. enviando mensajes a Kafka o RabbitMQ).

Bueno cabros (como dirian mis panas chilenos) hasta aca nos trajo el rio, espero les hayan quedado claros los conceptos, hasta un tuto de test les entregue a la final, jaja jaja, es que me extiendo, y vaya me voy por las rayas, activitos... Y a practicar muchachones que eso es lo que nos afianza las habilidades que necesitamos.

👁️ 3396 vistas