Catalog Service: Microservicio con Arquitectura Hexagonal

Microservicio empresarial de catálogo geográfico desarrollado con Arquitectura Hexagonal (Ports & Adapters), Java 25 y Spring Boot 4.0.5. Expone datos de referencia (países, comunidades autónomas, provincias, localidades y tipos de vía) mediante dos protocolos simultáneos: API REST y gRPC.

1. Código fuente

El repositorio completo está disponible en GitHub: iCesofT/arquitectura-hexagonal.


2. Introducción

catalog-service es un microservicio de catálogo geográfico que expone datos de referencia mediante dos protocolos:

  • REST — API HTTP/1.1 y HTTP/2 generada a partir de especificaciones OpenAPI 3.0

  • gRPC — API binaria de alta eficiencia generada a partir de Protocol Buffers

El servicio está diseñado para entornos de producción de alta concurrencia, incorporando múltiples estrategias de optimización.

2.1. Objetivos de diseño

ObjetivoDescripción

Mantenibilidad

Código organizado en capas bien definidas mediante Arquitectura Hexagonal, con dependencias unidireccionales estrictamente controladas.

Testeabilidad

Cada capa es testable de forma independiente. El dominio no tiene dependencia con ningún framework.

Rendimiento

Caché de dos niveles (Caffeine + Redis), pool de conexiones HikariCP, virtual threads de Java y serialización optimizada con Jackson Blackbird.

Observabilidad

Métricas con Micrometer/Prometheus, trazabilidad OTLP y actuator endpoints para health checks y readiness probes.

Seguridad

Contenedor ejecutado con usuario no root, imagen Alpine minimalista, dependencias auditadas con OWASP Dependency-Check.

Portabilidad

Docker multi-stage con capas optimizadas mediante Spring Boot Layertools, despliegue local con docker-compose.

2.2. Stack tecnológico

CategoríaTecnologíaRol

Runtime

Java 25

Virtual threads, ZGC, rendimiento mejorado

Framework

Spring Boot 4.0.5

Contenedor de IoC, autoconfiguración, actuator

Persistencia

Spring Data JPA + Hibernate 7

ORM con soporte para batch DML y proxies de mejora

Base de datos

PostgreSQL 16

Base de datos relacional principal

Migraciones

Liquibase 5

Control de versiones del esquema de base de datos

Caché L1

Caffeine 3

Caché local en memoria (heap), acceso sub-microsegundo

Caché L2

Redis 8

Caché distribuida, compartida entre instancias

API REST

OpenAPI 3.0 + Spring MVC

Generación de código a partir de contrato

API gRPC

Protocol Buffers 4 + Spring gRPC

Comunicación binaria eficiente entre servicios

Mapeo

MapStruct 1.6

Generación de mappers en tiempo de compilación

Logging

Log4j2 + LMAX Disruptor

Logging asíncrono de ultra-baja latencia

Métricas

Micrometer + Prometheus

Instrumentación y exportación de métricas

Contenedor

Docker + Alpine + ZGC

Imagen minimalista optimizada para producción


3. Arquitectura Hexagonal

3.1. ¿Qué es?

La Arquitectura Hexagonal (también conocida como Ports & Adapters, propuesta por Alistair Cockburn en 2005) organiza el software en torno a la lógica de negocio, aislándola completamente de los detalles técnicos (base de datos, frameworks, protocolos de red, etc.) mediante el uso de puertos e interfaces de adaptación.

Permite que una aplicación sea controlada igualmente por usuarios, programas, tests automatizados o scripts batch, y que se desarrolle y pruebe en aislamiento de sus eventuales dispositivos de tiempo de ejecución y bases de datos.

— Alistair Cockburn

3.2. Regla de dependencias

Las dependencias siempre apuntan hacia el interior del hexágono. Nunca desde el dominio hacia la infraestructura.

  ┌─────────────────────────────────────────────┐
  │             INFRAESTRUCTURA                 │
  │   ┌─────────────────────────────────────┐   │
  │   │           APLICACIÓN                │   │
  │   │   ┌─────────────────────────────┐   │   │
  │   │   │          DOMINIO            │   │   │
  │   │   │   (núcleo, sin framework)   │   │   │
  │   │   └─────────────────────────────┘   │   │
  │   └─────────────────────────────────────┘   │
  └─────────────────────────────────────────────┘

  Las flechas de dependencia apuntan siempre hacia dentro:
  Infraestructura → Aplicación → Dominio

3.3. Puertos y adaptadores

ConceptoDescripciónEjemplo en catalog-service

Puerto de entrada

Interfaz que define lo que la aplicación puede hacer (casos de uso).

GetPaisByIdUseCase, GetPaginatedPaisesUseCase

Puerto de salida

Interfaz que define lo que la aplicación necesita del exterior (repositorios).

PaisRepositoryPort, ComunidadAutonomaRepositoryPort

Adaptador primario

Implementación que conduce a la aplicación desde el exterior (REST, gRPC, CLI).

PaisesApiDelegateImpl, servicio gRPC

Adaptador secundario

Implementación que conecta la aplicación al exterior (BD, caché, mensajería).

PaisRepositoryAdapter, CacheConfiguration

3.4. Estructura de capas

catalog-service/
├── catalog-domain/            ← DOMINIO: entidades, puertos, excepciones
│   └── (sin dependencias de framework)
├── catalog-application/       ← APLICACIÓN: casos de uso, mediator
│   └── (depende sólo de catalog-domain)
├── catalog-infrastructure/    ← INFRAESTRUCTURA: adaptadores técnicos
│   ├── catalog-infrastructure-api-rest/     (adaptador REST entrante)
│   ├── catalog-infrastructure-api-grpc/     (adaptador gRPC entrante)
│   └── catalog-infrastructure-persistence-jpa/ (adaptador BD saliente)
└── catalog-bootstrap/         ← BOOTSTRAP: arranque, configuración Spring Boot
    └── (ensambla todos los módulos)

3.5. Flujo de una petición GET /paises/{id}

Cliente HTTP
    │
    ▼
PaisesApiDelegateImpl (Adaptador REST)
    │  GET /paises/{id}
    ▼
GetPaisByIdUseCase (Puerto de entrada)
    │
    ▼
GetPaisByIdUseCaseImpl (Caso de uso)
    │
    ▼
PaisRepositoryPort (Puerto de salida)
    │
    ▼
PaisRepositoryAdapter (Adaptador de persistencia)
    │
    ▼
JpaPaisRepository (Spring Data JPA)
    │  SELECT * FROM paises WHERE id = ?
    ▼
PostgreSQL 16
    │  País encontrado
    ▼  (respuesta sube por el mismo camino)
HTTP 200 OK + PaisDTO

3.6. Beneficios obtenidos

BeneficioEvidencia en el proyecto

Testeabilidad

La capa de dominio y aplicación se prueban sin Spring ni base de datos (tests en catalog-application con mocks).

Intercambiabilidad

Se puede cambiar PostgreSQL por CockroachDB sin modificar dominio ni aplicación (existe perfil application-cockroach.yaml).

Independencia de protocolo

El mismo caso de uso se sirve tanto por REST (PaisesApiDelegateImpl) como por gRPC, sin duplicar lógica.

Evolución independiente

Cada módulo Maven puede evolucionar, desplegarse y testearse de forma independiente.

Comprensión del código

La estructura de paquetes refleja el dominio de negocio, no la tecnología.

3.7. Verificación arquitectural con ArchUnit

El proyecto incorpora ArchUnit (arch-unit-maven-plugin) para validar automáticamente las reglas de dependencia en cada build:

// Las dependencias entre capas se validan en CI/CD:
// ✅ Dominio no depende de aplicación ni infraestructura
// ✅ Aplicación sólo depende de dominio
// ✅ Infraestructura puede depender de aplicación y dominio
// ✅ Bootstrap puede depender de todos los módulos
El plugin arch-unit-maven-plugin se ejecuta automáticamente en la fase verify de Maven, impidiendo que se mergee cualquier código que rompa las restricciones de arquitectura.

4. Módulos Maven

4.1. Estructura multi-módulo

catalog-service/                          (POM padre)
├── catalog-domain/                       (módulo de dominio)
├── catalog-application/                  (módulo de aplicación)
├── catalog-infrastructure/               (módulo de infraestructura — padre)
│   ├── catalog-infrastructure-api-rest/
│   ├── catalog-infrastructure-api-grpc/
│   └── catalog-infrastructure-persistence-jpa/
├── catalog-bootstrap/                    (módulo de arranque)
└── catalog-doc/                          (módulo de documentación)

4.2. Grafo de dependencias

                ┌────────────────┐
                │ catalog-domain │  (sin dependencias externas)
                └───────┬────────┘
                        │ ←depende
          ┌─────────────┴─────────────┐
          │                           │
 ┌────────┴─────────┐        ┌────────┴──────────────────┐
 │catalog-application│        │  catalog-infrastructure   │
 └────────┬─────────┘        │  (api-rest, api-grpc, jpa)│
          │ ←depende          └────────┬──────────────────┘
          └────────────┬──────────────┘
                       │ ←depende
              ┌────────┴────────┐
              │catalog-bootstrap│
              └─────────────────┘
La dirección de las dependencias garantiza que el dominio nunca conoce los detalles de la infraestructura. Si el dominio necesita algo del exterior (como acceder a la base de datos), lo define como una interfaz (puerto) y la infraestructura la implementa (adaptador), invirtiendo la dependencia.

5. Capa de Dominio

5.1. Propósito

La capa de dominio es el núcleo del hexágono. Contiene exclusivamente lógica de negocio: entidades, reglas de validación, contratos de repositorio (puertos de salida) y contratos de casos de uso (puertos de entrada).

El módulo catalog-domain no tiene ninguna dependencia de framework. No importa Spring, JPA, Jackson ni ninguna otra librería de infraestructura. Esto garantiza que las reglas de negocio son estables, portables y fácilmente testeables en aislamiento.

5.2. Entidades de dominio

Todas las entidades están implementadas como Java records, lo que garantiza:

  • Inmutabilidad — Los registros son inherentemente inmutables, evitando estados inconsistentes.

  • Validación en construcción — El constructor compacto valida invariantes de dominio.

  • Código conciso — Reducción de boilerplate sin perder expresividad.

5.2.1. Jerarquía de entidades geográficas

Pais                         ← Entidad independiente
ComunidadAutonoma            ← Entidad independiente
  └── Provincia              ← Hace referencia a ComunidadAutonoma
        └── Localidad        ← Hace referencia a Provincia
TipoVia                      ← Entidad independiente (catálogo)

5.2.2. Entidad Pais

public record Pais(String id, String denominacion) {
    public Pais {
        Objects.requireNonNull(id, "El id del país no puede ser nulo");
        Objects.requireNonNull(denominacion, "La denominación no puede ser nula");
        if (id.isBlank())
            throw new ValidationException(PaisErrorType.ID_REQUERIDO);
        if (denominacion.isBlank())
            throw new ValidationException(PaisErrorType.DENOMINACION_REQUERIDA);
    }
}

5.2.3. Modelo de paginación

// Parámetros de entrada de paginación
public record Paginacion(int page, int size) { }

// Resultado paginado genérico
public record Pagina<T>(
    List<T> contenido,
    long totalElementos,
    int totalPaginas,
    int numeroPagina,
    int tamanio
) {
    public boolean esUltimaPagina() { return numeroPagina >= totalPaginas - 1; }
    public boolean esPrimeraPagina() { return numeroPagina == 0; }
}

5.3. Puertos del dominio

5.3.1. Puertos de entrada (casos de uso)

// Puerto de entrada — define el contrato de un caso de uso
public interface GetPaisByIdUseCase {
    Pais getPorId(String id);
}

public interface GetPaginatedPaisesUseCase {
    Pagina<Pais> getPaginados(Paginacion paginacion);
}
EntidadPuertos de entrada

Pais

GetPaisByIdUseCase, GetPaginatedPaisesUseCase

ComunidadAutonoma

GetComunidadAutonomaByIdUseCase, GetPaginatedComunidadesAutonomasUseCase

Provincia

GetProvinciaByIdUseCase, GetPaginatedProvinciasUseCase

Localidad

GetLocalidadByIdUseCase, GetPaginatedLocalidadesUseCase

TipoVia

GetTipoViaByIdUseCase, GetPaginatedTiposViaUseCase

5.3.2. Puertos de salida (repositorios)

// Puerto de salida — define qué necesita el dominio para funcionar
public interface PaisRepositoryPort {
    Optional<Pais> getPorId(String id);
    Pagina<Pais> getPaginados(Paginacion paginacion);
}

5.4. Excepciones de dominio

DomainException (base)
├── NotFoundException      ← HTTP 404 en el adaptador REST
└── ValidationException    ← HTTP 422 en el adaptador REST

Cada tipo de error se define como una implementación de la interfaz ErrorType, lo que permite que los adaptadores REST conviertan las excepciones de dominio en respuestas RFC 9457 Problem Detail sin que el dominio sepa nada de HTTP.


6. Capa de Aplicación

6.1. Propósito

La capa de aplicación orquesta los casos de uso: recibe una petición, invoca los puertos de salida necesarios del dominio y devuelve un resultado. No contiene lógica de negocio; sólo coordina.

@Service
@Transactional(readOnly = true)
public class GetPaisByIdUseCaseImpl implements GetPaisByIdUseCase {

    private final PaisRepositoryPort repository;

    @Override
    public Pais getPorId(String id) {
        return repository.getPorId(id)
            .orElseThrow(() -> new NotFoundException(PaisErrorType.NO_ENCONTRADO, id));
    }
}

6.2. Patrón Mediator

Para escenarios más complejos, el proyecto implementa el patrón Mediator (también llamado Dispatcher o Bus), que desacopla quien emite una petición de quien la procesa.

// Marcador para cualquier petición que puede ser enviada por el mediator
public interface Request<R> { }

// Handler que procesa un tipo específico de Request
public interface RequestHandler<Req extends Request<Res>, Res> {
    Res handle(Req request);
}

// Interfaz principal del Mediator
public interface Mediator {
    <R> R send(Request<R> request);
}

SpringMediator usa el ApplicationContext de Spring para resolver dinámicamente el handler correcto a partir del tipo de la petición.

6.3. Pipeline Behaviors

Los Pipeline Behaviors son middlewares que envuelven la ejecución de cualquier handler, aplicando lógica transversal de forma declarativa y reutilizable:

Request
   │
   ▼
ValidationBehavior.handle()     ← valida la petición con Bean Validation
   │
   ▼
LoggingBehavior.handle()        ← registra la petición y el tiempo de respuesta
   │
   ▼
DomainEventsBehavior.handle()   ← recoge y publica eventos de dominio
   │
   ▼
RequestHandler.handle()         ← lógica del caso de uso
   │
   ▼ (resultado)

6.4. Patrón CQRS en Localidades

// Query (objeto de petición)
public record GetLocalidadByIdQuery(String id) implements Request<Localidad> { }

// Handler (procesador de la query)
@Component
public class GetLocalidadByIdHandler
    implements RequestHandler<GetLocalidadByIdQuery, Localidad> {

    @Override
    public Localidad handle(GetLocalidadByIdQuery query) {
        return repository.getPorId(query.id())
            .orElseThrow(() -> new NotFoundException(...));
    }
}

// En el adaptador REST se usa el Mediator (no el UseCase directamente):
mediator.send(new GetLocalidadByIdQuery(id));

7. Optimizaciones de Rendimiento

7.1. Visión general

CapaOptimizaciónImpacto esperado

Serialización

Jackson Blackbird (bytecode)

20-40% menos tiempo de serialización/deserialización

Caché L1

Caffeine (heap local)

Respuesta en microsegundos para datos calientes

Caché L2

Redis (distribuida)

Eliminación de queries a BD en entornos multi-instancia

Conexiones BD

HikariCP con parámetros optimizados

Reutilización de conexiones, mínima latencia de adquisición

Concurrencia

Virtual Threads (Java 25)

Throughput máximo con consumo mínimo de hilos del SO

I/O HTTP

HTTP/2 + compresión Gzip

Reducción de tamaño de respuesta y cabeceras

Logging

Log4j2 + LMAX Disruptor async

El logging no bloquea el hilo de petición

7.2. Caché de dos niveles: Caffeine + Redis

La caché sigue una estrategia read-through en dos niveles:

Petición
   │
   ▼
┌──────────────────────────────────────────┐
│         Caffeine Cache (L1)              │
│   Heap JVM · hasta 10.000 entradas       │
│   TTL: 5 minutos · Eviction: LFU         │
│   Acceso: < 1 microsegundo               │
└──────────────────────────────────────────┘
   │ (miss L1)
   ▼
┌──────────────────────────────────────────┐
│         Redis Cache (L2)                 │
│   Distribuida · compartida entre pods    │
│   TTL: 10 minutos · Serialización: JSON  │
│   Acceso: < 1 milisegundo (red local)    │
└──────────────────────────────────────────┘
   │ (miss L2)
   ▼
┌──────────────────────────────────────────┐
│         PostgreSQL                       │
│   Fuente de verdad · Query con índice    │
└──────────────────────────────────────────┘
@Configuration
@EnableCaching
public class CacheConfiguration {

    @Bean("caffeineCacheManager")
    public CacheManager caffeineCacheManager(CaffeineSpec spec) {
        return new CaffeineCacheManager() {{ setCaffeineSpec(spec); }};
    }

    @Bean("redisCacheManager")
    public RedisCacheManager redisCacheManager(
            RedisConnectionFactory factory, ObjectMapper objectMapper) {
        var config = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(
                    new GenericJackson2JsonRedisSerializer(objectMapper)));
        return RedisCacheManager.builder(factory).cacheDefaults(config).build();
    }

    @Primary
    @Bean
    public CacheManager compositeCacheManager(
            @Qualifier("caffeineCacheManager") CacheManager l1,
            @Qualifier("redisCacheManager")   CacheManager l2) {
        var composite = new CompositeCacheManager(l1, l2);
        composite.setFallbackToNoOpCache(true); // No falla si Redis no está
        return composite;
    }
}

7.3. Virtual Threads (Project Loom)

Los Virtual Threads (Java 21+ GA, mejorados en Java 25) son hilos ligeros gestionados por la JVM, no por el sistema operativo.

CaracterísticaHilos tradicionales (OS)Virtual Threads (JVM)

Memoria por hilo

~1 MB (stack fijo)

~1 KB (stack dinámico)

Creación

Costosa (syscall)

Muy barata (nanosegundos)

Bloqueo I/O

Bloquea el hilo del SO

Desmonta el virtual thread; el carrier thread sigue libre

Throughput

Limitado por número de cores × 2

Miles de peticiones concurrentes con pocos cores

spring:
  threads:
    virtual:
      enabled: true    # Habilita virtual threads para Tomcat y tareas async

7.4. Parámetros JVM para contenedor

ENV JAVA_OPTS="\
  -XX:+UseContainerSupport \
  -XX:InitialRAMPercentage=50.0 \
  -XX:MaxRAMPercentage=75.0 \
  -XX:+UseZGC \
  -XX:+ZGenerational \
  -XX:+UseStringDeduplication \
  -XX:MaxMetaspaceSize=256m \
  -XX:+ExitOnOutOfMemoryError"
ParámetroPropósito

-XX:+UseContainerSupport

La JVM respeta los límites de cgroup del contenedor en lugar de detectar la RAM del host.

-XX:MaxRAMPercentage=75.0

El heap máximo es el 75% de la RAM disponible. El 25% restante es para metaspace, stack de hilos y buffers nativos.

-XX:+UseZGC -XX:+ZGenerational

ZGC generacional: GC de ultra-baja latencia con pausas < 1 ms, mejora el throughput hasta un 30% frente a ZGC clásico.

-XX:+ExitOnOutOfMemoryError

Ante un OOM, la JVM termina inmediatamente. Kubernetes reiniciará el pod en lugar de dejarlo en estado corrupto.


8. Contenedores y Despliegue

8.1. Dockerfile Multi-Stage

# =============================================
# ETAPA 1: BUILDER
# =============================================
FROM eclipse-temurin:25-jdk-alpine AS builder

WORKDIR /app
COPY target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract

# =============================================
# ETAPA 2: RUNTIME
# =============================================
FROM eclipse-temurin:25-jre-alpine

# Usuario no root — seguridad en contenedor
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

WORKDIR /app

# Capas ordenadas de menor a mayor frecuencia de cambio
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./

EXPOSE 8080

ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"]
En un ciclo de desarrollo típico sólo cambia la capa application/. Docker reutiliza las tres capas inferiores del caché, reduciendo el tiempo de build y el ancho de banda de push al registry de ~200 MB a ~5-10 MB.

8.2. Docker Compose

El fichero docker-compose.yml levanta el stack completo para desarrollo local con health checks en todos los servicios y depends_on condicional para garantizar el orden de arranque.

services:
  backend:
    image: catalog-service:latest
    ports:
      - "8080:8080"
    depends_on:
      database:
        condition: service_healthy
      cache:
        condition: service_healthy
    deploy:
      resources:
        limits:
          memory: 512m
          cpus: "1.0"

  database:
    image: postgres:16-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U catalog -d catalog"]

  cache:
    image: redis:8.0-alpine
    command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]

9. Estrategia de Testing

9.1. Pirámide de tests

          ┌─────────────────────┐
          │   E2E / Contract    │  ← Pocos, lentos, frágiles
          │  (gRPC / OpenAPI)   │
          ├─────────────────────┤
          │  Integración / BDD  │  ← Cucumber con Spring context
          │  (Persistence + UC) │
          ├─────────────────────┤
          │    Unitarios        │  ← Muchos, rápidos, sin Spring
          │  (Domain + App)     │
          └─────────────────────┘

9.2. Tests de dominio (JUnit 5 puro)

class PaisTest {

    @Test
    void deberiaCrearPaisValido() {
        var pais = new Pais("ESP", "España");
        assertThat(pais.id()).isEqualTo("ESP");
        assertThat(pais.denominacion()).isEqualTo("España");
    }

    @Test
    void deberiaFallarSiIdEsNulo() {
        assertThatThrownBy(() -> new Pais(null, "España"))
            .isInstanceOf(ValidationException.class);
    }
}

9.3. Tests BDD con Cucumber

Feature: Obtener país por identificador

  Background:
    Given un repositorio de países con los siguientes datos:
      | id  | denominacion |
      | ESP | España       |
      | FRA | Francia      |

  Scenario: Obtener un país existente
    When se solicita el país con id "ESP"
    Then se obtiene el país con denominación "España"

  Scenario: País no encontrado lanza excepción
    When se solicita el país con id "XXX"
    Then se lanza una NotFoundException con el id "XXX"

9.4. Tests de persistencia con Testcontainers

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class PaisRepositoryAdapterTest {

    @Container
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine")
            .withDatabaseName("catalog_test");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
    }

    @Test
    void deberiaObtenerPaisPorId() {
        var resultado = adapter.getPorId("ESP");
        assertThat(resultado).isPresent();
        assertThat(resultado.get().denominacion()).isEqualTo("España");
    }
}

9.5. Cobertura objetivo

MóduloTipo de testsCobertura objetivo

catalog-domain

JUnit 5 unitarios

≥ 90% — todas las validaciones de entidades

catalog-application

JUnit 5 + Cucumber BDD

≥ 85% — todos los casos de uso con escenarios happy/error

catalog-infrastructure-persistence-jpa

Spock + jqwik + Testcontainers

≥ 80% — adaptadores con BD real

catalog-infrastructure-api-rest

@WebMvcTest con MockMvc

≥ 75% — todos los endpoints y manejo de errores


10. Buenas Prácticas

10.1. Principios SOLID

10.1.1. S — Single Responsibility Principle

ClaseResponsabilidad única

PaisesApiDelegateImpl

Mapear HTTP request/response a/desde el dominio

GetPaisByIdUseCaseImpl

Orquestar la lógica de obtención de un país

PaisRepositoryAdapter

Traducir entre el modelo JPA y el modelo de dominio

GlobalExceptionHandler

Traducir excepciones de dominio a respuestas HTTP

10.1.2. O — Open/Closed Principle

// Para añadir tracing a TODOS los handlers:
// → Crear un nuevo PipelineBehavior (extensión)
// → No modificar ningún handler existente (cerrado)

@Component
@Order(1)
public class TracingBehavior<Req, Res> implements PipelineBehavior<Req, Res> {
    @Override
    public Res handle(Req request, Supplier<Res> next) {
        return Tracer.trace(request.getClass().getSimpleName(), next);
    }
}

10.1.3. I — Interface Segregation Principle

// ✅ Correcto — Interfaces pequeñas y específicas
interface GetPaisByIdUseCase      { Pais getPorId(String id); }
interface GetPaginatedPaisesUseCase { Pagina<Pais> getPaginados(Paginacion p); }

// ❌ Incorrecto — Una interfaz "dios" que obliga a implementar todo
interface PaisUseCase {
    Pais getPorId(String id);
    Pagina<Pais> getPaginados(Paginacion p);
    void eliminar(String id);   // ← fuerza dependencia innecesaria
}

10.1.4. D — Dependency Inversion Principle

// catalog-application depende de la INTERFAZ (dominio)
private final PaisRepositoryPort repository;  // interfaz en catalog-domain

// catalog-infrastructure-persistence-jpa IMPLEMENTA la interfaz
@Repository
public class PaisRepositoryAdapter implements PaisRepositoryPort { ... }

// Spring Boot inyecta la implementación concreta en tiempo de ejecución
// → El código de aplicación nunca referencia JPA ni Hibernate directamente

10.2. Observabilidad con Actuator + Micrometer

EndpointPropósito

/actuator/health

Estado global: BD, Redis, disco. Usado por load balancer.

/actuator/health/liveness

Liveness probe de Kubernetes (¿está vivo el proceso?).

/actuator/health/readiness

Readiness probe de Kubernetes (¿está listo para recibir tráfico?).

/actuator/prometheus

Métricas en formato Prometheus para scraping.

/actuator/caches

Estado de los cachés Caffeine y Redis.

/actuator/loggers

Cambio dinámico de nivel de logging sin reiniciar.

10.3. Resumen de buenas prácticas

CategoríaPrácticaAplicación

Arquitectura

Hexagonal (Ports & Adapters)

Separación estricta en capas, dependencias hacia el interior

Arquitectura

Design-First API

OpenAPI y Protobuf como contrato canónico

Código

SOLID

Interfaces mínimas, inversión de dependencias, clases con una responsabilidad

Código

Inmutabilidad

Java records para entidades de dominio y DTOs internos

Código

Fail Fast

Validación en constructores de entidades, no en servicios

DDD

Lenguaje Ubicuo

Nombres en español que reflejan el negocio

Rendimiento

Caché multinivel

Caffeine (L1) + Redis (L2) con fallback graceful

Rendimiento

Virtual Threads

Máximo throughput en operaciones I/O bound

Seguridad

Usuario no root en contenedor

Limitación de impacto ante vulnerabilidades RCE

Seguridad

OWASP Dependency-Check

Detección automática de CVEs en dependencias

Calidad

ArchUnit

Validación automática de reglas de arquitectura en CI

Calidad

Spotless + SpotBugs

Formateo automático y análisis estático en cada build

Observabilidad

Actuator + Micrometer + Prometheus

Visibilidad completa del estado del servicio en producción


11. Inicio Rápido

11.1. Requisitos

  • Java 25+ (recomendado: Eclipse Temurin)

  • Maven 3.8.0+

  • Docker y Docker Compose

11.2. Clonar y compilar

git clone https://github.com/iCesofT/arquitectura-hexagonal.git
cd arquitectura-hexagonal/catalog-service
./mvnw clean compile

11.3. Ejecutar con Docker Compose

docker-compose up -d

11.4. Verificar funcionamiento

# Health Check
curl http://localhost:8080/actuator/health

# API REST — Países
curl http://localhost:8080/api/v1/paises

# Swagger UI
open http://localhost:8080/openapi/ui.html

11.5. API Endpoints

11.5.1. REST (Puerto 8080)

  • GET /api/v1/paises — Lista paginada de países

  • GET /api/v1/paises/{id} — Obtener país por ID

  • GET /api/v1/comunidades-autonomas — Lista de comunidades autónomas

  • GET /api/v1/provincias — Lista de provincias

  • GET /api/v1/localidades — Lista de localidades

  • GET /actuator/health — Health check

  • GET /openapi/ui.html — Documentación interactiva

11.5.2. gRPC (Puerto 9090)

  • CatalogService.GetPaises() — Lista países

  • CatalogService.GetPaisById() — País por ID

  • CatalogService.GetComunidadesAutonomas() — Comunidades autónomas

11.6. Ejecución de tests

# Tests unitarios
./mvnw test

# Tests de integración (requiere Docker)
./mvnw verify

# Tests Cucumber (BDD)
./mvnw test -Dtest="*CucumberTest"