11. Ejemplo práctico de aplicación hexagonal

Describir la arquitectura hexagonal conceptualmente es apenas el comienzo. Para aprovecharla en proyectos reales conviene repasar un ejemplo completo que abarque el dominio, los puertos, los adaptadores y la estrategia de pruebas. En esta sección construiremos paso a paso una aplicación de reservas de salas que ilustra cómo mantener el dominio aislado mientras colaboramos con una base de datos y una API REST.

El recorrido contempla cuatro momentos: el diseño del modelo de negocio y sus puertos, la creación de un puerto de salida con su adaptador de persistencia, la implementación de un adaptador HTTP de entrada y una estrategia de pruebas unitarias y de integración para validar cada parte.

11.1 Diseño de la aplicación con dominio, puertos y adaptadores

La aplicación gestionará reservas de salas de reuniones. Desde el dominio necesitamos representar la entidad Reserva, encapsular sus invariantes y expresar los puertos que la aplicación requiere para persistir y consultar datos. Este primer paso se realiza sin frameworks ni detalles de infraestructura.

package com.example.reservas.dominio;

import java.time.LocalDateTime;

public class Reserva {

    private final String identificador;
    private final String sala;
    private final LocalDateTime inicio;
    private final LocalDateTime fin;

    public Reserva(String identificador, String sala, LocalDateTime inicio, LocalDateTime fin) {
        if (identificador == null || identificador.isBlank()) {
            throw new IllegalArgumentException("El identificador es obligatorio");
        }
        if (sala == null || sala.isBlank()) {
            throw new IllegalArgumentException("La sala es obligatoria");
        }
        if (inicio == null || fin == null || !fin.isAfter(inicio)) {
            throw new IllegalArgumentException("El rango horario es inválido");
        }
        this.identificador = identificador;
        this.sala = sala;
        this.inicio = inicio;
        this.fin = fin;
    }

    public String identificador() { return identificador; }
    public String sala() { return sala; }
    public LocalDateTime inicio() { return inicio; }
    public LocalDateTime fin() { return fin; }
}

package com.example.reservas.dominio;

import java.util.Optional;

public interface PuertoReservas {
    void guardar(Reserva reserva);
    Optional<Reserva> buscarPorId(String identificador);
    boolean existeConflicto(String sala, java.time.LocalDateTime inicio, java.time.LocalDateTime fin);
}

package com.example.reservas.aplicacion;

import com.example.reservas.dominio.PuertoReservas;
import com.example.reservas.dominio.Reserva;

public class RegistrarReserva {

    private final PuertoReservas puertoReservas;

    public RegistrarReserva(PuertoReservas puertoReservas) {
        this.puertoReservas = puertoReservas;
    }

    public void ejecutar(Reserva reserva) {
        if (puertoReservas.existeConflicto(reserva.sala(), reserva.inicio(), reserva.fin())) {
            throw new IllegalStateException("La sala ya está reservada en ese horario");
        }
        puertoReservas.guardar(reserva);
    }
}

El caso de uso RegistrarReserva depende de la abstracción PuertoReservas. Aún no existe ningún detalle sobre cómo persiste la información; esa responsabilidad recaerá en un adaptador de salida.

11.2 Puerto de salida para persistencia y adaptador de base de datos

En la capa de infraestructura implementamos el puerto de salida con un adaptador que utiliza Spring Data JPA. El adaptador traduce la entidad de dominio a una representación compatible con la base y delega las operaciones en un repositorio.

package com.example.reservas.infraestructura.salida.jpa;

import javax.persistence.Entity;
import javax.persistence.Id;
import java.time.LocalDateTime;
import com.example.reservas.dominio.Reserva;

@Entity(name = "reserva")
class ReservaEntity {

    @Id
    private String identificador;
    private String sala;
    private LocalDateTime inicio;
    private LocalDateTime fin;

    static ReservaEntity desdeDominio(Reserva reserva) {
        ReservaEntity entity = new ReservaEntity();
        entity.identificador = reserva.identificador();
        entity.sala = reserva.sala();
        entity.inicio = reserva.inicio();
        entity.fin = reserva.fin();
        return entity;
    }

    Reserva aDominio() {
        return new Reserva(identificador, sala, inicio, fin);
    }
}

package com.example.reservas.infraestructura.salida.jpa;

import java.time.LocalDateTime;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

interface ReservaJpaRepository extends JpaRepository<ReservaEntity, String> {

    @Query("SELECT CASE WHEN COUNT(r) > 0 THEN true ELSE false END " +
           "FROM reserva r WHERE r.sala = :sala AND " +
           "(r.inicio < :fin AND r.fin > :inicio)")
    boolean existeTraslape(String sala, LocalDateTime inicio, LocalDateTime fin);

    Optional<ReservaEntity> findByIdentificador(String identificador);
}

package com.example.reservas.infraestructura.salida.jpa;

import java.time.LocalDateTime;
import java.util.Optional;
import org.springframework.stereotype.Repository;
import com.example.reservas.dominio.PuertoReservas;
import com.example.reservas.dominio.Reserva;

@Repository
class ReservaJpaAdapter implements PuertoReservas {

    private final ReservaJpaRepository repository;

    ReservaJpaAdapter(ReservaJpaRepository repository) {
        this.repository = repository;
    }

    @Override
    public void guardar(Reserva reserva) {
        repository.save(ReservaEntity.desdeDominio(reserva));
    }

    @Override
    public Optional<Reserva> buscarPorId(String identificador) {
        return repository.findByIdentificador(identificador).map(ReservaEntity::aDominio);
    }

    @Override
    public boolean existeConflicto(String sala, LocalDateTime inicio, LocalDateTime fin) {
        return repository.existeTraslape(sala, inicio, fin);
    }
}

Obsérvese que el adaptador depende del dominio para conocer el puerto y la entidad de negocio. El dominio, en cambio, no depende de anotaciones JPA ni de la API de persistencia.

11.3 Implementación de un puerto de entrada REST

Para exponer el caso de uso como servicio web creamos un controlador REST que recibe solicitudes JSON, las traduce a objetos del dominio y delega en el caso de uso. Utilizamos Spring Boot para simplificar la configuración.

package com.example.reservas.infraestructura.entrada.rest;

import java.time.LocalDateTime;
import com.example.reservas.aplicacion.RegistrarReserva;
import com.example.reservas.dominio.Reserva;
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("/reservas")
class ReservaRestController {

    private final RegistrarReserva registrarReserva;

    ReservaRestController(RegistrarReserva registrarReserva) {
        this.registrarReserva = registrarReserva;
    }

    @PostMapping
    ResponseEntity<Void> registrar(@RequestBody ReservaRequest request) {
        Reserva reserva = new Reserva(
                request.identificador(),
                request.sala(),
                request.inicio(),
                request.fin()
        );
        registrarReserva.ejecutar(reserva);
        return ResponseEntity.accepted().build();
    }

    record ReservaRequest(String identificador, String sala,
                          LocalDateTime inicio, LocalDateTime fin) {}
}

El adaptador de entrada se limita a traducir la petición y delegar al caso de uso. Esto permite ofrecer otros canales (CLI, eventos) sin modificar el dominio: solo se agregan adaptadores alternativos.

11.4 Pruebas unitarias del dominio y pruebas de integración sobre adaptadores

Gracias al diseño por puertos es sencillo escribir pruebas unitarias que validen la lógica y pruebas de integración que ejerciten los adaptadores. A nivel dominio utilizaremos JUnit 5 con un doble de prueba para el puerto de salida.

package com.example.reservas.aplicacion;

import java.time.LocalDateTime;
import java.util.Optional;
import com.example.reservas.dominio.PuertoReservas;
import com.example.reservas.dominio.Reserva;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class RegistrarReservaTest {

    @Test
    void cuandoNoExisteConflicto_guardaLaReserva() {
        PuertoReservasFake puerto = new PuertoReservasFake();
        RegistrarReserva casoDeUso = new RegistrarReserva(puerto);

        Reserva reserva = new Reserva("R-001", "SALA-1",
                LocalDateTime.now(), LocalDateTime.now().plusHours(1));
        casoDeUso.ejecutar(reserva);

        assertEquals(1, puerto.guardadas);
    }

    private static class PuertoReservasFake implements PuertoReservas {
        int guardadas = 0;

        @Override
        public void guardar(Reserva reserva) { guardadas++; }
        @Override
        public Optional<Reserva> buscarPorId(String id) { return Optional.empty(); }
        @Override
        public boolean existeConflicto(String sala, LocalDateTime inicio, LocalDateTime fin) { return false; }
    }
}

Para los adaptadores de salida conviene agregar pruebas de integración que verifiquen el contrato del puerto con una base en memoria o con herramientas como Testcontainers. El siguiente ejemplo ilustra un escenario sencillo con una base H2 embebida.

@SpringBootTest
@AutoConfigureTestDatabase
class ReservaJpaAdapterIT {

    @Autowired
    private ReservaJpaAdapter adapter;

    @Test
    void guardaYRecuperaReserva() {
        Reserva reserva = new Reserva("R-002", "SALA-2",
                LocalDateTime.now(), LocalDateTime.now().plusHours(2));

        adapter.guardar(reserva);

        Optional<Reserva> recuperada = adapter.buscarPorId("R-002");
        assertTrue(recuperada.isPresent());
        assertEquals("SALA-2", recuperada.get().sala());
    }
}

Separar pruebas unitarias e integración permite ejecutar rápidamente las suites del dominio y reservar los tests con infraestructura para pipelines de CI/CD o ejecuciones periódicas.

11.5 Conclusiones del ejemplo

El ejemplo completo demuestra cómo la arquitectura hexagonal ofrece un camino claro para mantener la independencia del dominio. Los puertos actúan como frontera entre la lógica de negocio y la infraestructura, los adaptadores se intercambian con mínimo impacto y las pruebas se diseƱan por niveles. Este enfoque reduce la complejidad a largo plazo y prepara el sistema para evolucionar hacia nuevos canales o proveedores tecnológicos sin reescribir la esencia del negocio.