Como definir un puerto de salida
Un puerto de salida es la interfaz que el servicio usa para hablar con el mundo afuera del dominio (base de datos, otros servicios). La interfaz vive en port/out/; el adapter que la implementa, en adapter/out/. El servicio depende solo de la interfaz.
Patrón en dos archivos
Para cada lado outbound del aggregate hay dos archivos:
| Archivo | Rol |
|---|---|
|
Interfaz pública. Define la operación en términos del dominio. |
|
Implementación package-private. Traduce la operación a tecnología (JPA, HTTP, etc.). |
Separación lectura / escritura
Los ports outbound se dividen por tipo de operación:
-
PersistencePortpara escrituras (save,delete). -
RetrievalPortpara lecturas (findById, queries paginadas).
El mismo adapter puede implementar ambos — declara una sola dependencia al Repository. La separación de interfaces evita que un caso de uso de lectura termine con acceso transitivo a save(…).
Ejemplo: port JPA para escritura
Interfaz del port:
public interface TaskPersistencePort {
void save(Task task);
}
Repository (Spring Data, package-private):
interface TaskRepository extends JpaRepository<Task, String> {
}
Adapter (implementación, package-private):
@Component
@RequiredArgsConstructor
class TaskPersistenceAdapter implements TaskPersistencePort {
private final TaskRepository repository;
@Override
public void save(Task task) {
repository.save(task);
}
}
Task es directamente una entidad JPA, heredada de AlphaIdEntity u otra clase base. No hay capa de mapeo entre dominio y modelo de persistencia. Para el detalle de las clases base ver Clases base JPA.
Ejemplo: port de lectura con paginación
Interfaz:
public interface TaskRetrievalPort {
Optional<Task> findById(String id);
Page<Task> findAll(Pageable pageable);
}
El mismo adapter, sumando la implementación:
@Component
@RequiredArgsConstructor
class TaskPersistenceAdapter implements TaskPersistencePort, TaskRetrievalPort {
private final TaskRepository repository;
@Override
public void save(Task task) {
repository.save(task);
}
@Override
public Optional<Task> findById(String id) {
return repository.findById(id);
}
@Override
public Page<Task> findAll(Pageable pageable) {
return repository.findAll(pageable);
}
}
Pageable y Page de Spring Data se pasan tal cual al Repository. El controller envuelve Page<Task> en PageResponse — ver GET paginado en el how-to del controller.
El servicio depende del port, nunca del adapter
@Service
@RequiredArgsConstructor
class TaskService implements CreateTaskUseCase {
private final TaskPersistencePort persistencePort; (1)
// private final TaskPersistenceAdapter adapter; (2)
// ...
}
| 1 | Interfaz: el servicio depende del contrato. |
| 2 | Implementación concreta: rompe la inversión de dependencias y acopla el servicio a JPA. |
Consecuencias prácticas:
-
El test del servicio reemplaza el port con
@MockitoBeansin tocar JPA. Ver Como escribir tests de integración. -
Cambiar el storage backend (JPA → in-memory, HTTP externo) requiere un adapter nuevo y cero cambios en el servicio.
Adaptadores HTTP a otros servicios
Cuando el port representa una llamada a otro microservicio, el patrón se mantiene: interfaz en port/out/, implementación en adapter/out/<algo> (por ejemplo adapter/out/http/). El adapter usa el cliente HTTP elegido (RestClient, WebClient) y traduce la respuesta al modelo de dominio. El arquetipo no incluye un cliente HTTP de referencia — la forma del contrato es la misma que el caso JPA.
Resumen
| Pieza | Visibilidad | Ubicación |
|---|---|---|
Interfaz del port |
|
|
Adapter |
package-private |
|
Repository (si JPA) |
package-private |
Junto al adapter |
Ver también
-
Como definir el modelo de dominio — las escrituras pasan por el aggregate root.
-
Capas y reglas — regla de dependencia inversa.