«Has aprendido a usar las herramientas. Ahora vas a construir algo con ellas.»

Con esa frase terminó el módulo 11, y esta lección es la primera que la toma en serio. Porque hay una diferencia enorme entre saber usar Spring, JPA, JUnit, Maven, Jackson y Logback y tener un proyecto. BiblioTech, ahora mismo, es lo primero: un conjunto de piezas excelentes que viven todas juntas en un único módulo Maven, en paquetes que se han ido creando por acumulación, donde una entidad JPA puede importar HttpClient, un servicio de dominio puede importar org.springframework, y nada —absolutamente nada— impide que mañana alguien meta una consulta SQL dentro de CalculadoraMultas.

Eso funciona. Con once módulos de curso encima, funciona. El problema no es que no funcione hoy: es que no resiste el crecimiento. Un proyecto sin fronteras se degrada de forma predecible, y el mecanismo es siempre el mismo: alguien tiene prisa, la clase que necesita está a un import de distancia, y no hay nada que se lo impida. Repite eso doscientas veces y tienes lo que en la industria se llama, sin cariño, «la bola de barro».

Esta lección convierte BiblioTech en un proyecto profesional. No añade ni una funcionalidad. Añade estructura: una arquitectura explícita, unas fronteras que el compilador verifica, una configuración por entorno, un control de versiones ordenado, un estilo automático y una documentación que sirve de verdad.

Al terminar sabrás diseñar la estructura de un proyecto Java real, entenderás la arquitectura por capas y la hexagonal y sabrás cuándo usar cada una, organizarás paquetes con criterio, convertirás un proyecto en multimódulo Maven de forma que el propio grafo de dependencias impida físicamente los errores arquitectónicos, separarás DTOs de entidades, configurarás la aplicación por entorno sin filtrar secretos, y dejarás el repositorio en un estado en el que otra persona pueda clonarlo y arrancarlo en cinco minutos.

Contenido

  1. El problema: qué le pasa a un proyecto sin estructura
  2. Qué es la arquitectura de una aplicación
  3. Arquitectura por capas
  4. La regla de dependencia
  5. Arquitectura hexagonal: puertos y adaptadores
  6. Comparativa: capas frente a hexagonal
  7. Organización de paquetes: por capa frente a por funcionalidad
  8. El árbol de paquetes de BiblioTech, en las dos opciones
  9. Recomendación justificada
  10. Proyecto multimódulo Maven aplicado a BiblioTech
  11. El POM padre y dependencyManagement
  12. Cómo el grafo de módulos impide que el dominio importe Spring
  13. DTOs frente a entidades
  14. Configuración por entorno: application.yml y perfiles
  15. La jerarquía de fuentes de configuración de Spring Boot
  16. Variables de entorno y secretos fuera del repositorio
  17. Control de versiones: .gitignore, ramas y commits convencionales
  18. Formato y estilo: .editorconfig y Spotless
  19. Un README.md que sirva de verdad
  20. Registro de decisiones de arquitectura (ADR)
  21. Scripts de arranque y dependencias locales
  22. El árbol completo de BiblioTech reestructurado
  23. Errores Comunes y Consejos
  24. Ejercicios
  25. Conclusión

  1. El problema: qué le pasa a un proyecto sin estructura

Antes de proponer soluciones, conviene ver el problema con precisión. Este es un fragmento real del BiblioTech actual:

package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.modelo.Prestamo;
import com.nexussoftware.bibliotech.repositorio.PrestamoRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.net.http.HttpClient;   // ¿qué hace esto aquí?

@Service
public class GestorPrestamos {

    private final PrestamoRepository repositorio;
    private final HttpClient http;   // un servicio de negocio con un cliente HTTP dentro
    // ...
}

Cada línea de esta clase es defendible por separado. El conjunto no lo es:

Síntoma Consecuencia a medio plazo
La lógica de negocio importa org.springframework No puedes probarla sin levantar un contexto; migrar de framework es reescribir
La lógica de negocio importa java.net.http Para probar el cálculo de multas necesitas red o un mock de HTTP
Todo está en un módulo Maven Nada impide que la entidad Prestamo llame al repositorio, ni que el repositorio llame al controlador
Las entidades JPA viajan al exterior Cambiar una columna rompe a los clientes de la API
No hay dirección de dependencias declarada Aparecen ciclos: serviciowebservicio

El síntoma final siempre es el mismo: el tiempo que cuesta hacer un cambio pequeño crece. Y crece porque cualquier cambio puede romper cualquier cosa, porque no hay forma de razonar sobre una parte sin conocer el todo.

La arquitectura es, exactamente, el conjunto de decisiones que limitan lo que se puede hacer. Una buena arquitectura no te da poderes: te quita opciones malas.

  1. Qué es la arquitectura de una aplicación

Definición operativa, sin misticismo:

La arquitectura de una aplicación es la división del sistema en partes, la asignación de responsabilidades a cada parte y las reglas sobre qué parte puede depender de cuál.

Las tres cosas importan, pero la tercera es la que se olvida y la única que se degrada sola. Dividir en modelo, servicio y web es fácil; lo difícil es que dentro de seis meses modelo siga sin depender de web.

Hay dos preguntas que toda arquitectura responde:

  1. ¿Dónde vive la lógica de negocio? (las reglas que existirían aunque no hubiera ordenadores: un préstamo dura 15 días, una multa son 0,50 € por día, un empleado no puede tener más de 3 préstamos activos)
  2. ¿Cómo se aísla esa lógica de la tecnología? (Spring, JPA, HTTP, PostgreSQL, JSON… todo eso es detalle: cambia cada pocos años)

Las dos arquitecturas que vamos a ver responden igual a la primera y de forma distinta a la segunda.

  1. Arquitectura por capas

Es la organización clásica, y probablemente la que más aplicaciones Java sostiene en el mundo. El sistema se divide en capas horizontales, y cada capa solo puede llamar a la inmediatamente inferior.

flowchart TD
    P["Presentación<br/>REST, CLI, vistas"]
    A["Aplicación / Servicio<br/>casos de uso, transacciones"]
    D["Dominio<br/>entidades, reglas de negocio"]
    I["Infraestructura<br/>JPA, HTTP, ficheros, SMTP"]

    P --> A
    A --> D
    A --> I
    I --> D

    style D fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px

Responsabilidad de cada capa en BiblioTech:

Capa Qué contiene en BiblioTech Qué NO puede contener
Presentación PrestamoController (REST), comandos Picocli, formateadores de salida Reglas de negocio, consultas a base de datos
Aplicación / Servicio GestorPrestamos, ProcesadorReservas, ServicioAvisos, transacciones SQL, JSON, HTTP, detalles de presentación
Dominio Material, Libro, Prestamo, Empleado, CalculadoraMultas, Gravedad Absolutamente nada externo
Infraestructura Repositorios JPA, ClienteMetadatos (HTTP), envío de correo, lectura de ficheros Reglas de negocio

Un ejemplo concreto de reparto correcto, con el caso de uso «devolver un préstamo»:

// PRESENTACIÓN: traduce HTTP a una llamada de caso de uso. Nada más.
@PostMapping("/api/prestamos/{id}/devolucion")
public ResponseEntity<FichaPrestamo> devolver(@PathVariable Long id) {
    return ResponseEntity.ok(gestorPrestamos.devolver(id));
}

// APLICACIÓN: orquesta, delimita la transacción, no decide reglas.
@Transactional
public FichaPrestamo devolver(Long id) {
    Prestamo prestamo = repositorio.findById(id)
        .orElseThrow(() -> new PrestamoNoEncontradoException(id));
    Dinero multa = prestamo.registrarDevolucion(LocalDate.now(reloj));  // la regla vive en el dominio
    avisos.notificarDevolucion(prestamo);
    return FichaPrestamo.desde(prestamo);
}

// DOMINIO: la regla de negocio. Sin Spring, sin JPA visible, sin HTTP.
public Dinero registrarDevolucion(LocalDate fecha) {
    if (this.fechaDevolucion.isPresent()) {
        throw new PrestamoYaDevueltoException(this.id);
    }
    this.fechaDevolucion = Optional.of(fecha);
    this.estado = EstadoPrestamo.DEVUELTO;
    return CalculadoraMultas.calcular(this.fechaVencimiento, fecha);
}

Observa dónde está cada decisión. El controlador no sabe qué es una multa. El servicio no sabe cómo se calcula. El dominio no sabe que existe HTTP. Cada capa sabe lo justo.

  1. La regla de dependencia

Es el corazón de todo lo que viene, así que va destacado:

El dominio no depende de nada. Todo lo demás depende del dominio.

La consecuencia inmediata parece un problema: si el servicio de aplicación necesita guardar un Prestamo en la base de datos, y la base de datos es infraestructura, ¿no está el dominio dependiendo de la infraestructura?

No, si se invierte la dependencia. Es la D de SOLID (inversión de dependencias, que veremos formalmente en 12-02) y ya la usaste en 11-02 sin ese nombre:

// EN EL DOMINIO: una interfaz que el dominio define porque el dominio la necesita.
package com.nexussoftware.bibliotech.dominio.puerto;

public interface RepositorioPrestamos {
    Optional<Prestamo> buscarPorId(Long id);
    Prestamo guardar(Prestamo prestamo);
    List<Prestamo> vencidosA(LocalDate fecha);
}
// EN LA INFRAESTRUCTURA: la implementación, que sí conoce JPA.
package com.nexussoftware.bibliotech.infraestructura.persistencia;

@Repository
class RepositorioPrestamosJpa implements RepositorioPrestamos {

    private final PrestamoSpringDataRepository delegado;   // Spring Data, módulo 11

    RepositorioPrestamosJpa(PrestamoSpringDataRepository delegado) {
        this.delegado = delegado;
    }

    @Override
    public Optional<Prestamo> buscarPorId(Long id) {
        return delegado.findById(id);
    }
    // ...
}

La flecha de compilación va de infraestructura a dominio (RepositorioPrestamosJpa importa RepositorioPrestamos), aunque la llamada en ejecución vaya del dominio a la infraestructura. La dirección de la dependencia y la dirección del flujo son cosas distintas, y esa es la idea más importante de la lección.

flowchart LR
    S["GestorPrestamos<br/>(aplicación)"]
    Pu["RepositorioPrestamos<br/>(interfaz, dominio)"]
    Im["RepositorioPrestamosJpa<br/>(infraestructura)"]

    S -->|"usa"| Pu
    Im -.->|"implementa"| Pu

    style Pu fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px

Nadie en el dominio ni en la aplicación escribe jamás import ...infraestructura....

  1. Arquitectura hexagonal: puertos y adaptadores

La arquitectura hexagonal (Alistair Cockburn, 2005; también llamada puertos y adaptadores) lleva la idea anterior hasta el final. Su tesis:

La aplicación tiene un interior (dominio y casos de uso) y un exterior (todo lo que la toca). El interior no sabe nada del exterior. La comunicación pasa siempre por puertos (interfaces definidas por el interior), y cada tecnología concreta es un adaptador que se enchufa a un puerto.

Hay dos tipos de puertos:

  • Puertos de entrada (driving): lo que la aplicación ofrece. Los adaptadores que los usan son quienes conducen la aplicación: REST, CLI, un test, un consumidor de mensajes.
  • Puertos de salida (driven): lo que la aplicación necesita. Los adaptadores que los implementan son conducidos por la aplicación: JPA, HTTP, SMTP, sistema de ficheros.
flowchart LR
    subgraph EXT_IZQ["Adaptadores de entrada"]
        REST["REST<br/>PrestamoController"]
        CLI["CLI<br/>ComandoPrestamo"]
        SOCK["Socket<br/>ServidorCatalogo"]
    end

    subgraph NUCLEO["Núcleo de la aplicación"]
        PE["Puertos de entrada<br/>GestionarPrestamos"]
        DOM["DOMINIO<br/>Prestamo, Material,<br/>CalculadoraMultas"]
        PS["Puertos de salida<br/>RepositorioPrestamos<br/>PasarelaMetadatos<br/>NotificadorAvisos"]
        PE --> DOM
        DOM --> PS
    end

    subgraph EXT_DER["Adaptadores de salida"]
        JPA["JPA / PostgreSQL"]
        HTTP["HttpClient"]
        MAIL["SMTP"]
    end

    REST --> PE
    CLI --> PE
    SOCK --> PE
    PS -.-> JPA
    PS -.-> HTTP
    PS -.-> MAIL

    style DOM fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px

En BiblioTech, los puertos ya existen casi todos, solo que dispersos y sin nombre:

Puerto Tipo Adaptador actual Otro adaptador posible
GestionarPrestamos Entrada PrestamoController (12-04) ComandoPrestamo de la CLI (12-03)
ConsultarCatalogo Entrada ServidorCatalogo (módulo 9) REST, CLI
RepositorioPrestamos Salida RepositorioPrestamosJpa En memoria, para pruebas
PasarelaMetadatos Salida ClienteMetadatos (HttpClient) Fichero local, WireMock
NotificadorAvisos Salida ServicioAvisos por correo Consola, cola de mensajes

El beneficio se ve al probar. Con puertos, una prueba del caso de uso completo no necesita ni base de datos ni red:

@Test
void devolverConRetrasoGeneraMulta() {
    var repositorio = new RepositorioPrestamosEnMemoria();   // adaptador de prueba
    var notificador  = new NotificadorSilencioso();
    var reloj = Clock.fixed(Instant.parse("2026-03-20T10:00:00Z"), ZoneId.of("Europe/Madrid"));
    var gestor = new GestorPrestamos(repositorio, notificador, reloj);

    repositorio.guardar(unPrestamoDe("978-0000000001", "Marta Ruiz")
        .conVencimiento(LocalDate.of(2026, 3, 10)));

    Dinero multa = gestor.devolver(1L).multa();

    assertThat(multa).isEqualTo(Dinero.euros("5.00"));   // 10 días × 0,50 €
}

Sin @SpringBootTest, sin H2, sin @DataJpaTest. Milisegundos. Eso es lo que compra la arquitectura hexagonal.

  1. Comparativa: capas frente a hexagonal

Aspecto Por capas Hexagonal (puertos y adaptadores)
Metáfora Pila horizontal Núcleo rodeado de enchufes
Dirección de dependencias De arriba abajo (y la infraestructura al dominio, si se invierte) Siempre hacia el núcleo
Dónde se definen las interfaces de persistencia A menudo en la capa de infraestructura Siempre en el núcleo
Número de interfaces Menor Mayor (un puerto por necesidad externa)
Pruebas del núcleo sin infraestructura Posible con esfuerzo Natural
Cambiar de base de datos o de framework Costoso si hubo filtraciones Escribir un adaptador nuevo
Curva de aprendizaje Baja, todo el mundo la conoce Media
Riesgo Que las capas se salten Sobreingeniería: puertos para todo
Encaja bien en CRUD, aplicaciones pequeñas o medianas Sistemas con reglas de negocio ricas y larga vida

Lo que no debes concluir: que hexagonal es «mejor». Un CRUD de 8 entidades con hexagonal completo tiene tres veces más ficheros y cero ventaja. Y son compatibles: hexagonal es, en la práctica, arquitectura por capas con la regla de dependencia aplicada sin excepciones y las interfaces colocadas del lado correcto.

BiblioTech va a usar capas con inversión de dependencias en las fronteras externas, que es hexagonal ligera: puertos donde hay tecnología externa (persistencia, HTTP, notificaciones), llamada directa donde no la hay.

  1. Organización de paquetes: por capa frente a por funcionalidad

Decidida la arquitectura, queda decidir cómo se traduce a paquetes. Hay dos escuelas.

Por capa (layer-first): el primer nivel de paquetes es la capa.

com.nexussoftware.bibliotech
├── controlador
├── servicio
├── dominio
└── repositorio

Por funcionalidad (feature-first, o package by feature): el primer nivel es el área de negocio.

com.nexussoftware.bibliotech
├── catalogo
├── prestamos
├── reservas
└── empleados
Criterio Por capa Por funcionalidad
Al añadir una funcionalidad Tocas 4 paquetes lejanos Tocas 1 paquete
Al leer el proyecto por primera vez Ves la tecnología Ves el negocio
Cohesión Baja: servicio mezcla préstamos y catálogo Alta
Acoplamiento visible Oculto Evidente (los import cruzados saltan a la vista)
Uso de visibilidad de paquete (package-private) Casi imposible Muy efectivo: puedes ocultar clases internas de la funcionalidad
Borrar una funcionalidad Arqueología Borrar un directorio
Escala a 50 clases Aceptable Bien
Escala a 500 clases Mal Bien

El argumento decisivo es el de la visibilidad. Con paquetes por funcionalidad puedes escribir:

package com.nexussoftware.bibliotech.prestamos;

// package-private: NADIE fuera de la funcionalidad "prestamos" puede tocar esto.
class CalculadoraMultas { ... }

Con paquetes por capa, CalculadoraMultas está en servicio junto a otras veinte clases y tiene que ser public para que la use quien la necesita: es decir, pública para todo el proyecto. La capacidad de ocultar es la que impide que las fronteras se erosionen.

  1. El árbol de paquetes de BiblioTech, en las dos opciones

Opción A — por capa:

src/main/java/com/nexussoftware/bibliotech/
├── BiblioTechApplication.java
├── controlador/
│   ├── PrestamoController.java
│   ├── CatalogoController.java
│   └── ReservaController.java
├── servicio/
│   ├── GestorPrestamos.java
│   ├── ProcesadorReservas.java
│   ├── ServicioAvisos.java
│   ├── EstadisticasBiblioTech.java
│   └── EnriquecedorCatalogo.java
├── dominio/
│   ├── Material.java
│   ├── Libro.java
│   ├── Revista.java
│   ├── Dvd.java
│   ├── Prestamo.java
│   ├── Reserva.java
│   ├── Empleado.java
│   ├── EstadoPrestamo.java
│   └── Gravedad.java
├── repositorio/
│   ├── PrestamoRepository.java
│   ├── MaterialRepository.java
│   └── ReservaRepository.java
└── dto/
    ├── FichaMaterialDto.java
    └── CrearPrestamoDto.java

Opción B — por funcionalidad:

src/main/java/com/nexussoftware/bibliotech/
├── BiblioTechApplication.java
├── compartido/                      ← solo lo genuinamente transversal
│   ├── Dinero.java
│   ├── Isbn.java
│   ├── Gravedad.java
│   └── BiblioTechException.java
├── catalogo/
│   ├── Material.java                (package-private donde se puede)
│   ├── Libro.java
│   ├── Revista.java
│   ├── Dvd.java
│   ├── TipoMaterial.java
│   ├── CatalogoService.java         ← API pública de la funcionalidad
│   ├── CatalogoController.java
│   ├── MaterialRepository.java
│   └── metadatos/
│       ├── PasarelaMetadatos.java   (puerto)
│       └── ClienteMetadatos.java    (adaptador HTTP)
├── prestamos/
│   ├── Prestamo.java
│   ├── EstadoPrestamo.java
│   ├── CalculadoraMultas.java       (package-private)
│   ├── GestorPrestamos.java         ← API pública
│   ├── PrestamoController.java
│   └── PrestamoRepository.java
├── reservas/
│   ├── Reserva.java
│   ├── ProcesadorReservas.java
│   └── ReservaRepository.java
├── empleados/
│   ├── Empleado.java
│   └── EmpleadoRepository.java
└── avisos/
    ├── NotificadorAvisos.java       (puerto)
    └── ServicioAvisos.java          (adaptador)

  1. Recomendación justificada

Para BiblioTech: por funcionalidad en el primer nivel, por capa dentro de cada funcionalidad. Es decir, la opción B.

Las razones, en orden de peso:

  1. El proyecto va a crecer. El módulo 12 le añade CLI, web, seguridad y observabilidad. Por capa, el paquete servicio acabaría con veinte clases sin relación entre sí.
  2. Permite ocultar. CalculadoraMultas es un detalle de cómo funcionan los préstamos; nadie más debería poder llamarla. Solo el paquete por funcionalidad lo hace cumplir.
  3. El código se lee por negocio, no por tecnología. Cuando Nuria Vidal pregunta «¿dónde está la regla de las reservas caducadas?», la respuesta es reservas/, no «busca en servicio, luego en dominio, luego en repositorio».
  4. Los cambios son locales. Añadir «renovación de préstamo» toca prestamos/ y nada más.
  5. Hace visible el acoplamiento. Si reservas necesita cinco clases de prestamos, los import lo gritan. Con paquetes por capa, ese mismo acoplamiento es invisible.

Un aviso honesto: la opción B tiene un punto débil, el paquete compartido. Tiende a convertirse en un cajón de sastre. La regla es: algo entra en compartido solo si lo usan tres o más funcionalidades y no pertenece a ninguna. Dinero e Isbn sí; CalculadoraMultas no, aunque la reutilicen dos.

  1. Proyecto multimódulo Maven aplicado a BiblioTech

Los paquetes son una convención: el compilador no impide que dominio importe controlador. Los módulos Maven sí lo impiden, porque un módulo solo ve lo que declara como dependencia, y Maven prohíbe los ciclos.

Esta es la estructura que va a tener BiblioTech:

flowchart BT
    DOM["bibliotech-dominio<br/>sin dependencias externas"]
    APP["bibliotech-aplicacion<br/>casos de uso"]
    INF["bibliotech-infraestructura<br/>JPA, HTTP, Spring"]
    CON["bibliotech-consola<br/>Picocli"]
    WEB["bibliotech-web<br/>Spring MVC"]

    APP --> DOM
    INF --> DOM
    INF --> APP
    CON --> APP
    CON --> INF
    WEB --> APP
    WEB --> INF

    style DOM fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px

Responsabilidad y dependencias permitidas de cada módulo:

Módulo Contiene Depende de Dependencias externas permitidas
bibliotech-dominio Entidades, objetos de valor, reglas, puertos, excepciones Nada Ninguna (a lo sumo, la API de validación)
bibliotech-aplicacion Casos de uso, orquestación, DTOs internos dominio Ninguna, o solo anotaciones de transacción
bibliotech-infraestructura Adaptadores JPA, HTTP, correo, configuración de Spring dominio, aplicación Spring, Hibernate, Jackson, HttpClient
bibliotech-consola Comandos Picocli, formateadores aplicación, infraestructura Picocli, Spring Boot
bibliotech-web Controladores REST, DTOs de API, manejador global de errores aplicación, infraestructura Spring Web, validación, springdoc

La estructura de directorios resultante:

bibliotech/
├── pom.xml                       ← POM padre (packaging: pom)
├── mvnw / mvnw.cmd / .mvn/
├── bibliotech-dominio/
│   ├── pom.xml
│   └── src/{main,test}/java/...
├── bibliotech-aplicacion/
│   ├── pom.xml
│   └── src/{main,test}/java/...
├── bibliotech-infraestructura/
│   ├── pom.xml
│   └── src/{main,test}/{java,resources}/...
├── bibliotech-consola/
│   ├── pom.xml
│   └── src/{main,test}/{java,resources}/...
└── bibliotech-web/
    ├── pom.xml
    └── src/{main,test}/{java,resources}/...

  1. El POM padre y dependencyManagement

El POM padre no produce código: agrega los módulos y centraliza las versiones. Recuerda de 11-05 que dependencyManagement declara versiones sin añadir dependencias; los hijos las heredan y solo escriben groupId y artifactId.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
                             https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <!-- Heredamos de Spring Boot: nos da el BOM con las versiones compatibles
       de más de 400 librerías, y la configuración por defecto de los plugins. -->
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.4</version>
    <relativePath/>
  </parent>

  <groupId>com.nexussoftware</groupId>
  <artifactId>bibliotech</artifactId>
  <version>1.0.0-SNAPSHOT</version>
  <packaging>pom</packaging>            <!-- clave: agregador, no produce jar -->
  <name>BiblioTech</name>
  <description>Gestión de la biblioteca técnica interna de Nexus Software</description>

  <!-- El orden aquí es irrelevante: Maven ordena los módulos por sus dependencias. -->
  <modules>
    <module>bibliotech-dominio</module>
    <module>bibliotech-aplicacion</module>
    <module>bibliotech-infraestructura</module>
    <module>bibliotech-consola</module>
    <module>bibliotech-web</module>
  </modules>

  <properties>
    <java.version>21</java.version>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <picocli.version>4.7.6</picocli.version>
    <mapstruct.version>1.6.2</mapstruct.version>
    <springdoc.version>2.6.0</springdoc.version>
  </properties>

  <dependencyManagement>
    <dependencies>
      <!-- Nuestros propios módulos: los hijos los usarán sin repetir la versión. -->
      <dependency>
        <groupId>com.nexussoftware</groupId>
        <artifactId>bibliotech-dominio</artifactId>
        <version>${project.version}</version>
      </dependency>
      <dependency>
        <groupId>com.nexussoftware</groupId>
        <artifactId>bibliotech-aplicacion</artifactId>
        <version>${project.version}</version>
      </dependency>
      <dependency>
        <groupId>com.nexussoftware</groupId>
        <artifactId>bibliotech-infraestructura</artifactId>
        <version>${project.version}</version>
      </dependency>

      <!-- Externas que Spring Boot no gestiona -->
      <dependency>
        <groupId>info.picocli</groupId>
        <artifactId>picocli-spring-boot-starter</artifactId>
        <version>${picocli.version}</version>
      </dependency>
      <dependency>
        <groupId>org.springdoc</groupId>
        <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
        <version>${springdoc.version}</version>
      </dependency>
    </dependencies>
  </dependencyManagement>

  <!-- Estas sí las hereda TODO el mundo, porque todo el mundo prueba. -->
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.assertj</groupId>
      <artifactId>assertj-core</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>
</project>

El POM del dominio es donde la arquitectura se vuelve verificable por la herramienta:

<project ...>
  <modelVersion>4.0.0</modelVersion>

  <parent>
    <groupId>com.nexussoftware</groupId>
    <artifactId>bibliotech</artifactId>
    <version>1.0.0-SNAPSHOT</version>
  </parent>

  <artifactId>bibliotech-dominio</artifactId>
  <name>BiblioTech :: Dominio</name>

  <!-- Fíjate bien: NO HAY <dependencies> de producción.
       Ni Spring, ni Hibernate, ni Jackson, ni HttpClient de terceros.
       Solo la biblioteca estándar de Java 21.
       Esto no es un adorno: es la regla de dependencia, hecha cumplir por Maven. -->
</project>

Y el de infraestructura, que sí puede traer tecnología:

<project ...>
  <parent>
    <groupId>com.nexussoftware</groupId>
    <artifactId>bibliotech</artifactId>
    <version>1.0.0-SNAPSHOT</version>
  </parent>

  <artifactId>bibliotech-infraestructura</artifactId>
  <name>BiblioTech :: Infraestructura</name>

  <dependencies>
    <dependency>
      <groupId>com.nexussoftware</groupId>
      <artifactId>bibliotech-aplicacion</artifactId>   <!-- versión heredada del padre -->
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
      <groupId>org.flywaydb</groupId>
      <artifactId>flyway-core</artifactId>
    </dependency>
    <dependency>
      <groupId>org.postgresql</groupId>
      <artifactId>postgresql</artifactId>
      <scope>runtime</scope>
    </dependency>
  </dependencies>
</project>

Construcción y comprobación:

# Construye los cinco módulos en el orden correcto (Maven lo deduce del grafo)
./mvnw clean install

# Solo el dominio y lo que necesita para compilar (-am = also make)
./mvnw -pl bibliotech-dominio -am test

# El árbol de dependencias del dominio: debe ser prácticamente vacío
./mvnw -pl bibliotech-dominio dependency:tree

  1. Cómo el grafo de módulos impide que el dominio importe Spring

Esta es la parte que convierte una recomendación en una garantía. Supón que Diego Alonso, con prisa, escribe esto en el módulo de dominio:

package com.nexussoftware.bibliotech.dominio.prestamos;

import org.springframework.stereotype.Service;   // en bibliotech-dominio

@Service
public class CalculadoraMultas { ... }

Resultado:

$ ./mvnw -pl bibliotech-dominio compile
[ERROR] /.../CalculadoraMultas.java:[3,32] package org.springframework.stereotype does not exist
[ERROR] /.../CalculadoraMultas.java:[5,2] cannot find symbol: class Service
[INFO] BUILD FAILURE

No es una convención que alguien deba recordar en la revisión de código: es un error de compilación. Y lo mismo ocurre en la otra dirección: si alguien intenta que el dominio dependa de infraestructura para «arreglarlo», Maven detecta el ciclo:

[ERROR] The projects in the reactor contain a cyclic reference:
        bibliotech-dominio -> bibliotech-infraestructura -> bibliotech-dominio

Este es el argumento definitivo a favor del multimódulo, y merece enunciarse como principio general:

Las reglas que dependen de la disciplina humana se rompen. Las que dependen de la herramienta, no.

La misma idea que el lombok.config de 11-07 (convertir «no uses @Data en entidades» en algo que el compilador verifica) aplicada a la arquitectura entera.

Si por alguna razón no puedes ir a multimódulo, la alternativa es ArchUnit (mencionada en 11-07), que expresa las mismas reglas como pruebas JUnit:

@AnalyzeClasses(packages = "com.nexussoftware.bibliotech")
class ReglasDeArquitecturaTest {

    @ArchTest
    static final ArchRule elDominioNoDependeDeSpring =
        noClasses().that().resideInAPackage("..dominio..")
            .should().dependOnClassesThat().resideInAnyPackage("org.springframework..");

    @ArchTest
    static final ArchRule elDominioNoDependeDeJpa =
        noClasses().that().resideInAPackage("..dominio..")
            .should().dependOnClassesThat().resideInAnyPackage("jakarta.persistence..");

    @ArchTest
    static final ArchRule sinCiclos =
        slices().matching("com.nexussoftware.bibliotech.(*)..").should().beFreeOfCycles();
}

Es peor que el multimódulo (la regla se comprueba en fase de pruebas, no de compilación), pero es infinitamente mejor que nada, y se puede aplicar hoy a cualquier proyecto sin reestructurarlo.

  1. DTOs frente a entidades

Ahora una decisión que parece menor y arruina proyectos: ¿puede un controlador REST devolver directamente una entidad JPA?

Técnicamente sí. Jackson serializa Prestamo sin protestar. Y es un error, por seis razones concretas:

Problema Qué pasa exactamente
Acoplamiento del contrato al esquema Renombras la columna fecha_venc a fecha_vencimiento y rompes a todos los clientes de la API
Fuga de datos La entidad Empleado tiene hashContrasena, dni, salario. Todo eso viaja en el JSON
Carga perezosa Jackson accede a prestamo.getMaterial() fuera de la transacción: LazyInitializationException, o peor, N+1 (11-03)
Ciclos infinitos PrestamoEmpleadoList<Prestamo>StackOverflowError
Entrada peligrosa Con @RequestBody Prestamo, un cliente puede enviar {"id": 7, "version": 3, "estado": "DEVUELTO"} y modificar campos que no debería
Formatos distintos La API quiere LocalDate en ISO-8601 y un campo calculado diasRestantes; la entidad no tiene por qué

La solución es un DTO (Data Transfer Object): un objeto cuyo único trabajo es cruzar la frontera. Y en Java 21, un DTO es un record (04-07):

package com.nexussoftware.bibliotech.web.dto;

/**
 * Lo que la API DEVUELVE de un préstamo. Contrato público, estable,
 * independiente de cómo esté modelada la entidad por dentro.
 */
public record PrestamoResponse(
        Long id,
        String tituloMaterial,
        String isbn,
        String nombreEmpleado,
        LocalDate fechaPrestamo,
        LocalDate fechaVencimiento,
        LocalDate fechaDevolucion,   // null si sigue prestado: JSON no tiene Optional
        String estado,
        long diasRestantes,          // campo calculado que la entidad no tiene
        BigDecimal multa) {

    public static PrestamoResponse desde(Prestamo p, LocalDate hoy) {
        return new PrestamoResponse(
            p.getId(),
            p.getMaterial().getTitulo(),
            p.getMaterial().getIsbn().valor(),
            p.getEmpleado().getNombre(),
            p.getFechaPrestamo(),
            p.getFechaVencimiento(),
            p.getFechaDevolucion().orElse(null),
            p.getEstado().name(),
            ChronoUnit.DAYS.between(hoy, p.getFechaVencimiento()),
            p.multaAcumulada(hoy).importe());
    }
}
/**
 * Lo que la API ACEPTA para crear un préstamo. Solo los campos que
 * el cliente tiene derecho a decidir. Ni id, ni version, ni estado.
 */
public record CrearPrestamoRequest(
        @NotBlank @Isbn String isbn,
        @NotNull Long idEmpleado,
        @Positive @Max(30) Integer dias) {
}

Fíjate en lo que hace la asimetría entre los dos records: el cliente no puede enviar id, version ni estado, porque el objeto en el que se deserializa no tiene esos componentes. La seguridad no depende de que el servidor recuerde ignorarlos.

Sobre el mapeo: escribir desde(...) a mano es perfectamente aceptable y es lo que haremos, porque es explícito y no añade magia. Cuando los DTOs se multiplican, MapStruct (mencionado en 11-07) genera esos mapeadores en compilación a partir de una interfaz anotada, sin reflexión y sin coste en ejecución:

@Mapper(componentModel = "spring")
public interface PrestamoMapper {
    @Mapping(target = "tituloMaterial", source = "material.titulo")
    @Mapping(target = "nombreEmpleado", source = "empleado.nombre")
    PrestamoResponse aResponse(Prestamo prestamo);
}

Regla práctica: entidad JPA hacia dentro, DTO hacia fuera. La frontera de la aplicación (REST, CLI, mensajes) nunca ve una entidad.

  1. Configuración por entorno: application.yml y perfiles

BiblioTech ya tiene perfiles dev y prod desde 11-02. Ahora hay que organizarlos bien, con una regla de fondo:

El mismo artefacto se despliega en todos los entornos. Lo que cambia es la configuración, nunca el jar.

Fichero base, bibliotech-web/src/main/resources/application.yml:

spring:
  application:
    name: bibliotech
  jpa:
    open-in-view: false            # desactívalo SIEMPRE: evita consultas en la capa de vista (11-03)
    properties:
      hibernate:
        jdbc.batch_size: 25
  threads:
    virtual:
      enabled: true                # hilos virtuales de Java 21 (10-06), se explica en 12-04

server:
  port: 8080
  shutdown: graceful               # apagado ordenado, se desarrolla en 12-06

bibliotech:                        # nuestras propiedades, tipadas en PropiedadesBiblioTech
  prestamo:
    dias-por-defecto: 15
    maximo-activos-por-empleado: 3
  multa:
    importe-por-dia: 0.50
    importe-maximo: 20.00
  metadatos:
    url-base: https://api.metadatos.ejemplo/v1
    tiempo-espera: 3s

logging:
  level:
    com.nexussoftware.bibliotech: INFO

Perfil de desarrollo, application-dev.yml:

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/bibliotech
    username: bibliotech
    password: bibliotech            # local, desechable, nunca reutilizada fuera
  jpa:
    show-sql: true
    hibernate:
      ddl-auto: validate            # nunca 'update': el esquema lo gobierna Flyway (12-06)
  flyway:
    enabled: true

logging:
  level:
    com.nexussoftware.bibliotech: DEBUG
    org.hibernate.SQL: DEBUG

Perfil de producción, application-prod.yml:

spring:
  datasource:
    url: ${BIBLIOTECH_DB_URL}       # sin valor por defecto: si falta, la app NO arranca
    username: ${BIBLIOTECH_DB_USER}
    password: ${BIBLIOTECH_DB_PASSWORD}
    hikari:
      maximum-pool-size: 20
  jpa:
    show-sql: false
    hibernate:
      ddl-auto: validate

server:
  error:
    include-stacktrace: never       # nunca filtrar trazas al cliente (12-04, 12-07)
    include-message: never

logging:
  level:
    root: WARN
    com.nexussoftware.bibliotech: INFO

Que ${BIBLIOTECH_DB_URL} no tenga valor por defecto es intencionado: si la variable no está definida, la aplicación falla al arrancar con un mensaje claro. Es infinitamente preferible a arrancar en producción apuntando silenciosamente a una base de datos de pruebas.

Activación del perfil:

# En desarrollo
./mvnw -pl bibliotech-web spring-boot:run -Dspring-boot.run.profiles=dev

# En producción (variable de entorno, no argumento)
SPRING_PROFILES_ACTIVE=prod java -jar bibliotech-web.jar

  1. La jerarquía de fuentes de configuración de Spring Boot

Spring Boot lee la configuración de muchos sitios y resuelve los conflictos por precedencia. Esta tabla, de mayor a menor prioridad (versión abreviada de la oficial, con lo que se usa de verdad), es de las que conviene tener a mano:

# Fuente Ejemplo Uso típico
1 Argumentos de línea de comandos --server.port=9090 Ajuste puntual, depuración
2 Propiedades de sistema de la JVM -Dserver.port=9090 Arranque desde scripts
3 Variables de entorno SERVER_PORT=9090 Producción y contenedores
4 application-{perfil}.yml externo (junto al jar) ./config/application-prod.yml Configuración del operador
5 application-{perfil}.yml empaquetado application-prod.yml Diferencias por entorno
6 application.yml externo ./application.yml Sobrescritura del operador
7 application.yml empaquetado application.yml Valores base
8 @PropertySource Casos heredados
9 Valores por defecto en el código @Value("${x:10}") Último recurso

Dos consecuencias prácticas:

  • La relajación de nombres: bibliotech.multa.importe-por-dia se puede fijar con la variable de entorno BIBLIOTECH_MULTA_IMPORTEPORDIA. Mayúsculas, puntos y guiones a subrayado. Esta correspondencia es lo que hace posible configurar cualquier cosa en un contenedor sin tocar ficheros.
  • Depurar la configuración: el endpoint /actuator/env (protegido, como veremos en 12-07) muestra el valor efectivo de cada propiedad y de qué fuente vino. Es la respuesta a «juraría que puse el puerto 9090».

  1. Variables de entorno y secretos fuera del repositorio

AVISO IMPORTANTE. Ninguna credencial, clave de API, certificado, contraseña de base de datos, token ni secreto de firma puede estar en el repositorio. Nunca. Ni en application.yml, ni en un .properties «solo de pruebas», ni en un comentario, ni en un test. Git recuerda para siempre: borrarlo en un commit posterior no lo elimina del historial, y si el repositorio ha sido clonado o publicado, el secreto ya está comprometido y hay que rotarlo, no ocultarlo.

Los mecanismos, de menos a más serio:

Mecanismo Cuándo Cuidado
Variables de entorno Casi siempre; estándar de facto en contenedores Visibles en /proc y en volcados de entorno
Fichero .env local, ignorado por Git Desarrollo Que esté en .gitignore antes de crearlo
Fichero externo montado en el despliegue Servidores propios Permisos 600 y propietario correcto
Gestor de secretos (Vault, AWS Secrets Manager, Secrets de Kubernetes) Producción seria Se desarrolla en 12-07

Detección temprana, que es lo que de verdad evita el incidente:

# Buscar secretos en TODO el historial, no solo en el árbol actual
gitleaks detect --source . --verbose

# Como enganche de pre-commit, para que no llegue ni a entrar
pre-commit run --all-files

En 12-07 se retoma este tema con la gestión completa: rotación, cifrado en reposo y qué hacer cuando ya ha pasado.

  1. Control de versiones: .gitignore, ramas y commits convencionales

El .gitignore, en la raíz del proyecto multimódulo:

# --- Maven ---
target/
!.mvn/wrapper/maven-wrapper.jar
.mvn/timing.properties
dependency-reduced-pom.xml

# --- Java ---
*.class
*.jar
*.war
hs_err_pid*.log
replay_pid*.log

# --- IDE: IntelliJ ---
.idea/
*.iml
*.iws

# --- IDE: Eclipse ---
.classpath
.project
.settings/
bin/

# --- IDE: VS Code (se versiona launch.json si el equipo lo comparte) ---
.vscode/*
!.vscode/launch.json
!.vscode/settings.json

# --- Sistema operativo ---
.DS_Store
Thumbs.db

# --- Local y secretos ---
.env
*.local.yml
application-local.yml
/config/secrets/
*.p12
*.jks
*.pem

Las dos últimas secciones son las importantes. target/ lo tiene todo el mundo; los .p12 y los .env son los que causan disgustos.

Estrategia de ramas. Para un equipo pequeño como el de Nexus Software, trunk-based con ramas cortas:

Rama Vive Propósito
main Siempre Siempre desplegable. Protegida: nadie empuja directamente
feature/xxx Horas o pocos días Una funcionalidad. Se integra por Pull Request con CI en verde
fix/xxx Horas Corrección
release/x.y Solo si hay versiones soportadas en paralelo Mantenimiento de una versión antigua

Ramas de vida larga = conflictos de integración grandes. La regla es: si una rama lleva más de tres días abierta, el trabajo estaba mal troceado.

Mensajes de commit convencionales (Conventional Commits). No es burocracia: permite generar el registro de cambios y deducir la versión semántica automáticamente.

Formato: tipo(ámbito): descripción en imperativo

Tipo Significado Ejemplo real de BiblioTech
feat Funcionalidad nueva feat(prestamos): permitir renovar un préstamo una vez
fix Corrección de error fix(multas): no cobrar los días de cierre por vacaciones
refactor Cambio interno sin cambio de comportamiento refactor(catalogo): extraer PasarelaMetadatos como puerto
test Pruebas test(prestamos): cubrir el límite de 3 préstamos activos
docs Documentación docs(readme): añadir instrucciones de arranque con Docker
build Construcción y dependencias build(deps): subir Spring Boot a 3.3.4
ci Integración continua ci: publicar informe de cobertura en el PR
perf Rendimiento perf(catalogo): evitar N+1 al listar materiales
chore Tareas varias chore: actualizar .gitignore

Un cambio incompatible se marca con ! o con un pie BREAKING CHANGE:, y eso es lo que dispara una subida de versión mayor:

feat(api)!: eliminar el campo `disponible` de MaterialResponse

BREAKING CHANGE: los clientes deben usar `unidadesDisponibles`, que es un
entero, en lugar del booleano `disponible`. El campo antiguo estuvo marcado
como deprecado desde la versión 1.4.0.

  1. Formato y estilo: .editorconfig y Spotless

Discutir llaves y sangrías en una revisión de código es tiempo tirado. Se automatiza y se olvida.

.editorconfig — lo entienden casi todos los editores, sin plugins:

root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
max_line_length = 120

[*.{xml,yml,yaml,json}]
indent_size = 2

[*.md]
trim_trailing_whitespace = false   # dos espacios finales = salto de línea en Markdown

[*.{sh,bash}]
indent_size = 2

Spotless — formatea de verdad, y falla la construcción si el código no está formateado. En el POM padre:

<build>
  <pluginManagement>
    <plugins>
      <plugin>
        <groupId>com.diffplug.spotless</groupId>
        <artifactId>spotless-maven-plugin</artifactId>
        <version>2.43.0</version>
        <configuration>
          <java>
            <palantirJavaFormat/>          <!-- o <googleJavaFormat/> -->
            <removeUnusedImports/>
            <importOrder>
              <order>java,javax,jakarta,org,com,com.nexussoftware,</order>
            </importOrder>
            <licenseHeader>
              <content>/* BiblioTech - Nexus Software */</content>
            </licenseHeader>
          </java>
          <pom>
            <sortPom/>
          </pom>
        </configuration>
        <executions>
          <execution>
            <goals>
              <!-- 'check' falla la construcción; 'apply' arregla.
                   En CI queremos que falle. -->
              <goal>check</goal>
            </goals>
            <phase>validate</phase>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </pluginManagement>
</build>
./mvnw spotless:apply    # formatea todo el proyecto
./mvnw spotless:check    # solo comprueba: esto es lo que corre en CI

Un consejo de proceso: haz la reformateo masivo inicial en un commit propio, que no contenga ningún cambio funcional, y anótalo en .git-blame-ignore-revs para que no ensucie el git blame:

# .git-blame-ignore-revs
# Reformateo inicial con Spotless (sin cambios funcionales)
a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
git config blame.ignoreRevsFile .git-blame-ignore-revs

  1. Un README.md que sirva de verdad

Casi todos los README son inútiles porque cuentan lo que el proyecto es y no lo que el lector necesita hacer. El criterio de calidad es objetivo:

Una persona que nunca ha visto el proyecto, con solo el README, debe poder arrancarlo y ejecutar las pruebas en menos de diez minutos.

# BiblioTech

Sistema de gestión de la biblioteca técnica interna de Nexus Software.
Gestiona el catálogo de materiales (libros, revistas, DVD), los préstamos
a empleados, las reservas y las multas por retraso.

## Requisitos

| Herramienta | Versión | Comprobación |
|---|---|---|
| JDK | 21+ | `java -version` |
| Docker | 24+ | `docker --version` |
| Maven | No hace falta: usa `./mvnw` | — |

## Arranque rápido

git clone https://git.nexussoftware.com/bibliotech.git cd bibliotech docker compose up -d # PostgreSQL en localhost:5432 ./mvnw clean install ./mvnw -pl bibliotech-web spring-boot:run -Dspring-boot.run.profiles=dev

Comprobación:

curl http://localhost:8080/actuator/health # {"status":"UP"} curl http://localhost:8080/api/materiales

Documentación de la API: <http://localhost:8080/swagger-ui.html>

## Comandos habituales

| Objetivo | Comando |
|---|---|
| Compilar todo | `./mvnw clean install` |
| Solo pruebas unitarias | `./mvnw test` |
| Pruebas de integración | `./mvnw verify` |
| Informe de cobertura | `./mvnw verify` → `target/site/jacoco/index.html` |
| Formatear el código | `./mvnw spotless:apply` |
| CLI | `java -jar bibliotech-consola/target/bibliotech-consola.jar catalogo listar` |

## Estructura

| Módulo | Responsabilidad |
|---|---|
| `bibliotech-dominio` | Entidades y reglas de negocio. Sin dependencias externas |
| `bibliotech-aplicacion` | Casos de uso |
| `bibliotech-infraestructura` | JPA, HTTP, correo, configuración de Spring |
| `bibliotech-consola` | CLI con Picocli |
| `bibliotech-web` | API REST |

Decisiones de arquitectura: [`docs/adr/`](docs/adr/).

## Configuración

Todas las propiedades propias están bajo `bibliotech.*` en `application.yml`.
En producción se inyectan por variables de entorno (ver `docs/despliegue.md`).
**Nunca** se añaden secretos al repositorio.

## Contribuir

1. Rama desde `main`: `feature/descripcion-corta`
2. Commits con [Conventional Commits](https://www.conventionalcommits.org/)
3. `./mvnw verify` y `./mvnw spotless:check` en verde
4. Pull Request; requiere una aprobación y CI en verde

Lo que no debe llevar un README: el diagrama de clases completo (se queda obsoleto en una semana), la historia del proyecto, ni la documentación de la API (esa la genera OpenAPI).

  1. Registro de decisiones de arquitectura (ADR)

Nota: ADR (Architecture Decision Record). Un ADR es un fichero Markdown corto, numerado e inmutable, que registra una decisión de arquitectura: el contexto, la decisión y sus consecuencias. Vive en docs/adr/ dentro del repositorio, junto al código que describe. No se edita cuando la decisión cambia: se escribe un ADR nuevo que supersede al anterior. Su valor no está en el presente, sino dentro de dos años, cuando alguien pregunte «¿por qué demonios está esto así?» y la alternativa sea adivinar.

Ejemplo real, extraído de una decisión que ya tomamos en el módulo 11:

# ADR-004: Renunciar a `sealed` en la jerarquía Material

- **Estado:** Aceptada
- **Fecha:** 2026-06-18
- **Decisores:** Marta Ruiz, Diego Alonso

## Contexto

`Material` era una interfaz `sealed` (Java 17, módulo 10) con `Libro`, `Revista`
y `Dvd` como únicas implementaciones permitidas, lo que habilitaba pattern
matching exhaustivo sin `default`.

Al pasar a JPA con estrategia `SINGLE_TABLE`, Hibernate necesita crear proxies
por subclase y las entidades no pueden pertenecer a una jerarquía sellada
gestionada de ese modo.

## Decisión

Convertir `Material` en clase abstracta no sellada, con `@Inheritance(SINGLE_TABLE)`
y `@DiscriminatorColumn(name = "tipo")`.

## Consecuencias

**Positivas:** persistencia polimórfica con una sola consulta; sin JOIN por tipo.

**Negativas:** se pierde la exhaustividad del `switch`; hay que mantener una rama
`default` que lance `IllegalStateException`. Cualquiera puede heredar de `Material`
sin que el compilador lo impida.

**Mitigación:** una prueba de ArchUnit comprueba que las subclases de `Material`
son exactamente tres.

## Alternativas descartadas

- **`TABLE_PER_CLASS`:** mantendría el modelo, pero las consultas polimórficas
  se convierten en `UNION` y el rendimiento del catálogo se degrada.
- **Modelo de dominio separado del de persistencia:** conserva `sealed`, a costa
  de duplicar nueve clases y sus mapeos. Desproporcionado para este proyecto.

Escribe un ADR cuando la decisión sea cara de revertir: elección de base de datos, de framework, estructura de módulos, estrategia de autenticación, formato de la API. No escribas uno para elegir el nombre de una variable.

  1. Scripts de arranque y dependencias locales

El objetivo es que arrancar el entorno de desarrollo sea un comando. Para las dependencias (base de datos, y más adelante lo que haga falta), Docker Compose:

# compose.yaml — solo dependencias, NO la aplicación.
# En desarrollo la app corre en el IDE, con recarga en caliente y depurador.
services:
  postgres:
    image: postgres:16-alpine
    container_name: bibliotech-db
    environment:
      POSTGRES_DB: bibliotech
      POSTGRES_USER: bibliotech
      POSTGRES_PASSWORD: bibliotech
    ports:
      - "5432:5432"
    volumes:
      - bibliotech-datos:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U bibliotech"]
      interval: 5s
      timeout: 3s
      retries: 10

volumes:
  bibliotech-datos:
docker compose up -d        # levantar
docker compose logs -f      # ver registros
docker compose down         # parar (los datos sobreviven en el volumen)
docker compose down -v      # parar y BORRAR los datos

Y un script de conveniencia, scripts/dev.sh:

#!/usr/bin/env bash
set -euo pipefail          # -e: falla al primer error; -u: variable sin definir es error;
                           # -o pipefail: un fallo en medio de una tubería cuenta

cd "$(dirname "$0")/.."    # ejecutable desde cualquier directorio

echo "==> Levantando dependencias"
docker compose up -d --wait     # --wait espera a que el healthcheck esté en verde

echo "==> Construyendo"
./mvnw -q clean install -DskipTests

echo "==> Arrancando BiblioTech (perfil dev)"
exec ./mvnw -pl bibliotech-web spring-boot:run -Dspring-boot.run.profiles=dev

Docker Compose se desarrolla a fondo en 12-06, incluida la aplicación contenerizada. Aquí solo lo necesitamos para tener PostgreSQL en local y dejar de usar H2 como base de datos de desarrollo, que es una fuente de sorpresas (12-05 explica por qué H2 miente).

  1. El árbol completo de BiblioTech reestructurado

Este es el estado final del proyecto al terminar la lección:

bibliotech/
├── .editorconfig                       # estilo común a todos los editores
├── .gitignore
├── .git-blame-ignore-revs              # ignora el commit de reformateo
├── .github/
│   └── workflows/
│       └── ci.yml                      # se escribe en 12-05
├── compose.yaml                        # PostgreSQL local
├── mvnw, mvnw.cmd, .mvn/               # wrapper: misma versión de Maven para todos (11-05)
├── pom.xml                             # POM padre: módulos + dependencyManagement
├── README.md
├── docs/
│   ├── adr/
│   │   ├── 0001-arquitectura-por-capas-con-puertos.md
│   │   ├── 0002-proyecto-multimodulo-maven.md
│   │   ├── 0003-postgresql-en-lugar-de-h2.md
│   │   └── 0004-renunciar-a-sealed-en-material.md
│   └── despliegue.md                   # 12-06
├── scripts/
│   ├── dev.sh
│   └── bibliotech                      # lanzador de la CLI (12-03)
│
├── bibliotech-dominio/                 # ── SIN DEPENDENCIAS EXTERNAS ──
│   ├── pom.xml
│   └── src/main/java/com/nexussoftware/bibliotech/dominio/
│       ├── compartido/
│       │   ├── Dinero.java
│       │   ├── Isbn.java
│       │   ├── Gravedad.java
│       │   └── BiblioTechException.java        # jerarquía del módulo 6
│       ├── catalogo/
│       │   ├── Material.java
│       │   ├── Libro.java  Revista.java  Dvd.java
│       │   ├── TipoMaterial.java
│       │   └── puerto/
│       │       ├── RepositorioMateriales.java
│       │       └── PasarelaMetadatos.java
│       ├── prestamos/
│       │   ├── Prestamo.java
│       │   ├── EstadoPrestamo.java
│       │   ├── CalculadoraMultas.java
│       │   ├── ReglaTarifa.java                # Estrategia, se formaliza en 12-02
│       │   └── puerto/RepositorioPrestamos.java
│       ├── reservas/
│       │   ├── Reserva.java
│       │   └── puerto/RepositorioReservas.java
│       └── empleados/
│           ├── Empleado.java
│           └── puerto/RepositorioEmpleados.java
│
├── bibliotech-aplicacion/              # ── casos de uso ──
│   ├── pom.xml                         # depende SOLO de dominio
│   └── src/main/java/com/nexussoftware/bibliotech/aplicacion/
│       ├── prestamos/
│       │   ├── GestionarPrestamos.java         # puerto de entrada
│       │   └── GestorPrestamos.java            # implementación
│       ├── catalogo/
│       │   ├── ConsultarCatalogo.java
│       │   ├── CatalogoService.java
│       │   └── EnriquecedorCatalogo.java
│       ├── reservas/ProcesadorReservas.java
│       ├── avisos/ServicioAvisos.java
│       └── estadisticas/EstadisticasBiblioTech.java
│
├── bibliotech-infraestructura/         # ── adaptadores de salida ──
│   ├── pom.xml
│   └── src/main/
│       ├── java/com/nexussoftware/bibliotech/infraestructura/
│       │   ├── persistencia/
│       │   │   ├── RepositorioPrestamosJpa.java
│       │   │   ├── PrestamoSpringDataRepository.java
│       │   │   ├── RepositorioMaterialesJpa.java
│       │   │   └── convertidor/IsbnConverter.java
│       │   ├── metadatos/ClienteMetadatos.java     # HttpClient (módulo 9)
│       │   ├── avisos/NotificadorCorreo.java
│       │   ├── sockets/ServidorCatalogo.java       # el del módulo 9, sigue vivo
│       │   └── config/
│       │       ├── PropiedadesBiblioTech.java
│       │       ├── ConfiguracionReloj.java         # Clock inyectable (10-05)
│       │       └── AspectoCronometro.java          # AOP (11-02)
│       └── resources/db/migration/                 # Flyway (12-06)
│           ├── V1__esquema_inicial.sql
│           └── V2__indices_catalogo.sql
│
├── bibliotech-consola/                 # ── adaptador de entrada: CLI (12-03) ──
│   ├── pom.xml
│   └── src/main/java/com/nexussoftware/bibliotech/consola/
│       ├── BiblioTechCli.java
│       └── comandos/…
│
└── bibliotech-web/                     # ── adaptador de entrada: REST (12-04) ──
    ├── pom.xml
    └── src/main/
        ├── java/com/nexussoftware/bibliotech/web/
        │   ├── BiblioTechApplication.java
        │   ├── prestamos/PrestamoController.java
        │   ├── catalogo/CatalogoController.java
        │   ├── dto/…
        │   └── error/ManejadorGlobalErrores.java
        └── resources/
            ├── application.yml
            ├── application-dev.yml
            ├── application-prod.yml
            └── logback-spring.xml                  # SLF4J + MDC (11-07)

Comprobación de que la arquitectura se sostiene:

$ ./mvnw -pl bibliotech-dominio dependency:tree
[INFO] com.nexussoftware:bibliotech-dominio:jar:1.0.0-SNAPSHOT
[INFO] +- org.springframework.boot:spring-boot-starter-test:jar:3.3.4:test
[INFO] \- org.assertj:assertj-core:jar:3.25.3:test

Ni una sola dependencia de producción. El dominio de BiblioTech es Java 21 puro: se compila en un segundo, se prueba en milisegundos y sobrevivirá a Spring.

Errores Comunes y Consejos

1. Reestructurar todo de golpe en una rama de dos semanas. Es la forma más eficaz de que la reestructuración no llegue nunca a main. Hazlo por pasos: primero extraer el dominio, verificar, integrar; luego la aplicación; luego los adaptadores. Cada paso con las pruebas en verde y un commit refactor(...).

2. Confundir «paquetes» con «arquitectura». Renombrar carpetas no cambia nada si dominio sigue importando org.springframework. La arquitectura es la dirección de las dependencias, y hasta que no la verifica una herramienta (módulos Maven o ArchUnit), es solo una intención.

3. Sobreingeniería hexagonal. No todo necesita un puerto. Si CalculadoraMultas es una clase de dominio pura que nadie va a sustituir, llámala directamente. Los puertos son para lo que cruza la frontera de la aplicación: persistencia, red, ficheros, reloj, notificaciones.

4. Un módulo commons que lo contiene todo. Empieza con Dinero y Isbn y acaba con veinte utilidades y una dependencia de Spring que contamina el dominio entero. Sé estricto: solo entra lo que usan tres funcionalidades y no pertenece a ninguna.

5. Exponer entidades JPA en la API «por ahora». No hay ningún «por ahora». En cuanto un cliente consume ese JSON, el esquema de la base de datos se ha convertido en contrato público. Crea el DTO desde el primer endpoint.

6. Meter secretos en el repositorio y borrarlos después. Git no olvida. Si ocurre: rota el secreto inmediatamente, y solo después limpia el historial. El orden inverso no sirve de nada.

7. ddl-auto: update en cualquier entorno que no sea tu portátil. Genera esquemas distintos según el orden de arranque, no borra columnas, no versiona nada y no es reproducible. El esquema se gobierna con Flyway (12-06).

8. open-in-view activado. Está a true por defecto en Spring Boot, y mantiene la sesión de Hibernate abierta durante el renderizado de la respuesta. El resultado es N+1 invisible y consultas ejecutándose en la capa de presentación. Ponlo a false y arregla lo que se rompa: lo que se rompe estaba mal.

9. No usar el wrapper de Maven. Sin ./mvnw, «funciona en mi máquina» aparece por diferencias de versión de Maven. El wrapper se versiona en el repositorio y se usa siempre, también en CI.

10. README desactualizado. Un README que miente es peor que no tenerlo. Truco: que CI ejecute los comandos del arranque rápido. Si el README miente, la construcción falla.

Consejo final: la mejor prueba de que la estructura funciona es el tiempo de incorporación. Si alguien nuevo puede clonar, arrancar, ejecutar las pruebas y encontrar dónde vive la regla de las multas en menos de media hora, la estructura es buena. Si no, ninguna justificación teórica lo compensa.

Ejercicios

Ejercicio 1: aplicar la regla de dependencia

La clase siguiente está en bibliotech-dominio y no compila tras la reestructuración:

package com.nexussoftware.bibliotech.dominio.avisos;

import com.nexussoftware.bibliotech.dominio.prestamos.Prestamo;
import com.nexussoftware.bibliotech.infraestructura.correo.ServidorSmtp;
import org.springframework.stereotype.Service;

@Service
public class ServicioAvisos {

    private final ServidorSmtp smtp = new ServidorSmtp("smtp.nexussoftware.com", 587);

    public void avisarVencimiento(Prestamo prestamo) {
        String cuerpo = "Hola " + prestamo.getEmpleado().getNombre()
                      + ", el préstamo de \"" + prestamo.getMaterial().getTitulo()
                      + "\" vence el " + prestamo.getFechaVencimiento() + ".";
        smtp.enviar(prestamo.getEmpleado().getCorreo(), "Aviso de vencimiento", cuerpo);
    }
}

Enumera todas las violaciones arquitectónicas y reescribe el código repartido entre los módulos correctos.

Ejercicio 2: diseñar los módulos de una funcionalidad nueva

Nexus Software quiere que BiblioTech emita un informe mensual de uso en PDF, con los materiales más prestados, los empleados con más multas y el porcentaje de devoluciones con retraso. El informe se genera con una librería externa (openpdf), se guarda en disco y se envía por correo. Debe poder lanzarse desde la CLI, desde la API REST y automáticamente el día 1 de cada mes.

Indica, para cada pieza que crees: en qué módulo Maven vive, en qué paquete, si es puerto o adaptador, y de qué depende. Escribe las firmas de las interfaces clave.

Ejercicio 3: detectar problemas de configuración

Este application.yml está en el repositorio de BiblioTech. Encuentra al menos seis problemas y escribe la versión corregida, explicando cada cambio.

spring:
  datasource:
    url: jdbc:postgresql://db-produccion.nexussoftware.com:5432/bibliotech
    username: admin
    password: Nexus2026!
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
server:
  port: 8080
  error:
    include-stacktrace: always
bibliotech:
  metadatos:
    api-key: sk-live-9f3a2b1c8d7e6f5a
logging:
  level:
    root: DEBUG

Soluciones

Solución 1

Violaciones detectadas:

# Violación Por qué es grave
1 El dominio importa infraestructura.correo.ServidorSmtp Rompe la regla de dependencia. Con multimódulo, ni compila (y sería un ciclo)
2 El dominio importa org.springframework El dominio no puede depender de un framework
3 new ServidorSmtp(...) dentro de la clase Instanciación directa de infraestructura: imposible de probar sin servidor SMTP
4 Host y puerto empotrados en el código Configuración en el código fuente; distinto por entorno
5 El texto del correo se construye en el dominio Es formato de presentación, no regla de negocio
6 Cadena de accesos prestamo.getEmpleado().getCorreo() Ley de Demeter (03-07, se formaliza en 12-02)

Reescritura. Primero, el puerto, en el dominio:

// bibliotech-dominio/…/dominio/avisos/puerto/NotificadorAvisos.java
package com.nexussoftware.bibliotech.dominio.avisos.puerto;

import com.nexussoftware.bibliotech.dominio.avisos.Aviso;

/**
 * Puerto de salida: el dominio declara QUÉ necesita (notificar),
 * sin decir CÓMO (correo, SMS, consola, cola de mensajes).
 */
public interface NotificadorAvisos {
    void notificar(Aviso aviso);
}

El mensaje como objeto de dominio, sin formato de presentación:

// bibliotech-dominio/…/dominio/avisos/Aviso.java
package com.nexussoftware.bibliotech.dominio.avisos;

public record Aviso(String destinatario,
                    TipoAviso tipo,
                    String nombreEmpleado,
                    String tituloMaterial,
                    LocalDate fecha) {

    public static Aviso porVencimiento(Prestamo prestamo) {
        return new Aviso(
            prestamo.correoDelEmpleado(),          // método del propio Prestamo: sin cadena de getters
            TipoAviso.VENCIMIENTO,
            prestamo.nombreDelEmpleado(),
            prestamo.tituloDelMaterial(),
            prestamo.getFechaVencimiento());
    }
}

El caso de uso, en la capa de aplicación:

// bibliotech-aplicacion/…/aplicacion/avisos/ServicioAvisos.java
package com.nexussoftware.bibliotech.aplicacion.avisos;

public class ServicioAvisos {

    private final NotificadorAvisos notificador;   // el PUERTO, no la implementación
    private final RepositorioPrestamos prestamos;
    private final Clock reloj;                     // 10-05: nunca LocalDate.now() a pelo

    public ServicioAvisos(NotificadorAvisos notificador,
                          RepositorioPrestamos prestamos,
                          Clock reloj) {
        this.notificador = notificador;
        this.prestamos = prestamos;
        this.reloj = reloj;
    }

    public int avisarVencimientosProximos(int diasDeAntelacion) {
        LocalDate limite = LocalDate.now(reloj).plusDays(diasDeAntelacion);
        List<Prestamo> proximos = prestamos.vencidosA(limite);
        proximos.forEach(p -> notificador.notificar(Aviso.porVencimiento(p)));
        return proximos.size();
    }
}

El adaptador, en infraestructura, que es el único que conoce SMTP, Spring y el formato:

// bibliotech-infraestructura/…/infraestructura/avisos/NotificadorCorreo.java
package com.nexussoftware.bibliotech.infraestructura.avisos;

@Component
class NotificadorCorreo implements NotificadorAvisos {

    private static final Logger log = LoggerFactory.getLogger(NotificadorCorreo.class);

    private final JavaMailSender correo;
    private final PropiedadesBiblioTech props;     // host, puerto y remitente vienen de aquí

    NotificadorCorreo(JavaMailSender correo, PropiedadesBiblioTech props) {
        this.correo = correo;
        this.props = props;
    }

    @Override
    public void notificar(Aviso aviso) {
        var mensaje = new SimpleMailMessage();
        mensaje.setFrom(props.avisos().remitente());
        mensaje.setTo(aviso.destinatario());
        mensaje.setSubject("BiblioTech: aviso de vencimiento");
        mensaje.setText("""
                Hola %s,

                El préstamo de "%s" vence el %s.

                — BiblioTech, Nexus Software
                """.formatted(aviso.nombreEmpleado(), aviso.tituloMaterial(), aviso.fecha()));
        try {
            correo.send(mensaje);
        } catch (MailException e) {
            // Frontera de errores (06-07): traducir a la excepción del dominio
            log.warn("No se pudo enviar el aviso a {}", aviso.destinatario(), e);
            throw new NotificacionFallidaException(aviso.destinatario(), e);
        }
    }
}

Y el cableado, también en infraestructura:

// bibliotech-infraestructura/…/infraestructura/config/ConfiguracionAvisos.java
@Configuration
class ConfiguracionAvisos {

    @Bean
    ServicioAvisos servicioAvisos(NotificadorAvisos notificador,
                                  RepositorioPrestamos prestamos,
                                  Clock reloj) {
        return new ServicioAvisos(notificador, prestamos, reloj);
    }
}

Ganancia: ServicioAvisos se prueba con un NotificadorAvisos en memoria que guarda los avisos en una lista. Ni correo, ni Spring, ni base de datos, ni red. Milisegundos.

Solución 2

Diseño de la funcionalidad «informe mensual».

Reparto por módulos:

Pieza Módulo Paquete Rol
InformeMensual (record) dominio dominio.informes Objeto de dominio: los datos del informe
LineaMaterial, LineaEmpleado dominio dominio.informes Objetos de valor
GeneradorInforme (interfaz) dominio dominio.informes.puerto Puerto de salida: convertir datos en bytes
AlmacenInformes (interfaz) dominio dominio.informes.puerto Puerto de salida: guardar
RepositorioEstadisticas (interfaz) dominio dominio.informes.puerto Puerto de salida: consultar agregados
EmitirInformeMensual (interfaz) aplicación aplicacion.informes Puerto de entrada
ServicioInformeMensual aplicación aplicacion.informes Caso de uso: orquesta los cuatro puertos
GeneradorInformePdf infraestructura infraestructura.informes Adaptador con openpdf
AlmacenInformesFichero infraestructura infraestructura.informes Adaptador NIO.2 (módulo 7)
RepositorioEstadisticasJpa infraestructura infraestructura.persistencia Adaptador con JPQL de agregación
PlanificadorInformes infraestructura infraestructura.planificacion Adaptador de entrada: @Scheduled
ComandoInforme consola consola.comandos Adaptador de entrada: Picocli
InformeController web web.informes Adaptador de entrada: REST

Las interfaces clave:

// DOMINIO: los datos del informe. Sin PDF, sin ficheros, sin fechas de servidor.
package com.nexussoftware.bibliotech.dominio.informes;

public record InformeMensual(YearMonth periodo,
                             List<LineaMaterial> masPrestados,
                             List<LineaEmpleado> masMultados,
                             double porcentajeDevolucionesConRetraso) {

    public String nombreSugerido() {
        return "bibliotech-%s.pdf".formatted(periodo);   // yyyy-MM
    }
}

// PUERTO: convertir el informe en bytes. El dominio no sabe que existe el PDF.
package com.nexussoftware.bibliotech.dominio.informes.puerto;

public interface GeneradorInforme {
    byte[] generar(InformeMensual informe);
    String tipoMime();     // "application/pdf", "text/csv"…
}

// PUERTO: dónde se guarda. El dominio no sabe si es disco, S3 o base de datos.
public interface AlmacenInformes {
    URI guardar(String nombre, byte[] contenido, String tipoMime);
}

// PUERTO: de dónde salen los agregados. El dominio no sabe qué es JPQL.
public interface RepositorioEstadisticas {
    List<LineaMaterial> materialesMasPrestados(YearMonth periodo, int limite);
    List<LineaEmpleado> empleadosMasMultados(YearMonth periodo, int limite);
    double porcentajeConRetraso(YearMonth periodo);
}

El caso de uso, que es el único sitio donde se junta todo:

// APLICACIÓN
package com.nexussoftware.bibliotech.aplicacion.informes;

public interface EmitirInformeMensual {           // puerto de ENTRADA
    ResultadoInforme emitir(YearMonth periodo, boolean enviarPorCorreo);
}

public class ServicioInformeMensual implements EmitirInformeMensual {

    private final RepositorioEstadisticas estadisticas;
    private final GeneradorInforme generador;
    private final AlmacenInformes almacen;
    private final NotificadorAvisos notificador;
    private final Clock reloj;

    // constructor con los cinco puertos…

    @Override
    public ResultadoInforme emitir(YearMonth periodo, boolean enviarPorCorreo) {
        var informe = new InformeMensual(
                periodo,
                estadisticas.materialesMasPrestados(periodo, 10),
                estadisticas.empleadosMasMultados(periodo, 10),
                estadisticas.porcentajeConRetraso(periodo));

        byte[] contenido = generador.generar(informe);
        URI ubicacion = almacen.guardar(informe.nombreSugerido(), contenido, generador.tipoMime());

        if (enviarPorCorreo) {
            notificador.notificar(Aviso.informeDisponible(periodo, ubicacion));
        }
        return new ResultadoInforme(periodo, ubicacion, contenido.length);
    }
}

Y los tres adaptadores de entrada, que son tres formas de llamar exactamente al mismo caso de uso:

// infraestructura: automático el día 1 a las 06:00
@Component
class PlanificadorInformes {
    private final EmitirInformeMensual caso;
    @Scheduled(cron = "0 0 6 1 * *")
    void mensual() { caso.emitir(YearMonth.now().minusMonths(1), true); }
}

// consola (12-03)
@Command(name = "informe", description = "Genera el informe mensual de uso")
class ComandoInforme implements Callable<Integer> { … }

// web (12-04)
@PostMapping("/api/informes/{periodo}")
ResponseEntity<InformeResponse> generar(@PathVariable YearMonth periodo) { … }

Lo importante del ejercicio: cambiar el PDF por CSV es escribir un GeneradorInformeCsv; cambiar el disco por S3 es escribir un AlmacenInformesS3. El caso de uso y el dominio no se tocan. Eso es lo que compra la arquitectura hexagonal, y por eso merece la pena en una funcionalidad como esta y no en un CRUD.

Solución 3

Problemas encontrados (nueve):

# Problema Gravedad Por qué
1 Contraseña Nexus2026! en el repositorio Crítica Secreto en Git; queda en el historial para siempre
2 api-key: sk-live-9f3a... en el repositorio Crítica Clave de producción expuesta
3 URL de la base de datos de producción en el fichero base Crítica Cualquiera que arranque en local escribe en producción
4 Usuario admin Alta Viola el mínimo privilegio (12-07): la app necesita CRUD, no DDL
5 ddl-auto: update Alta Modifica el esquema de producción de forma no versionada ni reproducible
6 include-stacktrace: always Alta Filtra estructura interna, versiones y rutas a cualquier cliente
7 logging.level.root: DEBUG Media Volumen enorme, coste, y riesgo de registrar datos personales
8 show-sql: true Media Duplicado del logging, sin parámetros ligados, ruidoso en producción
9 Todo en el fichero base, sin perfiles Media No hay separación entre entornos

Versión corregida. Fichero base, application.yml — solo lo común y ningún secreto:

spring:
  application:
    name: bibliotech
  jpa:
    open-in-view: false
    hibernate:
      ddl-auto: validate        # el esquema lo gobierna Flyway; validate detecta desajustes
  flyway:
    enabled: true

server:
  port: ${SERVER_PORT:8080}     # con valor por defecto: no es un secreto
  shutdown: graceful
  error:
    include-stacktrace: never
    include-message: never

bibliotech:
  metadatos:
    url-base: https://api.metadatos.ejemplo/v1
    tiempo-espera: 3s
    # api-key NO está aquí: llega por variable de entorno

logging:
  level:
    root: INFO
    com.nexussoftware.bibliotech: INFO

application-dev.yml — local, desechable, verboso:

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/bibliotech
    username: bibliotech
    password: bibliotech        # aceptable: base local en un contenedor efímero
  jpa:
    show-sql: true

server:
  error:
    include-stacktrace: on_param   # ?trace=true, cómodo al depurar

bibliotech:
  metadatos:
    api-key: ${METADATOS_API_KEY:clave-de-desarrollo}

logging:
  level:
    com.nexussoftware.bibliotech: DEBUG
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE   # ver los parámetros ligados: solo en dev

application-prod.yml — sin un solo valor por defecto en lo sensible:

spring:
  datasource:
    url: ${BIBLIOTECH_DB_URL}
    username: ${BIBLIOTECH_DB_USER}         # usuario con permisos mínimos, NO admin
    password: ${BIBLIOTECH_DB_PASSWORD}
    hikari:
      maximum-pool-size: 20
  jpa:
    show-sql: false

bibliotech:
  metadatos:
    api-key: ${METADATOS_API_KEY}           # sin defecto: si falta, no arranca

logging:
  level:
    root: WARN
    com.nexussoftware.bibliotech: INFO

Acciones adicionales, y este es el punto del ejercicio: corregir el fichero no es suficiente. La contraseña y la clave de API ya están en el historial de Git. El procedimiento correcto es, en este orden:

  1. Rotar ya la contraseña de la base de datos y la clave de API. Están comprometidas.
  2. Crear un usuario de base de datos con permisos mínimos (SELECT, INSERT, UPDATE, DELETE sobre el esquema de la aplicación; nunca DROP ni CREATE).
  3. Añadir gitleaks como enganche de pre-commit y como paso de CI.
  4. Limpiar el historial (git filter-repo) solo si el repositorio es privado y controlado, sabiendo que reescribe todos los hashes y obliga a que todo el equipo vuelva a clonar.
  5. Escribir un ADR sobre la gestión de secretos, para que la decisión quede documentada.

Se retoma en 12-07.

Conclusión

BiblioTech ha dejado de ser un montón de clases excelentes para convertirse en un proyecto.

Tiene una arquitectura explícita: capas con la regla de dependencia aplicada en serio y puertos donde hay tecnología externa. Sabes distinguir la arquitectura por capas de la hexagonal, conoces sus ventajas y sus costes reales, y —lo más importante— tienes el criterio para no aplicar hexagonal completa a un CRUD ni capas laxas a un sistema con reglas de negocio ricas. Y has interiorizado la idea que sostiene todo: la dirección de la dependencia y la dirección del flujo son cosas distintas, y por eso el dominio puede definir la interfaz que la infraestructura implementa.

Tiene una organización de paquetes con criterio: por funcionalidad en el primer nivel, por capa dentro, con la razón decisiva de que solo así puedes ocultar CalculadoraMultas detrás de la visibilidad de paquete. Los paquetes por capa se degradan porque obligan a hacer público todo.

Tiene cinco módulos Maven cuyo grafo de dependencias convierte la arquitectura en algo que el compilador verifica. Que bibliotech-dominio no tenga dependencias de producción no es un adorno: es la razón por la que nadie podrá meter una anotación de Spring en una regla de negocio, ni hoy ni dentro de dos años con prisa. Y si el multimódulo no es viable, tienes ArchUnit como red de seguridad.

Tiene DTOs: entidades hacia dentro, record hacia fuera, con las seis razones concretas por las que exponer una entidad JPA en una API acaba mal, y con la asimetría de los DTOs de entrada como defensa estructural frente a la manipulación de campos.

Tiene configuración por entorno con perfiles, la tabla de precedencia de fuentes de Spring Boot, la relajación de nombres que hace posible configurarlo todo por variables de entorno, y la regla de que en producción lo sensible no lleva valor por defecto: si falta, la aplicación no arranca. Y tiene la advertencia que más incidentes evita: los secretos, fuera del repositorio, siempre.

Y tiene la higiene que separa un proyecto profesional de uno amateur: un .gitignore que cubre lo que importa, ramas cortas, commits convencionales que permiten generar el registro de cambios, .editorconfig y Spotless para no discutir nunca más sobre llaves, ADRs que responden al «¿por qué está esto así?» dentro de dos años, un compose.yaml que levanta las dependencias con un comando, y un README que cumple el único criterio que importa: que alguien nuevo arranque el proyecto en diez minutos.

Queda una deuda que este módulo abrió y no cerró: los patrones. Al reestructurar han vuelto a aparecer, ahora casi todos a la vez. RepositorioPrestamos con su implementación JPA es el patrón Repositorio y también Adaptador. PasarelaMetadatos es un Puerto. ReglaTarifa es Estrategia. FichaPrestamo.desde(...) es un Método de fábrica. La inyección por constructor es Inversión de dependencias. Llevas once módulos encontrándote estas estructuras, nombrándolas de pasada y aplazándolas.

Se acabó el aplazamiento. La siguiente lección las formaliza todas: qué problema resuelve cada patrón, cómo se implementa en BiblioTech, y —tan importante como lo anterior— cuándo no usarlo.

Curso de Programación en Java

Módulo 1: Introducción a Java

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Programación Orientada a Objetos Avanzada

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

Módulo 11: Frameworks y Librerías de Java

Módulo 12: Construcción de Aplicaciones del Mundo Real

© Copyright 2026. Todos los derechos reservados