«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
- El problema: qué le pasa a un proyecto sin estructura
- Qué es la arquitectura de una aplicación
- Arquitectura por capas
- La regla de dependencia
- Arquitectura hexagonal: puertos y adaptadores
- Comparativa: capas frente a hexagonal
- Organización de paquetes: por capa frente a por funcionalidad
- El árbol de paquetes de BiblioTech, en las dos opciones
- Recomendación justificada
- Proyecto multimódulo Maven aplicado a BiblioTech
- El POM padre y
dependencyManagement - Cómo el grafo de módulos impide que el dominio importe Spring
- DTOs frente a entidades
- Configuración por entorno:
application.ymly perfiles - La jerarquía de fuentes de configuración de Spring Boot
- Variables de entorno y secretos fuera del repositorio
- Control de versiones:
.gitignore, ramas y commits convencionales - Formato y estilo:
.editorconfigy Spotless - Un
README.mdque sirva de verdad - Registro de decisiones de arquitectura (ADR)
- Scripts de arranque y dependencias locales
- El árbol completo de BiblioTech reestructurado
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- 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: servicio → web → servicio |
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.
- 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:
- ¿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)
- ¿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.
- 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.
- 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....
- 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.
- 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.
- 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.
Por funcionalidad (feature-first, o package by feature): el primer nivel es el área de negocio.
| 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.
- 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.javaOpció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)
- 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:
- El proyecto va a crecer. El módulo 12 le añade CLI, web, seguridad y observabilidad. Por capa, el paquete
servicioacabaría con veinte clases sin relación entre sí. - Permite ocultar.
CalculadoraMultases un detalle de cómo funcionan los préstamos; nadie más debería poder llamarla. Solo el paquete por funcionalidad lo hace cumplir. - 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». - Los cambios son locales. Añadir «renovación de préstamo» toca
prestamos/y nada más. - Hace visible el acoplamiento. Si
reservasnecesita cinco clases deprestamos, losimportlo 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.
- 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}/...
- El POM padre y
dependencyManagement
dependencyManagementEl 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
- 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 FAILURENo 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-dominioEste 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.
- 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 | Prestamo → Empleado → List<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.
- Configuración por entorno:
application.yml y perfiles
application.yml y perfilesBiblioTech 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: INFOPerfil 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: DEBUGPerfil 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: INFOQue ${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
- 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-diase puede fijar con la variable de entornoBIBLIOTECH_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».
- 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-filesEn 12-07 se retoma este tema con la gestión completa: rotación, cifrado en reposo y qué hacer cuando ya ha pasado.
- Control de versiones:
.gitignore, ramas y commits convencionales
.gitignore, ramas y commits convencionalesEl .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
*.pemLas 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.
- Formato y estilo:
.editorconfig y Spotless
.editorconfig y SpotlessDiscutir 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 = 2Spotless — 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 CIUn 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
- Un
README.md que sirva de verdad
README.md que sirva de verdadCasi 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
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).
- 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.
- 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 datosY 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=devDocker 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).
- 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:testNi 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: DEBUGSoluciones
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: INFOapplication-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 devapplication-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: INFOAcciones 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:
- Rotar ya la contraseña de la base de datos y la clave de API. Están comprometidas.
- Crear un usuario de base de datos con permisos mínimos (
SELECT,INSERT,UPDATE,DELETEsobre el esquema de la aplicación; nuncaDROPniCREATE). - Añadir
gitleakscomo enganche de pre-commit y como paso de CI. - 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. - 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
- Introducción a Java
- Configuración del Entorno de Desarrollo
- Sintaxis y Estructura Básica
- Variables y Tipos de Datos
- Operadores
- Entrada y Salida por Consola
- Tu Primer Programa Completo: BiblioTech
Módulo 2: Flujo de Control
- Sentencias Condicionales
- Bucles
- Sentencias Switch
- Break y Continue
- Depuración y Trazas de Ejecución
- Proyecto: Menú Interactivo de BiblioTech
Módulo 3: Programación Orientada a Objetos
- Introducción a la POO
- Clases y Objetos
- Métodos
- Constructores
- Herencia
- Polimorfismo
- Encapsulamiento
- Abstracción
- La Clase Object: equals, hashCode y toString
Módulo 4: Programación Orientada a Objetos Avanzada
- Interfaces
- Clases Abstractas
- Clases Internas
- Clases Anónimas
- Expresiones Lambda
- Interfaces Funcionales y Referencias a Métodos
- Enumeraciones y Registros
Módulo 5: Estructuras de Datos y Colecciones
- Arreglos
- El Framework de Colecciones
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cola y Deque
- Pila
- Ordenación y Búsqueda en Colecciones
Módulo 6: Manejo de Excepciones
- Introducción a las Excepciones
- Bloque Try-Catch
- Throw y Throws
- Excepciones Personalizadas
- Bloque Finally
- Try-with-resources y AutoCloseable
- Estrategias de Manejo de Errores y Logging
Módulo 7: Entrada/Salida de Archivos
- Lectura de Archivos
- Escritura de Archivos
- Flujos de Archivos
- BufferedReader y BufferedWriter
- Serialización
- La API NIO.2: Path y Files
- Formatos de Intercambio: CSV y Properties
Módulo 8: Multihilo y Concurrencia
- Introducción al Multihilo
- Creación de Hilos
- Ciclo de Vida de un Hilo
- Sincronización
- Utilidades de Concurrencia
- Colecciones Concurrentes y Variables Atómicas
- Tareas Asíncronas con CompletableFuture
Módulo 9: Redes
- Introducción a las Redes
- Sockets
- ServerSocket
- DatagramSocket y DatagramPacket
- URL y HttpURLConnection
- El Cliente HTTP Moderno
Módulo 10: Temas Avanzados
- Genéricos
- Anotaciones
- Reflexión
- Características de Java 8: Streams y Optional
- Fechas y Horas con java.time
- Java 9 y Más Allá
- Memoria, Recolección de Basura y Rendimiento
Módulo 11: Frameworks y Librerías de Java
- Introducción a los Frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Pruebas Avanzadas con Mockito
- Librerías Esenciales del Ecosistema
