El núcleo de la arquitectura hexagonal cobra sentido cuando entendemos cómo se comunican la aplicación y su entorno. Los puertos definen contratos que protegen al dominio y los adaptadores implementan esos contratos para conectarse con canales reales. En esta sección desglosamos los tipos de puertos, los estilos comunes de adaptadores y presentamos un ejemplo paso a paso para ilustrar la colaboración entre todos los componentes.
Al dominar estos conceptos podés extender tu aplicación a nuevos canales de entrada o reemplazar proveedores externos sin modificar la lógica central. La clave está en escribir puertos significativos, nombrar adaptadores de forma explícita y respetar la dirección de dependencias hacia el dominio.
Los puertos actúan como interfaces que describen qué necesita el dominio para responder a su entorno. Se clasifican según la dirección del flujo de datos o eventos:
Esta distinción asegura que la aplicación reciba pedidos a través de un contrato claro y que, al mismo tiempo, pueda delegar responsabilidades externas sin conocer detalles técnicos.
Los adaptadores de entrada transforman una solicitud proveniente del exterior en una invocación a un puerto de entrada. El formato puede variar según el canal, pero el objetivo siempre es el mismo: traducir la petición al lenguaje del dominio y disparar el caso de uso adecuado.
Si un proyecto necesita sumar un nuevo canal (por ejemplo, pasar de REST a eventos), solo se agrega un adaptador de entrada que cumpla el contrato existente. El dominio permanece inalterado.
Los adaptadores de salida atienden los requerimientos definidos por los puertos de salida del dominio. Su función es delegar en tecnologías concretas sin que el caso de uso las conozca. Entre los más habituales se encuentran:
Cada adaptador de salida se coordina con el dominio a través de una interfaz. Si hay que cambiar la base de datos o el proveedor, basta con crear otra implementación del mismo puerto y sustituir la configuración.
Veamos un flujo completo para gestionar solicitudes de afiliación a un programa de beneficios. El dominio necesita aprobar la solicitud y notificarse cuando la persistencia falla. Diseñaremos puertos de entrada y salida, y luego construiremos adaptadores REST y JPA que los implementen.
package com.example.afiliaciones.dominio;
import java.time.LocalDateTime;
public class SolicitudAfiliacion {
private final String documento;
private final LocalDateTime instante;
public SolicitudAfiliacion(String documento, LocalDateTime instante) {
if (documento == null || documento.isBlank()) {
throw new IllegalArgumentException("El documento es obligatorio");
}
this.documento = documento;
this.instante = instante != null ? instante : LocalDateTime.now();
}
public String documento() {
return documento;
}
public LocalDateTime instante() {
return instante;
}
}
package com.example.afiliaciones.dominio;
public interface PuertoAfiliaciones {
void guardar(SolicitudAfiliacion solicitud);
}
package com.example.afiliaciones.dominio;
public interface PuertoNotificaciones {
void reportarError(String mensaje);
}
package com.example.afiliaciones.aplicacion;
import java.time.LocalDateTime;
import com.example.afiliaciones.dominio.PuertoAfiliaciones;
import com.example.afiliaciones.dominio.PuertoNotificaciones;
import com.example.afiliaciones.dominio.SolicitudAfiliacion;
public class RegistrarAfiliacion {
private final PuertoAfiliaciones puertoAfiliaciones;
private final PuertoNotificaciones puertoNotificaciones;
public RegistrarAfiliacion(PuertoAfiliaciones puertoAfiliaciones,
PuertoNotificaciones puertoNotificaciones) {
this.puertoAfiliaciones = puertoAfiliaciones;
this.puertoNotificaciones = puertoNotificaciones;
}
public void ejecutar(String documento) {
SolicitudAfiliacion solicitud = new SolicitudAfiliacion(documento, LocalDateTime.now());
try {
puertoAfiliaciones.guardar(solicitud);
} catch (RuntimeException ex) {
puertoNotificaciones.reportarError("No fue posible registrar la solicitud: " + ex.getMessage());
throw ex;
}
}
}
El puerto de entrada es la clase RegistrarAfiliacion, que recibe los puertos de salida PuertoAfiliaciones y PuertoNotificaciones. El dominio y la aplicación permanecen limpios, listos para aceptar adaptadores variados.
Implementemos un controlador REST que reciba la solicitud. El adaptador usa el caso de uso y se ubica en la infraestructura. Su tarea es traducir el JSON del cliente a un comando interno.
package com.example.afiliaciones.infraestructura.entrada;
import com.example.afiliaciones.aplicacion.RegistrarAfiliacion;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/afiliaciones")
class AfiliacionRestController {
private final RegistrarAfiliacion registrarAfiliacion;
AfiliacionRestController(RegistrarAfiliacion registrarAfiliacion) {
this.registrarAfiliacion = registrarAfiliacion;
}
@PostMapping
ResponseEntity<Void> registrar(@RequestBody SolicitudRequest request) {
registrarAfiliacion.ejecutar(request.documento());
return ResponseEntity.accepted().build();
}
record SolicitudRequest(String documento) {}
}
Si necesitáramos exponer la misma funcionalidad por línea de comandos, crearíamos un adaptador alternativo que lea el documento desde argumentos e invoque al mismo caso de uso. El puerto de entrada no se modifica.
Para la persistencia desarrollamos un repositorio basado en JPA y, para los errores, un publicador que envía eventos a Kafka. Ambos implementan los puertos declarados en el dominio.
package com.example.afiliaciones.infraestructura.salida.jpa;
import javax.persistence.Entity;
import javax.persistence.Id;
import com.example.afiliaciones.dominio.SolicitudAfiliacion;
@Entity
class SolicitudEntity {
@Id
private String documento;
private java.time.LocalDateTime instante;
static SolicitudEntity desdeDominio(SolicitudAfiliacion solicitud) {
SolicitudEntity entidad = new SolicitudEntity();
entidad.documento = solicitud.documento();
entidad.instante = solicitud.instante();
return entidad;
}
}
package com.example.afiliaciones.infraestructura.salida.jpa;
import com.example.afiliaciones.dominio.PuertoAfiliaciones;
import com.example.afiliaciones.dominio.SolicitudAfiliacion;
import org.springframework.stereotype.Repository;
@Repository
class AfiliacionesJpaAdapter implements PuertoAfiliaciones {
private final SolicitudJpaRepository repository;
AfiliacionesJpaAdapter(SolicitudJpaRepository repository) {
this.repository = repository;
}
@Override
public void guardar(SolicitudAfiliacion solicitud) {
repository.save(SolicitudEntity.desdeDominio(solicitud));
}
}
package com.example.afiliaciones.infraestructura.salida.kafka;
import com.example.afiliaciones.dominio.PuertoNotificaciones;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Component;
@Component
class NotificacionKafkaAdapter implements PuertoNotificaciones {
private final KafkaTemplate<String, String> kafkaTemplate;
NotificacionKafkaAdapter(KafkaTemplate<String, String> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
@Override
public void reportarError(String mensaje) {
kafkaTemplate.send("errores-afiliaciones", mensaje);
}
}
El dominio desconoce que la persistencia usa JPA o que la notificación viaja por Kafka. También podríamos crear un adaptador alternativo que envíe correos electrónicos o escriba en un archivo log, sin alterar los puertos.
La configuración final consiste en vincular adaptadores con puertos. En proyectos con Spring Boot, esto se resuelve mediante la detección de componentes y la inyección automática. En otros entornos, un contenedor ligero o una clase factory se encarga de construir los objetos.
package com.example.afiliaciones.infraestructura.config;
import com.example.afiliaciones.aplicacion.RegistrarAfiliacion;
import com.example.afiliaciones.dominio.PuertoAfiliaciones;
import com.example.afiliaciones.dominio.PuertoNotificaciones;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
class AfiliacionesConfig {
@Bean
RegistrarAfiliacion registrarAfiliacion(PuertoAfiliaciones puertoAfiliaciones,
PuertoNotificaciones puertoNotificaciones) {
return new RegistrarAfiliacion(puertoAfiliaciones, puertoNotificaciones);
}
}
Gracias a esta composición, la aplicación queda preparada para intercambiar adaptadores. Si se reemplaza JPA por un servicio REST externo para guardar solicitudes, basta con proveer otra implementación del puerto y ajustar la configuración.
Para aprovechar al máximo el patrón puertos y adaptadores es conveniente mantener las interfaces pequeñas, con métodos que expresen intenciones de negocio. Asimismo, conviene publicar contratos explícitos para eventos y respuestas, de manera que los consumidores externos comprendan qué esperan los puertos de entrada y salida.
Incluí pruebas unitarias que validen los casos de uso con dobles de prueba para los puertos de salida y pruebas de integración que verifiquen que cada adaptador cumple el contrato. Estas verificaciones refuerzan la confianza en la arquitectura y permiten evolucionar la infraestructura de forma segura.