Las dos lecciones anteriores respondieron a «¿está bien hecha?» desde dos ángulos: qué conviene hacer y qué conviene evitar. Falta el tercero, que no produce ningún error y sin embargo decide el futuro del proyecto: cómo se lee.

Un AlquilerService puede cumplir las treinta y ocho comprobaciones de 10-01, no contener ni uno de los errores de 10-02 y ser aun así un método de doscientas líneas con tres banderas booleanas, variables llamadas data y temp, comentarios que repiten lo que el código ya dice y un dominio que es una bolsa de setters. Nada de eso falla hoy. Lo que falla es dentro de seis meses, cuando el ayuntamiento de Ribalta pida tarifas dinámicas y nadie se atreva a tocar ese método.

Esta lección trata de eso, con un criterio único que atraviesa todos los apartados y que conviene fijar antes de empezar: el destinatario del código no es el compilador, es el próximo que lo lea. Y ese próximo, con frecuencia, eres tú dentro de un año, sin acordarte de nada.

Contenido

  1. Qué significa código limpio aquí
  2. Nombres
  3. Funciones y clases
  4. Una refactorización completa: AlquilerService
  5. SOLID en Spring, con ejemplos de Ribalta
  6. Comentarios
  7. Manejo de errores
  8. Inmutabilidad
  9. Optional bien usado
  10. El modelo de dominio anémico
  11. Arquitectura verificable con ArchUnit
  12. Estilo y automatización
  13. Refactorización segura
  14. Deuda técnica
  15. Errores Comunes y Consejos
  16. Ejercicios

  1. Qué significa código limpio aquí

«Código limpio» suele confundirse con «código bonito», y no es lo mismo. Un criterio operativo, que se puede aplicar sin discutir de gustos:

Propiedad Pregunta que responde Cómo se comprueba
Legible ¿Se entiende qué hace sin ejecutarlo? Alguien ajeno lo lee y lo explica
Localizable ¿Sé dónde tocar para cambiar X? Se busca el sitio de un cambio hipotético
Modificable ¿Cambiar una cosa obliga a cambiar cinco? Se cuenta el radio de un cambio real
Comprobable ¿Puedo escribir una prueba sin acrobacias? Se intenta escribirla

La cuarta es la más objetiva y la que más información da. Si para probar un método hay que simular métodos estáticos, instanciar seis colaboradores o manipular el reloj del sistema, el problema no es la prueba: es el diseño. Fue la razón de inyectar un Clock desde 02-01 y de que SeguridadAlquileres sea un bean normal en lugar de una expresión SpEL de tres líneas. Y una advertencia sobre el alcance: nada de esta lección es cuestión de estilo personal —la indentación, las llaves y la longitud de línea se resuelven con una herramienta en el apartado 12 y no se discuten en las revisiones—. Lo que sigue es diseño.

  1. Nombres

Nombrar es la actividad más frecuente al programar y la que más rendimiento da por minuto invertido.

2.1. La convención del curso

CicloUrbana tiene una regla explícita que conviene enunciar porque no es universal: el dominio se nombra en español —Estacion, Bicicleta, Alquiler, calcularImporte, bicicletasDisponibles— y solo se conserva el inglés en lo que pertenece al framework o a una librería: anotaciones, tipos de Spring, findById, Pageable. El motivo no es patriótico, es de lenguaje ubicuo: el ayuntamiento de Ribalta habla de estaciones, anclajes y tarifas, y cuando el código usa esas mismas palabras, la traducción mental entre la conversación y el programa desaparece. Lo que hay que evitar a toda costa es el híbrido: EstacionService.getEstacionByNombre() obliga a cambiar de idioma dos veces en una línea.

Elemento Convención Ejemplo de CicloUrbana
Clase Sustantivo, PascalCase AlquilerService, SelectorTarifa
Interfaz Sustantivo o capacidad, sin prefijo I CalculadoraTarifa, ValidacionAlquiler
Método Verbo en infinitivo calcular, buscarConDisponibilidad, esPropietario
Booleano es..., tiene..., puede... estaLlena(), puedeAlquilarse()
Variable y constante Sustantivo concreto; MAYUSCULAS_CON_GUION anclajesLibres, MINUTOS_GRATIS
Paquete Minúsculas, funcionalidad com.ciclourbana.alquileres
Prueba Frase que describe la regla conQuinceMinutosExactosElAlquilerSigueSiendoGratuito

2.2. Nombres que revelan intención

La diferencia entre un nombre y un buen nombre es si obliga a leer la implementación.

// ❌ Hay que leer el cuerpo para saber qué devuelve
public List<Estacion> getData(int x) { ... }
public List<Estacion> proceso2(boolean b) { ... }

// ✅ El nombre es la documentación
public List<Estacion> buscarConDisponibilidad(int bicicletasMinimas) { ... }
public List<Estacion> buscarOperativasEnHoraPunta() { ... }

getData() falla en tres frentes: no dice qué datos, no dice de dónde salen y no dice según qué criterio se filtran. buscarConDisponibilidad(int bicicletasMinimas) responde a los tres, y además el nombre del parámetro convierte el número de la llamada en información: buscarConDisponibilidad(3) se lee «estaciones con al menos tres bicicletas».

Cuatro reglas prácticas: nada de abreviaturas salvo las universalmente conocidas (id, url, http); nada de números en los nombres, porque proceso1 y proceso2 significan que no sabes qué los distingue; el nombre indica el tipo de retorno —lo que empieza por es o puede devuelve boolean, lo que empieza por buscar puede no encontrar y devuelve Optional o lista—; y el mismo concepto, siempre la misma palabra: si es buscar, no es obtener en la clase de al lado.

  1. Funciones y clases

3.1. Un solo nivel de abstracción

La regla más útil sobre el tamaño de una función no es «menos de veinte líneas», sino: todas las sentencias de un método deben estar al mismo nivel de detalle.

// ❌ Mezcla "qué se hace" con "cómo se hace"
public AlquilerResponse iniciar(IniciarAlquilerRequest peticion) {
    Bicicleta bicicleta = bicicletaRepositorio.bloquearMejorDisponible(
            peticion.estacionOrigenId(), red.umbralBateria())
            .orElseThrow(() -> new BicicletaNoDisponibleException(peticion.estacionOrigenId()));
    if (alquilerRepositorio.countByUsuarioIdAndFinIsNull(peticion.usuarioId()) >= 1) {
        throw new ReglaNegocioException("ALQUILER_EN_CURSO", "Ya tienes un alquiler abierto");
    }
    bicicleta.setEstado(EstadoBicicleta.EN_USO);
    // ... veinte líneas más al mismo nivel de detalle
}

// ✅ El método público cuenta la historia; los privados la detallan
public AlquilerResponse iniciar(IniciarAlquilerRequest peticion) {
    validarQueNoTieneAlquilerAbierto(peticion.usuarioId());
    Bicicleta bicicleta = reservarMejorBicicleta(peticion.estacionOrigenId());
    Alquiler alquiler = registrarAlquiler(peticion, bicicleta);
    eventos.publishEvent(new AlquilerIniciado(alquiler.getId(), instanteActual()));
    return alquilerMapper.aRespuesta(alquiler);
}

El método público se lee como un párrafo y se entiende en cinco segundos. Quien necesite el detalle baja un nivel; quien solo quiera saber qué ocurre, no baja.

3.2. Argumentos, y por qué las banderas booleanas son un problema

Cero, uno o dos argumentos es lo ideal; tres es aceptable si pertenecen al mismo concepto; con cuatro o más casi siempre falta un objeto que los agrupe.

Una bandera booleana es un argumento que hace que el método haga dos cosas distintas, y tiene tres defectos: en la llamada no se lee nada (finalizar(9L, true) no dice qué es true), obliga a un if dentro que separa dos flujos que ya son dos métodos, y crece: mañana hay dos banderas y cuatro combinaciones, de las cuales dos no tienen sentido.

// ❌ ¿Qué significa ese true en la llamada?
public AlquilerResponse finalizar(Long id, boolean aplicarPenalizacion) { ... }
finalizar(9L, true);

// ✅ Dos métodos con nombre
public AlquilerResponse finalizar(Long id) { ... }
public AlquilerResponse finalizarPorCaducidad(Long id) { ... }   // aplica la penalización

3.3. Responsabilidad única, aplicada de verdad

«Una clase, una responsabilidad» se cita mucho y se aplica mal, porque «responsabilidad» es vago. La formulación operativa es mejor: una clase debe tener una sola razón para cambiar, es decir, un solo interlocutor que pueda pedir modificarla. Si AlquilerService cambia cuando el ayuntamiento revisa las tarifas, cuando facturación cambia el formato de la factura y cuando el proveedor de correo cambia su API, tiene tres razones para cambiar y tres personas distintas pidiéndolas. El apartado siguiente lo arregla.

  1. Una refactorización completa: AlquilerService

Partimos de una versión real y verosímil de AlquilerService.finalizar: funciona, pasa las pruebas y hace demasiado.

// ❌ Antes: 5 responsabilidades en un método
@Transactional
public AlquilerResponse finalizar(Long id, FinalizarAlquilerRequest peticion) {
    Alquiler alquiler = alquilerRepositorio.findById(id).orElseThrow();
    if (alquiler.getFin() != null) throw new ConflictoRecursoException("Ya finalizó");
    Estacion destino = estacionRepositorio.findById(peticion.estacionDestinoId()).orElseThrow();
    if (bicicletaRepositorio.countByEstacionId(destino.getId()) >= destino.getCapacidad()) {
        throw new EstacionLlenaException(destino.getId());
    }

    // Cálculo del importe, reimplementado a mano dentro del servicio
    long minutos = Duration.between(alquiler.getInicio(), Instant.now()).toMinutes();
    BigDecimal importe;
    if (alquiler.getUsuario().getTipoTarifa() == TipoTarifa.ESTUDIANTE) {
        importe = new BigDecimal("0.08").multiply(BigDecimal.valueOf(Math.max(0, minutos - 15)));
    } else if (alquiler.getUsuario().getTipoTarifa() == TipoTarifa.JUBILADO) {
        importe = new BigDecimal("0.05").multiply(BigDecimal.valueOf(Math.max(0, minutos - 30)));
    } else {
        importe = new BigDecimal("0.50")
                .add(new BigDecimal("0.12").multiply(BigDecimal.valueOf(Math.max(1, minutos))));
    }
    importe = importe.setScale(2, RoundingMode.HALF_UP);
    if (minutos > 120) importe = importe.add(new BigDecimal("5.00"));   // recargo por exceso

    alquiler.setFin(Instant.now());                    // mutación a base de setters
    alquiler.setEstacionDestino(destino);
    alquiler.setImporte(importe);
    alquiler.setEstado(EstadoAlquiler.FINALIZADO);
    alquiler.getBicicleta().setEstado(EstadoBicicleta.DISPONIBLE);
    alquiler.getBicicleta().setEstacion(destino);

    correo.enviarResumen(alquiler.getUsuario().getCorreo(), importe);   // dentro de la transacción
    metricas.registrarFinalizacion(alquiler);
    return new AlquilerResponse(alquiler.getId(), /* ... 8 campos más ... */);
}

Las cinco responsabilidades son cinco razones distintas para cambiar esta clase, y cada una tiene un interlocutor diferente: validar las reglas de finalización (el ayuntamiento), calcular el importe (el departamento de tarifas), aplicar el recargo por exceso (el ayuntamiento, en otra reunión), mutar el estado del alquiler y la bicicleta (el equipo de dominio) y notificar y medir (marketing y operaciones).

Paso 1: extraer el cálculo de tarifa a donde ya existía

El if/else if/else reimplementa lo que SelectorTarifa y las tres CalculadoraTarifa de 02-02 ya hacían. Es duplicación pura, y peor: una segunda fuente de verdad que puede divergir de la primera. Las quince líneas se convierten en selectorTarifa.paraUsuario(alquiler.getUsuario()).calcular(duracion).

Paso 2: el recargo es una tarifa, no un if

El if (minutos > 120) es una regla de negocio escondida en un servicio. Como el sistema de tarifas ya es extensible por diseño, el recargo encaja como decorador:

package com.ciclourbana.alquileres;

/** Recargo por exceso de duración sobre cualquier tarifa base. Decorador: solo conoce
 *  el contrato, no la tarifa concreta. */
public record TarifaConRecargoPorExceso(CalculadoraTarifa base, Duration duracionMaxima,
                                        BigDecimal recargo) implements CalculadoraTarifa {
    @Override
    public BigDecimal calcular(Duration duracion) {
        BigDecimal importe = base.calcular(duracion);
        return duracion.compareTo(duracionMaxima) > 0 ? importe.add(recargo) : importe;
    }

    @Override
    public String nombre() { return base.nombre() + "-con-recargo"; }
}

Ahora el recargo se prueba solo, se combina con cualquier tarifa presente o futura y se configura por propiedades. AlquilerService deja de saber que existe.

Paso 3: el comportamiento, al dominio

Los seis setters consecutivos son el síntoma del modelo anémico del apartado 10. La operación «finalizar un alquiler» pertenece a Alquiler, que es quien conoce sus invariantes:

// En la entidad Alquiler:
/** Cierra el alquiler. Invariante: un alquiler finalizado no vuelve a finalizarse. */
public void finalizar(Estacion destino, Instant fin, BigDecimal importe) {
    if (this.estado == EstadoAlquiler.FINALIZADO) {
        throw new ConflictoRecursoException("El alquiler " + id + " ya finalizó");
    }
    this.fin = fin;
    this.estacionDestino = destino;
    this.importe = importe;
    this.estado = EstadoAlquiler.FINALIZADO;
    this.bicicleta.anclarEn(destino);          // la bicicleta gestiona su propio estado
}

Nótese lo que se gana: la comprobación de «ya finalizó» deja de depender de que el servicio se acuerde. Cualquier camino que llame a finalizar la aplica.

Paso 4: los efectos secundarios, fuera de la transacción

El correo y la métrica se sacan a un evento en AFTER_COMMIT, como ya establecimos en Transacciones y Tareas Programadas y Asincronía.

El resultado

// ✅ Después: una responsabilidad y cero reglas de negocio escondidas
@Transactional
public AlquilerResponse finalizar(Long id, FinalizarAlquilerRequest peticion) {
    Alquiler alquiler = buscarEnCurso(id);
    Estacion destino = buscarEstacionConHueco(peticion.estacionDestinoId());

    Instant fin = Instant.now(reloj);
    BigDecimal importe = selectorTarifa.paraUsuario(alquiler.getUsuario())
            .calcular(Duration.between(alquiler.getInicio(), fin));

    alquiler.finalizar(destino, fin, importe);     // el dominio protege sus reglas

    eventos.publishEvent(new AlquilerFinalizado(alquiler.getId(), importe, fin));
    return alquilerMapper.aRespuesta(alquiler);
}
Antes Después
Líneas del método / razones para cambiar 38 / 5 11 / 1
Reglas de negocio en el servicio 3 0
Prueba del recargo Con contexto y datos Unitaria de 1 ms
Añadir una tarifa nueva Tocar este método Una clase nueva

Y la condición sin la cual nada de esto se hace: las pruebas del módulo 6 estaban en verde antes de empezar y siguieron en verde después de cada paso. Sin ellas, esta refactorización es una reescritura a ciegas.

  1. SOLID en Spring, con ejemplos de Ribalta

Principio Qué dice Dónde se ve en CicloUrbana
SRP — responsabilidad única Una sola razón para cambiar La refactorización del apartado 4; SeguridadAlquileres separado de AlquilerService
OCP — abierto/cerrado Abierto a extensión, cerrado a modificación CalculadoraTarifa: añadir TarifaJubilado no toca SelectorTarifa, que las inyecta en una List
LSP — sustitución de Liskov Un subtipo debe poder sustituir al tipo base sin sorpresas Cualquier CalculadoraTarifa debe devolver un importe no negativo; una que lanzara excepción con duración cero rompería a todos sus consumidores
ISP — segregación de interfaces Mejor varias interfaces pequeñas que una grande Los repositorios: EstacionRepositorio expone lo de estaciones y nada más; y Pageable/Sort en lugar de un método con ocho parámetros
DIP — inversión de dependencias Depender de abstracciones, no de implementaciones Es literalmente lo que hace el contenedor: AlquilerService declara CalculadoraTarifa y Spring decide cuál inyectar

Dos malentendidos que conviene despejar. DIP no significa «una interfaz por clase»: significa que la dependencia apunta hacia la abstracción cuando hay una frontera real. AlquilerService depende de CalculadoraTarifa porque hay tres implementaciones y habrá más; una IAlquilerService con una sola implementación es ceremonia sin beneficio. Y LSP es el más ignorado y el que produce fallos más raros: su incumplimiento típico no es de herencia, sino de contrato —una implementación que devuelve null donde las demás devuelven lista vacía, o que lanza excepción donde las demás devuelven cero—, y el consumidor, escrito contra el comportamiento de la primera, se rompe con la segunda.

  1. Comentarios

Un comentario es una deuda de mantenimiento: no lo comprueba el compilador, no lo ejecuta ninguna prueba y envejece en silencio. Por eso el criterio es exigente.

Comentario ¿Merece la pena?
Repite lo que el código dice No. // incrementa i sobre i++
Explica qué hace un método largo No. Extrae un método con nombre
Explica por qué se tomó una decisión no obvia Sí. El más valioso
Documenta una restricción externa Sí. «El proveedor limita a 100 peticiones/min»
Advierte de una trampa Sí. «No convertir en private: se pierde el proxy»
Código comentado, o // TODO sin fecha ni responsable No. Para eso está el control de versiones y el gestor de tareas
// ❌ Ruido: dice lo que ya se lee
// Comprobamos si la estación está llena
if (bicicletas.size() >= estacion.getCapacidad()) { ... }

// ✅ Explica lo que el código no puede decir
// El ayuntamiento exige dejar siempre un anclaje libre para el furgón de
// mantenimiento (convenio de 2026), de ahí el -1 y no la capacidad completa.
if (bicicletas.size() >= estacion.getCapacidad() - 1) { ... }

El javadoc que merece la pena es el de las fronteras: interfaces públicas, contratos de dominio y cualquier método cuyo comportamiento no sea evidente en los casos límite —CalculadoraTarifa.calcular documenta qué ocurre con duración cero, porque eso no está en la firma—. En cambio, un /** Devuelve el nombre. @return el nombre */ sobre getNombre() solo añade líneas.

  1. Manejo de errores

Excepciones específicas del dominio, no genéricas. throw new RuntimeException("Error") obliga a quien la captura a leer el mensaje para saber qué pasó. La jerarquía de 03-06 —CicloUrbanaException con RecursoNoEncontradoException, ConflictoRecursoException, EstacionLlenaException y BicicletaNoDisponibleException— permite que el @RestControllerAdvice traduzca cada una a su código HTTP sin un solo if sobre cadenas.

No uses excepciones para el flujo normal. «No hay bicicletas disponibles» en una estación concurrida no es excepcional: pasa cien veces al día. Una excepción cuesta construir la traza de pila y, sobre todo, comunica algo equivocado al lector; si el caso es esperable, devuelve Optional o un tipo resultado. Y no captures Exception: atrapa también lo que no sabes manejar y lo convierte en un log. Captura lo que puedes tratar, y deja subir lo demás.

// ❌ Traga todo, incluidos los fallos que deberían llegar al manejador global
try { ... } catch (Exception e) { log.error("Error", e); }

// ✅ Trata lo previsto, relanza como excepción del dominio
try {
    pasarelaPago.cobrar(importe);
} catch (PagoRechazadoException e) {
    throw new ReglaNegocioException("PAGO_RECHAZADO", "El pago ha sido rechazado", e);
}

Mensajes útiles. "Error" no sirve para nada; "La estación 3 está llena: 18 bicicletas para 18 anclajes" dice qué pasó, con qué datos, y permite reproducirlo. Con una condición que no se puede olvidar: el mensaje que va al cliente y el que va al log no son el mismo. Al log, el detalle; al cliente, la versión sin información interna.

  1. Inmutabilidad

Un objeto inmutable no puede estar en un estado inválido después de construirse, es seguro entre hilos sin razonar nada y no puede cambiar entre el momento en que se valida y el momento en que se usa —una fuente real de vulnerabilidades—.

record para DTOs y objetos de valor. Todos los DTOs de CicloUrbana lo son, con el constructor compacto como sitio natural para normalizar; y también los objetos de valor del dominio, como UbicacionResponse. Colecciones inmutables en los retornos, porque devolver la lista interna permite que el llamante la modifique por la espalda:

// ❌ El llamante puede hacer estacion.getBicicletas().clear()
public List<Bicicleta> getBicicletas() { return bicicletas; }

// ✅ Vista de solo lectura; las modificaciones pasan por métodos con nombre
public List<Bicicleta> getBicicletas() { return List.copyOf(bicicletas); }
public void anclar(Bicicleta bicicleta) { /* valida capacidad y añade */ }

Y el límite honesto: las entidades JPA no pueden ser inmutables. Hibernate necesita un constructor sin argumentos y modifica el estado al cargar y al aplicar cambios. La respuesta no es forzar la inmutabilidad, sino controlar la mutación: campos privados, sin setters públicos indiscriminados, y métodos con nombre de negocio —finalizar, anclarEn, marcarAveriada— que son los únicos que cambian el estado y que pueden proteger los invariantes.

  1. Optional bien usado

Optional se introdujo para un caso concreto —el retorno de un método que puede no encontrar nada— y se usa con frecuencia para tres más, donde estorba.

Uso ¿Correcto? Por qué
Tipo de retorno Sí Es su propósito: obliga al llamante a decidir qué hace si no hay valor
Parámetro de método No El llamante tiene que envolver; y hay tres estados posibles: null, vacío y presente
Campo de una clase No No es serializable, ocupa memoria extra y complica el enlace de Jackson y JPA
Colección vacía No Una lista vacía ya expresa «no hay nada»; Optional<List<T>> es redundante

Y el antipatrón más frecuente de todos: .get() sin comprobar, que convierte un caso previsto en una NoSuchElementException con 500 y traza en el log.

// ❌ Un 500 en lugar de un 404
Estacion estacion = estacionRepositorio.findById(id).get();

// ✅ La ausencia se traduce a una excepción del dominio
Estacion estacion = estacionRepositorio.findById(id)
        .orElseThrow(() -> new RecursoNoEncontradoException("Estación", id));

orElseThrow, orElseGet, map, filter e ifPresent cubren prácticamente todo. Un detalle que se pasa por alto: orElse evalúa siempre su argumento, incluso cuando hay valor, así que si construir la alternativa es caro debe ser orElseGet con un proveedor perezoso.

  1. El modelo de dominio anémico

Un modelo anémico es aquel en el que las entidades solo tienen datos —campos, getters y setters— y toda la lógica vive en los servicios. Es el estilo por defecto de la mayoría de los proyectos Spring, y merece una discusión honesta porque no siempre está mal.

El caso a favor de poner comportamiento en el dominio. Compara las dos formas de finalizar un alquiler:

// ❌ El servicio manipula el estado con setters: los invariantes viven en el servicio
alquiler.setFin(fin);
alquiler.setEstacionDestino(destino);
alquiler.setImporte(importe);
alquiler.setEstado(EstadoAlquiler.FINALIZADO);

// ✅ La entidad protege sus propias reglas
alquiler.finalizar(destino, fin, importe);

Cuatro diferencias que importan. El invariante viaja con el dato: la comprobación de «ya finalizó» se aplica por cualquier camino, no solo por el que se acordó de escribirla. La entidad no puede quedar a medias: con setters públicos, un método puede fijar el importe y olvidar el estado, y nada lo impide. El nombre está en el lenguaje del ayuntamiento, no en el de la base de datos. Y se puede probar sin nada: Alquiler.finalizar es una prueba unitaria de un milisegundo.

El caso a favor del modelo anémico existe y no conviene despreciarlo:

Situación Por qué el anémico es aceptable
CRUD sin reglas Si «actualizar la dirección de una estación» es asignar un campo, envolverlo no añade nada
La lógica necesita colaboradores Calcular el importe requiere SelectorTarifa; una entidad JPA no debería inyectar servicios
La regla cruza varios agregados «El usuario no puede tener dos alquileres abiertos» no cabe dentro de Alquiler
Equipo sin experiencia en DDD Un dominio rico mal hecho —entidades con repositorios dentro— es peor que uno anémico

El criterio práctico de CicloUrbana, que es un punto intermedio defendible: las reglas que dependen solo del estado del propio agregado van en la entidad —finalizar, puedeAlquilarse, estaLlena, anclarEn—; las que necesitan colaboradores o cruzan agregados van en el servicio —seleccionar la tarifa, verificar que no hay otro alquiler abierto, comprobar permisos—. Con esa división, Alquiler no conoce ningún repositorio y AlquilerService no manipula estado a base de setters.

  1. Arquitectura verificable con ArchUnit

Las reglas de dependencia de 10-01 —el controlador no toca el repositorio, el dominio no importa el framework— son acuerdos que se incumplen tarde o temprano si dependen de que alguien lo note en una revisión. ArchUnit las convierte en pruebas.

Con la dependencia com.tngtech.archunit:archunit-junit5:1.3.0 en ámbito test, las reglas se escriben como campos estáticos anotados con @ArchTest:

package com.ciclourbana;

import com.tngtech.archunit.junit.*;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;

@AnalyzeClasses(packages = "com.ciclourbana",
                importOptions = ImportOption.DoNotIncludeTests.class)
class ReglasArquitecturaTest {

    /** Regla 1: los controladores hablan con servicios, nunca con repositorios. */
    @ArchTest
    static final ArchRule losControladoresNoAccedenARepositorios =
            noClasses().that().haveSimpleNameEndingWith("Controller")
                    .should().dependOnClassesThat().haveSimpleNameEndingWith("Repositorio")
                    .because("el controlador traduce HTTP; las reglas viven en el servicio");

    /** Regla 2: las entidades no salen del paquete de su agregado. */
    @ArchTest
    static final ArchRule lasEntidadesNoSalenDeSuPaquete =
            classes().that().areAnnotatedWith(jakarta.persistence.Entity.class)
                    .should().onlyBeAccessed().byClassesThat()
                    .resideInAnyPackage("com.ciclourbana.(**)", "com.ciclourbana.comun..")
                    .because("el contrato público se expresa con DTOs, no con entidades");

    /** Regla 3: nada del dominio conoce la capa web. */
    @ArchTest
    static final ArchRule elDominioNoConoceElServlet =
            noClasses().that().resideInAPackage("..alquileres..")
                    .and().haveSimpleNameNotEndingWith("Controller")
                    .should().dependOnClassesThat().resideInAPackage("jakarta.servlet..")
                    .because("la lógica de alquileres no depende de que la entrada sea HTTP");

    /** Regla 4: sin ciclos entre los módulos de negocio. */
    @ArchTest
    static final ArchRule sinCiclosEntreModulos =
            slices().matching("com.ciclourbana.(*)..").should().beFreeOfCycles();

    /** Regla 5: la trampa del proxy de 10-02, convertida en prueba. */
    @ArchTest
    static final ArchRule lasAnotacionesDeProxyVanEnMetodosPublicos =
            methods().that().areAnnotatedWith(Transactional.class).should().bePublic()
                    .because("CGLIB no intercepta métodos no públicos");
}

Cinco reglas, un fichero, y a partir de ese momento ./mvnw test se pone en rojo el día que alguien las incumpla, con un mensaje que dice qué clase, qué dependencia y —gracias al because— por qué está prohibida. Es la diferencia entre una convención documentada y una convención que se cumple. Tres consejos para que la inversión no se vuelva en contra: empieza por tres o cuatro reglas, las que de verdad duelen si se rompen; escribe siempre el because, porque el mensaje de fallo es lo único que verá quien la incumpla dentro de un año; y si una regla necesita excepciones, decláralas explícitamente en lugar de borrar la regla entera.

  1. Estilo y automatización

El estilo —dónde van las llaves, cuántos espacios, el orden de los import, la longitud de línea— es la discusión con peor relación entre tiempo invertido y valor obtenido de toda la ingeniería de software. La solución no es acordar un estilo: es delegarlo en una herramienta.

<plugin>
    <groupId>com.diffplug.spotless</groupId>
    <artifactId>spotless-maven-plugin</artifactId>
    <version>2.44.0</version>
    <configuration>
        <java>
            <palantirJavaFormat/>            <!-- formateo determinista -->
            <removeUnusedImports/>
            <importOrder><order>java,javax,jakarta,org,com,</order></importOrder>
            <trimTrailingWhitespace/>
            <endWithNewline/>
        </java>
    </configuration>
    <!-- Enganchado a la fase validate: falla si algo no está formateado -->
    <executions><execution><phase>validate</phase>
        <goals><goal>check</goal></goals></execution></executions>
</plugin>

./mvnw spotless:apply formatea todo el proyecto y ./mvnw spotless:check falla si algo no lo está, lo que ejecuta la canalización de 08-05 en cada cambio.

Herramienta Qué comprueba Cuándo se ejecuta
Spotless Formato: espacios, import, saltos En validate, y con apply en local
Checkstyle Convenciones: nombres, tamaño de método, javadoc En CI
SpotBugs / Error Prone Errores probables: equals incoherente, comparaciones sospechosas En CI
SonarQube Análisis global, deuda, duplicación, cobertura Nocturno o por pull request
ArchUnit Reglas de arquitectura (apartado 11) Con las pruebas

Por qué el estilo no se discute en las revisiones. Un comentario que dice «faltan dos espacios» consume la atención que debería ir a la lógica, genera fricción y no aporta nada que una herramienta no haga gratis. Con Spotless en validate, el código llega ya formateado y la revisión se ocupa de lo que ninguna herramienta detecta: si el diseño es correcto, si el nombre revela la intención y si falta un caso límite. Y el orden importa al adoptarlo: primero se aplica el formateador a todo el proyecto en un commit dedicado, sin ningún cambio funcional, y solo después se activa la comprobación; mezclar reformateo y lógica produce diferencias irrevisables.

  1. Refactorización segura

Refactorizar es cambiar la estructura interna sin cambiar el comportamiento observable, y la definición contiene su propio requisito: para saber que el comportamiento no ha cambiado, hay que poder comprobarlo.

flowchart LR
    A["Pruebas en verde<br/>ANTES de tocar nada"] --> B["Un cambio pequeño<br/>y reversible"]
    B --> C["Pruebas en verde"]
    C -->|"Sí"| D["Commit"]
    C -->|"No"| E["Deshacer y<br/>hacerlo más pequeño"]
    D --> B

Las cuatro reglas, en orden de importancia:

  1. Si no hay pruebas, la primera tarea es escribirlas. No las de todo el sistema: las del comportamiento que vas a mover. Se llaman pruebas de caracterización y afirman lo que el código hace hoy, incluidas sus rarezas. Sin ellas no estás refactorizando, estás reescribiendo.
  2. Un paso cada vez, con las pruebas entre medias. La refactorización del apartado 4 fueron cuatro pasos, cada uno con la suite en verde. Si el paso 3 rompiera algo, sabrías exactamente cuál fue.
  3. No mezcles refactorización y funcionalidad en el mismo commit. Un cambio que mueve doscientas líneas y además arregla un fallo es irrevisable: nadie puede distinguir el movimiento del cambio.
  4. Confía en el IDE para lo mecánico. Renombrar, extraer método o clase, cambiar firma e introducir parámetro son transformaciones que el IDE hace sin equivocarse; hacerlas con buscar-y-reemplazar es donde aparecen los errores.

Cuándo refactorizar. La respuesta sostenible no es «reservamos un sprint»: es la regla del campamento, dejar el código un poco mejor de como lo encontraste cada vez que lo tocas por otro motivo. Un sprint de refactorización compite con funcionalidad y siempre pierde; una mejora de diez minutos dentro de una tarea que ya estabas haciendo, no compite con nada.

  1. Deuda técnica

La metáfora es exacta y por eso funciona: tomas prestado tiempo hoy y pagas intereses cada vez que tocas ese código. Como la financiera, hay deuda razonable y deuda ruinosa.

Tipo Ejemplo en CicloUrbana ¿Aceptable?
Deliberada y prudente «Salimos con mapeo manual; migraremos a MapStruct al llegar a 20 DTOs» Sí, si está registrada
Deliberada e imprudente «No hay tiempo para pruebas» No: los intereses son inmediatos
Accidental y prudente «Ahora entendemos que el mapeador no debería consultar repositorios» Inevitable y sana: es aprendizaje
Accidental e imprudente Nadie sabía que @Transactional no funciona en autoinvocación Formación, revisión y las reglas del apartado 11

Cómo se reconoce. Cuatro señales: una estimación que crece sin que el requisito crezca; un fichero que aparece en el 80 % de los commits; una parte del código que «solo toca fulano»; y la frase «no lo toques, que funciona», reconocimiento explícito de que nadie lo entiende.

Cómo se registra. Un // TODO sin fecha ni responsable es decoración: a los seis meses hay ciento cuarenta y nadie los lee. Lo que sí funciona:

// DEUDA-2026-03: el mapeador consulta repositorios y provoca N+1 en listados.
// Impacto: p95 de GET /api/v1/alquileres. Salida: que el servicio devuelva
// un agregado con los nombres ya resueltos. Estimado: 1 día. Ficha: CU-412.

La diferencia con un TODO es que tiene destinatario, coste e impacto, que es la información necesaria para priorizarla frente a una funcionalidad; y va acompañada de una ficha en el mismo sistema donde vive el resto del trabajo, porque una deuda que solo existe en el código nunca se planifica.

Cuándo se paga. Tres momentos, y ninguno es «cuando haya tiempo»: cuando vas a tocar esa zona por otro motivo —el interés se paga solo—; cuando bloquea algo que el ayuntamiento sí quiere; y cuando el coste de los intereses supera el de la amortización, lo que se detecta midiendo, no discutiendo. La deuda que no molesta a nadie puede quedarse: no todo lo mejorable merece ser mejorado.

Errores Comunes y Consejos

Confundir código limpio con código «elegante». Una cadena de cinco stream() anidados puede ser muy ingeniosa e ilegible. Si hay que leerla tres veces, un bucle con nombres claros es mejor código.

Refactorizar sin pruebas. Es reescribir con otro nombre. Si no hay pruebas, escríbelas antes: primero las de caracterización, después el cambio. Y aplicar SOLID como ritual —una interfaz por clase, un mapeador por DTO, una fábrica por servicio— no es diseño: es ceremonia. Cada abstracción tiene que estar pagando algo concreto.

Comentar lo que se puede nombrar. Si necesitas un comentario para explicar qué hace un bloque, casi siempre lo que necesitas es extraer ese bloque a un método con ese nombre. Y dejar código comentado «por si acaso» solo genera dudas sobre si debería estar activo: está en el control de versiones.

Confundir el modelo rico con meter repositorios en las entidades. Una entidad que inyecta un repositorio es peor que el modelo anémico: mezcla persistencia y dominio y hace imposible probarla aisladamente.

Consejo: la mejor prueba de legibilidad es leerlo en voz alta. Si alquiler.finalizar(destino, fin, importe) se lee como una frase del ayuntamiento y procesar(a, true, 2) no, ya tienes la respuesta sin discutir de estilo.

Consejo: escribe el código pensando en quien lo borrará. El código fácil de borrar —fronteras claras, pocas dependencias entrantes— es el mismo que es fácil de cambiar: si eliminar TarifaJubilado es borrar una clase, el diseño es bueno.

Consejo: la revisión de código se ocupa de lo que ninguna herramienta ve. Spotless mira el formato, ArchUnit las dependencias, SpotBugs los errores probables y JaCoCo la cobertura. Lo que queda para las personas es si el nombre revela la intención, si la abstracción es la correcta y si falta un caso límite. Eso es exactamente donde una revisión aporta valor.

Ejercicios

Ejercicio 1: limpiar un servicio de incidencias

Refactoriza esta clase aplicando lo visto: nombres, nivel de abstracción, banderas booleanas, Optional, manejo de errores y modelo de dominio. Justifica cada cambio.

@Service
public class IncService {

    @Transactional
    public Object proc(Long id, boolean cerrar, boolean notificar) throws Exception {
        Incidencia i = repo.findById(id).get();
        if (cerrar) {
            i.setEstado("CERRADA");
            i.setFechaCierre(new Date());
            if (i.getTipo().equals("BATERIA")) {
                i.getBicicleta().setEstado("DISPONIBLE");
                i.getBicicleta().setNivelBateria(100);
            }
        } else {
            i.setEstado("ABIERTA");
        }
        repo.save(i);
        if (notificar) {
            try { correo.enviar(i.getAutor().getCorreo(), "Incidencia " + id); }
            catch (Exception e) { }
        }
        return i;
    }
}

Ejercicio 2: reglas de ArchUnit para Ribalta

Escribe cuatro reglas de ArchUnit que protejan decisiones tomadas a lo largo del curso, distintas de las cinco del apartado 11. Para cada una, indica qué decisión protege, en qué lección se tomó y qué mensaje daría al fallar.

Ejercicio 3: ¿anémico o rico?

Para cada una de estas seis reglas de negocio de CicloUrbana, decide si debe vivir en la entidad o en el servicio, y justifícalo con el criterio del apartado 10: (1) una bicicleta con menos del 20 % de batería no puede alquilarse; (2) un usuario no puede tener dos alquileres abiertos a la vez; (3) una estación está llena cuando sus bicicletas ancladas igualan su capacidad; (4) el importe depende del tipo de tarifa del usuario; (5) un alquiler finalizado no puede volver a finalizarse; (6) solo un operario puede marcar una bicicleta como averiada.

Soluciones

Solución 1

@Service
@Transactional(readOnly = true)
public class IncidenciaService {   // constructor omitido: repositorio, eventos, mapper, reloj

    /** Cierra la incidencia y devuelve la bicicleta al servicio si procede. */
    @Transactional
    public IncidenciaResponse cerrar(Long idIncidencia, String resolucion) {
        Incidencia incidencia = buscar(idIncidencia);
        incidencia.cerrar(resolucion, Instant.now(reloj));   // el dominio decide
        eventos.publishEvent(new IncidenciaCerrada(idIncidencia,
                incidencia.getAutor().getCorreo()));
        return incidenciaMapper.aRespuesta(incidencia);
    }

    /** Reabre una incidencia cerrada por error. Método aparte, no una bandera. */
    @Transactional
    public IncidenciaResponse reabrir(Long idIncidencia, String motivo) { ... }

    private Incidencia buscar(Long id) {
        return incidenciaRepositorio.findById(id)
                .orElseThrow(() -> new RecursoNoEncontradoException("Incidencia", id));
    }
}

// En la entidad Incidencia:
/** Cierra la incidencia. Si era de batería, la bicicleta vuelve al servicio. */
public void cerrar(String resolucion, Instant momento) {
    if (this.estado == EstadoIncidencia.CERRADA) {
        throw new ConflictoRecursoException("La incidencia " + id + " ya estaba cerrada");
    }
    this.estado = EstadoIncidencia.CERRADA;
    this.fechaCierre = momento;
    this.resolucion = resolucion;
    if (this instanceof IncidenciaBateria) {
        bicicleta.devolverAlServicio();          // la bicicleta gestiona su estado
    }
}

Los diez cambios, justificados.

# Cambio Motivo
1 IncService → IncidenciaService, proc → cerrar/reabrir Un nombre abreviado no ahorra nada y cuesta una consulta al lector
2 Las dos banderas booleanas pasan a ser dos métodos proc(9L, true, false) no se lee; y de las cuatro combinaciones, dos no tenían sentido
3 Object → IncidenciaResponse Object renuncia al tipado; devolver la entidad sería la fuga de 03-05
4 throws Exception desaparece; .get() → orElseThrow La jerarquía del dominio hereda de RuntimeException y provoca rollback; y la ausencia da un 404, no un 500
5 String → enum en estado y tipo equals("BATERIA") falla en silencio con un error tipográfico; el enum no compila
6 new Date() → Instant.now(reloj) Determinismo: la prueba controla el tiempo con Clock.fixed
7 repo.save(i) desaparece La entidad está gestionada; el dirty checking genera el UPDATE
8 El correo pasa a un evento en AFTER_COMMIT No retiene la conexión durante la llamada de red y no anuncia lo que puede deshacerse
9 El catch (Exception e) { } vacío desaparece Es el peor fragmento de la clase: descarta el fallo sin dejar rastro

Y el cambio de fondo: la lógica de cierre se ha movido a Incidencia, donde el invariante «no se cierra dos veces» se aplica por cualquier camino.

Solución 2

/** Decisión (03-05, 04-07): el servicio devuelve DTOs, nunca entidades. */
@ArchTest
static final ArchRule losServiciosNoDevuelvenEntidades =
        noMethods().that().areDeclaredInClassesThat().haveSimpleNameEndingWith("Service")
                .and().arePublic()
                .should().haveRawReturnType(describe("una entidad JPA",
                        c -> c.isAnnotatedWith(jakarta.persistence.Entity.class)))
                .because("con open-in-view: false provoca LazyInitializationException");

/** Decisión (03-05): los DTOs son record inmutables. */
@ArchTest
static final ArchRule losDtosSonRecord =
        classes().that().resideInAPackage("..dto..").should().beRecords()
                .because("un DTO mutable puede cambiar entre validarse y usarse");

/** Decisión (09-05): el log lo gobierna Logback, nunca System.out. */
@ArchTest
static final ArchRule sinSalidaEstandarDirecta =
        noClasses().should().accessField(System.class, "out")
                .because("un println no lleva traceId, ni nivel, ni formato JSON");

/** Decisión (02-02): inyección por constructor con campos final. */
@ArchTest
static final ArchRule sinInyeccionPorCampo =
        noFields().should().beAnnotatedWith(Autowired.class)
                .because("impide final y obliga a reflexión para construir en una prueba");

Por qué estas cuatro y no otras: las cuatro protegen decisiones que se incumplen por descuido y no producen ningún error inmediato. Una regla de ArchUnit sobre algo que ya falla al compilar no aporta nada; su valor está exactamente en los acuerdos silenciosos. Y el because no es decorativo: es lo único que verá dentro de dos años quien la incumpla, así que conviene incluir la razón concreta o la lección, no un «está prohibido».

Solución 3

# Regla Dónde vive Justificación
1 Batería mínima para alquilar Entidad Bicicleta.puedeAlquilarse(umbral) Depende solo del estado propio. El umbral entra como parámetro desde RedProperties, así que la entidad no necesita conocer la configuración
2 Un solo alquiler abierto por usuario Servicio Cruza agregados y necesita consultar el repositorio: Alquiler no puede saber cuántos otros alquileres existen
3 Estación llena Entidad Estacion.estaLlena() Comparación entre dos datos del propio agregado. Es el caso más claro de los seis
4 Importe según tarifa Servicio Requiere el colaborador SelectorTarifa. Meterlo en Alquiler obligaría a inyectar un servicio en una entidad, que es peor que el modelo anémico
5 No finalizar dos veces Entidad Alquiler.finalizar(...) Es un invariante del agregado, y ponerlo en la entidad garantiza que ningún camino pueda saltárselo
6 Solo un operario marca averiada Servicio, con @PreAuthorize Es autorización, no dominio. Depende del usuario autenticado, un concepto ajeno a Bicicleta

El patrón que emerge, y que es la respuesta del ejercicio: la regla vive en la entidad cuando se puede evaluar con lo que la entidad ya tiene delante. En cuanto hace falta consultar otra cosa —otro agregado, un servicio, el usuario autenticado— sube al servicio. El caso 1 suele generar discusión, porque podría argumentarse que el umbral es configuración y por tanto la regla es del servicio; pasarlo como argumento mantiene la entidad limpia y la regla junto al dato. Cuando dudes, la pregunta útil es: ¿puedo probar esta regla con un new y nada más? Si la respuesta es sí, puede vivir en la entidad.

Conclusión

El código de CicloUrbana ya no solo funciona y evita los errores conocidos: se puede leer. Y el criterio que lo gobierna todo es uno solo, el que abría la lección: el destinatario no es el compilador, es el próximo que lo lea, con la propiedad más objetiva —la comprobabilidad— como termómetro: si probar algo exige acrobacias, el problema es el diseño.

Sabes nombrar con la convención del curso —dominio en español, framework en inglés, sin híbridos— y distinguir getData() de buscarConDisponibilidad(int bicicletasMinimas), que responde a qué, de dónde y según qué criterio. Escribes funciones con un solo nivel de abstracción, donde el método público cuenta la historia y los privados la detallan, y sabes por qué una bandera booleana es en realidad dos métodos que aún no se han separado. Has visto la refactorización completa de AlquilerService.finalizar en cuatro pasos —extraer el cálculo a SelectorTarifa, convertir el recargo en el decorador TarifaConRecargoPorExceso, mover el comportamiento a Alquiler.finalizar(...) y sacar los efectos secundarios al AFTER_COMMIT—, de treinta y ocho líneas y cinco razones para cambiar a once líneas y una, con la condición innegociable de tener las pruebas en verde antes y después de cada paso.

Tienes SOLID aterrizado en Ribalta con sus dos malentendidos aclarados —DIP no es una interfaz por clase, y LSP se incumple casi siempre por contrato y no por herencia—; el criterio para los comentarios, que documentan el porqué y nunca el qué; el manejo de errores con excepciones del dominio, sin usarlas para el flujo normal, sin capturar Exception y con mensajes distintos para el log y para el cliente; la inmutabilidad con record y colecciones de solo lectura, y su límite honesto en las entidades JPA, donde la respuesta no es la inmutabilidad sino la mutación controlada por métodos con nombre de negocio; y Optional en su único sitio correcto, el retorno, con orElseThrow sustituyendo al .get() que convierte un 404 en un 500. Y tienes la discusión honesta sobre el modelo anémico, con el criterio de CicloUrbana como punto intermedio defendible: las reglas que dependen solo del estado del propio agregado van en la entidad, las que necesitan colaboradores o cruzan agregados van en el servicio.

Cierran la lección las tres piezas que convierten todo lo anterior en algo que se sostiene solo: ArchUnit, que transforma las reglas de dependencia en pruebas que se ponen en rojo con un mensaje que explica el porqué; Spotless, Checkstyle y el análisis estático, que sacan el estilo de las revisiones para que estas se ocupen de lo que ninguna herramienta ve; y la refactorización segura apoyada en el módulo 6, con la regla del campamento como forma sostenible de mejorar y con la deuda técnica registrada con destinatario, coste e impacto en lugar de con TODO decorativos —y con el permiso explícito de dejar sin pagar la deuda que no molesta a nadie—.

Con esto se cierra la reflexión sobre cómo está hecha CicloUrbana. Durante diez módulos hemos construido la red de Ribalta capa por capa y nunca la hemos mirado entera. La lección Proyecto Final: Recorrido Completo de CicloUrbana hace exactamente eso: la arquitectura final en un diagrama, la estructura completa del repositorio, el recorrido de un POST /api/v1/alquileres paso a paso desde el balanceador hasta la métrica —citando en cada paso la lección donde se estudió—, el mapa de qué construyó cada módulo, las decisiones de diseño con sus alternativas y sus contrapartidas, el pom.xml y el application.yml finales comentados, cómo poner en marcha el proyecto entero desde cero, y hacia dónde seguir ampliándolo.

Curso de Spring Boot

Módulo 1: Introducción a Spring Boot

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados