Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado la api en una url. En Servidor continúas la implementación del mismo producto.
Se explica
25 minutos · explicación y demostración
El controlador ya valida y responde correctamente, pero concentra demasiadas responsabilidades. Hoy separarás almacenamiento, reglas y HTTP en repository, service y controller. Refactorizar significa cambiar la organización interna conservando el comportamiento observable.
Las tres preguntas que delatan el problema
Las medidas dicen que hay algo raro. Estas tres preguntas dicen qué te va a costar.
Quieres comprobar que una tarea nueva nace sin completar. Hoy, para comprobarlo, necesitas: compilar el proyecto, arrancar Tomcat, abrir un puerto, abrir Postman, escribir un JSON, enviarlo y mirar la respuesta.
Todo eso para comprobar un false.
El problema no es solo su lentitud: ante un fallo no sabes si el defecto está en la regla, en la ruta, en el mapper, en la validación o en el JSON que escribiste. La prueba no señala el culpable.
Imagina tres peticiones perfectamente razonables:
- Importar tareas desde un CSV.
- Crear tareas automáticamente cada lunes.
- Crear una tarea desde otra parte de la aplicación, sin HTTP.
Las tres tienen que aplicar las mismas reglas: asignar el id, nacer sin completar, comprobar que el proyecto existe.
Hoy esas reglas viven dentro de un método anotado con @PostMapping. Solo se pueden ejecutar si alguien hace una petición HTTP. Las tres situaciones te obligarían a copiar el código, y ya sabes lo que pasa con el código copiado.
Dentro de dos unidades, la lista en memoria se sustituye por PostgreSQL. Mira tu controlador y responde honestamente: ¿cuántos métodos tendrías que tocar?
Todos los que mencionen tareas. Es decir, todos. Un cambio de almacenamiento acaba tocando la capa web, que no tiene nada que ver con dónde se guardan los datos.
Adónde vamos
La solución no procede de ninguna característica de Spring, sino de un principio anterior y sencillo: separar por motivo de cambio.
- Controller
habla HTTP - Service
decide reglas - Repository
guarda y recupera
| Capa | Cambia cuando… | No sabe nada de… |
|---|---|---|
| Controller | Cambia una ruta, un código o el contrato | Dónde se guardan los datos |
| Service | Cambia una regla de negocio | HTTP, ni de SQL |
| Repository | Cambia el almacenamiento | Reglas de negocio |
Fíjate en la columna de la derecha, que es la que de verdad importa: el service no sabrá que existe HTTP. Por eso se podrá probar sin arrancar un servidor, y por eso el CSV y la tarea programada podrán reutilizarlo.
Primero identificaremos esas responsabilidades en el controlador actual; después las separaremos en repositorio, servicio y controlador, comprobando que las peticiones siguen dando el mismo resultado.
Las tres capas, sin misticismo
- Controller
- Service
- Repository
| Capa | Su única pregunta | Vocabulario que usa |
|---|---|---|
| Controller | ¿Cómo se expresa esto en HTTP? | Rutas, códigos, DTO, cabeceras |
| Service | ¿Qué se puede hacer y con qué reglas? | Tareas, proyectos, «no se puede si…» |
| Repository | ¿Cómo guardo y recupero esto? | Listas hoy; tablas y consultas en la UD5 |
La regla de la dirección
El controller conoce al service. El service conoce al repository. Y nunca al revés.
Un repository que llamara a un service, o un service que supiera qué es un código 404, rompería justo lo que se gana: que cada capa pueda cambiar sin arrastrar a las otras.
La consecuencia más útil, y la que hay que retener: el service no sabe que existe HTTP. Ni códigos de estado, ni ResponseEntity, ni DTO. Recibe y devuelve objetos del modelo. Por eso lo podrá usar una importación de CSV y en esta unidad lo probarás sin arrancar el servidor.
Se trabaja
140 minutos · implementación guiada sobre el proyecto propio
Paso 1 · Retomar el proyecto y preparar la comprobación
- Ejecuta la colección del contrato y conserva su resultado como referencia. Abre el controlador principal.
- Marca qué líneas leen o escriben la lista, cuáles aplican reglas y cuáles construyen respuestas HTTP. Esa clasificación indicará qué mover a cada clase.
- Prepara los paquetes
repositoryyservicebajo tu paquete base. No cambies a la vez las rutas ni el formato de los DTO.
Paso 2 · Separar responsabilidades conservando el contrato HTTP
Esta unidad no añade ni una funcionalidad. Al terminar, tu API responderá exactamente lo mismo que hoy: mismas rutas, mismos códigos, mismo JSON. Tu colección de Postman seguirá en verde sin tocar una sola petición.
Aun así se encuentra entre las más importantes, porque determina quién podrá seguir interviniendo sobre ese código dentro de seis meses.
Qué es una refactorización
Cambiar cómo está escrito el código sin cambiar lo que hace. Si el comportamiento observable cambia, no has refactorizado: has hecho otra cosa y probablemente la has roto.
Por eso esta unidad llega después de la UD3 y no antes. Refactorizar sin una forma de comprobar que nada se ha roto es reescribir a ciegas, y tú ya tienes esa forma: la colección.
Paso 3 · Medir las responsabilidades y dependencias del controlador
«Este código es un desastre» constituye una impresión y no un diagnóstico. A continuación se sustituye por magnitudes y hechos verificables.
Abre tu TareaController y clasifica cada línea en una de estas categorías:
| Categoría | Qué es |
|---|---|
| Web | Leer la petición, elegir el código de estado, construir la respuesta |
| Datos | Guardar, buscar, recorrer, filtrar, borrar de la lista |
| Reglas | Decidir qué se puede hacer y qué no, o qué valor toma algo |
| Traducción | Pasar de DTO a modelo o al revés |
| Infraestructura | Generar identificadores, construir URLs |
Cuenta cuántas categorías distintas aparecen. En un controlador salido de la UD3 salen normalmente cuatro o cinco.
Esta es mejor que contar líneas. Pregúntate qué tendría que ocurrir en el mundo para tener que abrir este archivo:
- Cambia una ruta o un código de estado · motivo web
- Se pasa de una lista en memoria a una base de datos · motivo almacenamiento
- Cambia una regla, como qué prioridad tiene una tarea nueva · motivo negocio
- Se publica un campo distinto · motivo contrato
Cuatro motivos independientes, cuatro personas distintas que podrían tocar el mismo archivo la misma semana, y cuatro oportunidades de romper algo que no tiene nada que ver con lo que se venía a cambiar.
Una clase, un motivo de cambio
Es el principio de responsabilidad única, y se enuncia así y no como «una clase hace una cosa», que no significa nada. La pregunta útil siempre es: ¿qué tendría que pasar para que tuviera que abrir este archivo? Si hay más de una respuesta, hay más de una clase.
Pon tu TareaController y tu ProyectoController uno al lado del otro. Vas a encontrar, casi seguro:
- Un método
buscar(id)privado, prácticamente idéntico. - Un campo
siguienteIdy la misma línea que lo incrementa. - La misma estructura de bucle para filtrar.
- El mismo
throw new RecursoNoEncontradoException(...).
Por qué duplicar es caro
No por escribirlo dos veces: eso son treinta segundos. Es caro porque el día que haya que corregirlo, se corregirá en uno solo.
Y el que quede sin corregir no dará error: seguirá funcionando como funcionaba, que es exactamente lo que hace que nadie lo encuentre.
Paso 4 · Clasificar las líneas del controlador por responsabilidad
Coge tu TareaController y produce esta tabla. Una fila por método:
| Método | Web | Datos | Reglas | Traducción | Infra |
|---|---|---|---|---|---|
lista() |
|||||
detalle() |
|||||
crear() |
|||||
reemplazar() |
|||||
modificar() |
|||||
eliminar() |
Marca cada casilla donde ese método haga algo de esa categoría. Después responde:
- ¿Cuál es el método con más casillas marcadas? ¿Por qué crees que es ese?
- ¿Hay alguna columna marcada en todos los métodos? ¿Qué significa eso?
- Si mañana cambias de almacenamiento, ¿cuántas casillas se ven afectadas?
Paso 5 · El inventario de problemas
Analiza los problemas del código en los cuatro apartados siguientes. Sé concreto: nada de «está desordenado».
Lista, con número de línea, los sitios donde una misma clase hace cosas de categorías distintas. Formato: «líneas 34-41: el método crear decide una regla de negocio y además construye una URL».
Lista lo que está repetido entre tus controladores, con las dos ubicaciones. Para cada uno, escribe qué pasaría si alguien corrigiera solo una de las dos copias.
Lista las reglas de negocio de tu aplicación y, para cada una, qué haría falta arrancar para comprobarla hoy.
Elige uno de estos tres cambios y cuenta exactamente cuántos archivos y métodos tendrías que tocar:
- Pasar de lista en memoria a base de datos.
- Que una tarea nueva nazca con prioridad
mediaen lugar de sin prioridad. - Publicar un campo nuevo en la respuesta.
Este archivo es la justificación de la unidad entera. En la sesión 18 lo repasarás y tendrás que poder tachar cada línea.
Arquitectura por capas
Paso 6 · Extrae el repositorio
Crea repository/TareaRepository.java con el bloque completo. Mueve allí la lista y el contador que antes estaban en el controlador, y conserva sus operaciones bajo los métodos de acceso mostrados. Todavía no borres el código del controlador: primero prepara el servicio del paso siguiente y después cambia sus llamadas. La aplicación tendrá una sola lista efectiva cuando completes la extracción, no una copia independiente en cada capa.
package com.ejemplo.gestor.repository;
import com.ejemplo.gestor.model.Tarea;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public class TareaRepository {
private final List<Tarea> tareas = new ArrayList<>();
private int siguienteId = 1;
public List<Tarea> findAll() {
return new ArrayList<>(tareas);
}
public Optional<Tarea> findById(int id) {
for (Tarea tarea : tareas) {
if (tarea.getId() == id) {
return Optional.of(tarea);
}
}
return Optional.empty();
}
public Tarea save(Tarea tarea) {
if (tarea.getId() == 0) {
tarea.setId(siguienteId);
siguienteId = siguienteId + 1;
tareas.add(tarea);
}
return tarea;
}
public boolean deleteById(int id) {
return tareas.removeIf(tarea -> tarea.getId() == id);
}
public boolean existsById(int id) {
return findById(id).isPresent();
}
}
- Por qué estos nombres y no otros
- Son los de Spring Data JPA. En la UD5 esta clase entera desaparecerá y se sustituirá por una interfaz que Spring implementa sola, y si los nombres coinciden, el service no se enterará del cambio. Estás preparando ese momento sin saberlo.
- Por qué
findAlldevuelve una copia - Porque si devuelves la lista original, quien la reciba puede añadir o borrar elementos por su cuenta y saltarse el repositorio entero. La copia protege el dato de quien no debería tocarlo.
- Por qué el contador vive aquí
- Generar identificadores es una responsabilidad del almacenamiento. En la UD5 lo hará la base de datos, y de nuevo nadie más se enterará.
Optional
Una caja que puede contener un valor o estar vacía. Sustituye a devolver null, con una ventaja: el tipo te obliga a considerar el caso vacío, en lugar de dejarlo al olvido. Se consulta con isPresent(), se abre con get(), y tiene atajos como orElseThrow().
Es también lo que devuelve JpaRepository.findById en la UD5, así que empezamos con él ya.
Paso 7 · Extrae el servicio
Crea service/TareaService.java e importa el repositorio, el modelo y la excepción. El bloque define el recorrido obtener → aplicar regla → guardar; conserva las reglas ya desarrolladas para tu dominio. Para trasladar PUT y PATCH, mueve sus asignaciones al servicio y deja la conversión de DTO en el controlador. No elimines una operación del contrato porque el bloque de ejemplo solo muestre parte de ellas.
package com.ejemplo.gestor.service;
import com.ejemplo.gestor.error.RecursoNoEncontradoException;
import com.ejemplo.gestor.model.Tarea;
import com.ejemplo.gestor.repository.TareaRepository;
import java.util.List;
public class TareaService {
private final TareaRepository repositorio = new TareaRepository();
public List<Tarea> listar() {
return repositorio.findAll();
}
public Tarea obtener(int id) {
return repositorio.findById(id)
.orElseThrow(() -> new RecursoNoEncontradoException("tarea", id));
}
public Tarea crear(Tarea tarea) {
// Regla: una tarea nace sin completar, lo pida quien lo pida.
tarea.setCompletada(false);
return repositorio.save(tarea);
}
public Tarea reemplazar(int id, Tarea datos) {
Tarea existente = obtener(id);
existente.setTitulo(datos.getTitulo());
existente.setPrioridad(datos.getPrioridad());
existente.setProyectoId(datos.getProyectoId());
existente.setCompletada(datos.isCompletada());
return existente;
}
public void eliminar(int id) {
repositorio.deleteById(id);
}
}
orElseThrow- «Dame el valor, y si la caja está vacía, lanza esto.» Sustituye el
if (tarea == null)por una línea que además obliga a decidir qué pasa cuando no está. - Por qué
obtenerlanza y no devuelvenull - Porque «pedir una tarea que no existe» es un error del que llama, y quien llama no debería tener que acordarse de comprobarlo. Además, así
reemplazarreutilizaobtenery hereda gratis el mismo comportamiento. - La línea del comentario
- Esa regla estaba antes dentro de un
@PostMapping. Ahora vive donde se puede reutilizar y donde se puede probar. Es literalmente el problema de ayer, resuelto.
¿Puede el service lanzar una excepción que acabará siendo un 404?
Sí, y conviene entender por qué no rompe la regla de la dirección. RecursoNoEncontradoException no habla de HTTP: dice «esto que me pides no existe», que es una afirmación del dominio y sería igual de cierta desde una importación de CSV.
Quien la traduce a un 404 es el manejador de la sesión 13, que sí es capa web. El service dice qué ha pasado; el controller decide cómo se cuenta eso por HTTP.
Paso 8 · Dejar en el controlador la entrada y la respuesta HTTP
Modifica la clase TareaController existente. Añade el campo servicio, cambia un endpoint para utilizarlo y prueba esa petición antes de seguir con los demás. El ejemplo muestra listado, detalle, alta y borrado; adapta también PUT, PATCH y filtros a los métodos del servicio. Solo cuando todas las operaciones utilicen el servicio elimina del controlador la lista, el contador y las búsquedas privadas. Conserva paquete, imports, validación, Location y estados del contrato.
@RestController
@RequestMapping("/tareas")
public class TareaController {
private final TareaService servicio = new TareaService();
@GetMapping
public List<TareaResponse> lista() {
return TareaMapper.aRespuestas(servicio.listar());
}
@GetMapping("/{id}")
public TareaResponse detalle(@PathVariable(name = "id") int id) {
return TareaMapper.aRespuesta(servicio.obtener(id));
}
@PostMapping
public ResponseEntity<TareaResponse> crear(
@Valid @RequestBody TareaRequest peticion) {
Tarea creada = servicio.crear(TareaMapper.aModelo(peticion));
URI ubicacion = ServletUriComponentsBuilder
.fromCurrentRequest().path("/{id}")
.buildAndExpand(creada.getId()).toUri();
return ResponseEntity.created(ubicacion)
.body(TareaMapper.aRespuesta(creada));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> eliminar(@PathVariable(name = "id") int id) {
servicio.eliminar(id);
return ResponseEntity.noContent().build();
}
}
Cada método hace ahora tres cosas y solo tres: traducir lo que entra, llamar al servicio, traducir lo que sale. No hay bucles, no hay listas, no hay reglas.
Dónde queda el mapper
En la capa web, junto al controlador. Traduce entre DTO y modelo, y los DTO son el contrato HTTP: si el service usara el mapper, sabría de la existencia de un contrato web que no le incumbe.
La regla práctica: los DTO no cruzan hacia dentro. Al service entran y salen objetos del modelo.
Paso 9 · Ejecutar la colección para comprobar que se conserva el contrato
Este es el momento de la sesión.
- Arranca la aplicación.
- Ejecuta la colección entera de Postman.
- Verde de arriba abajo, sin haber tocado ni una petición.
Has movido de sitio casi todo el código de tu API y quien la consume no se ha enterado de nada. Eso es una refactorización, y esa colección que escribiste en la UD2 es lo que te ha permitido hacerla sin miedo.
Si algo sale en rojo
No cambies la colección. La colección describe lo que tu API prometía y sigue siendo correcta: lo que está mal es la refactorización. Ese rojo es exactamente el aviso para el que la escribiste.
Paso 10 · Identificar los colaboradores creados directamente con new
Mira estas dos líneas:
private final TareaService servicio = new TareaService();
private final TareaRepository repositorio = new TareaRepository();
Funcionan, y tienen tres problemas serios:
- El controller elige qué servicio usa, así que no se le puede dar otro
- El service decide qué repositorio usa, así que en la UD5 habrá que abrirlo para cambiarlo
- Para probar el service en la sesión 17 hará falta su repositorio de verdad, con sus datos
Déjalas así hoy. Mañana desaparecen, y entenderás qué se gana porque habrás sentido qué molesta.
Paso 11 · Las capas de proyectos
- Crea
ProyectoRepositorycon los mismos cinco métodos y los mismos nombres. - Crea
ProyectoServicecon sus casos de uso. - Adelgaza
ProyectoControllerhasta que ningún método tenga bucles ni listas. - Mueve al service la regla del
409de la UD3 —el nombre repetido— y explica en un comentario por qué es una regla y no una validación de formato. - Ejecuta la colección entera y comprueba que sigue verde.
Paso 12 · Comprobar y registrar el resultado del proyecto
- Ejecuta la misma colección después de cada extracción. Sus resultados deben mantenerse sin modificar las peticiones para ocultar un fallo.
- Sigue una creación en el código: controller recibe, service decide y repository almacena. Comprueba que otra entidad de tu proyecto sigue la misma distribución.
Ampliación si has completado el trabajo
Primero termina y verifica los pasos anteriores. Estos retos profundizan en el mismo contenido; no sustituyen la entrega ni obligan a iniciar otro proyecto.
Reto · Diagnostica sin ver el código
Un compañero te describe su API así, sin enseñarte nada:
«Tengo dos controladores. En cada uno, un
ArrayListy un contador. Cuando creo algo compruebo dentro del@PostMappingque el nombre no esté repetido recorriendo la lista. Para el informe mensual tengo un tercer controlador que también recorre las dos listas, así que las tiene declaradas comostaticpara poder acceder a ellas desde fuera.»
- Enumera al menos cuatro problemas distintos, ordenados por gravedad.
- Uno de ellos es bastante peor que los demás y no es la duplicación. Identifícalo y explica qué le va a ocurrir el día que dos peticiones lleguen a la vez.
- Para cada problema, di en qué capa debería vivir eso según el mapa de hoy.
- Tu compañero responde: «pero funciona, lo he probado». ¿Qué le contestas? Escribe la respuesta en dos frases, sin condescendencia y con un argumento que pueda comprobar él mismo.
Ver respuestas
1 · «¿Qué tendría que ocurrir para que tuviera que abrir este archivo?» Si hay más de una respuesta independiente, hay más de una responsabilidad.
2 · Porque el día que haya que corregir algo se corregirá en una sola de las copias, y la otra seguirá funcionando como funcionaba, sin dar error y sin que nadie la encuentre.
3 · Una importación desde un archivo, una tarea programada, otra parte de la aplicación llamando directamente. Ninguna hace una petición HTTP.
4 · El comportamiento observable: las mismas rutas, los mismos códigos y el mismo JSON. Si eso cambia, no ha sido una refactorización.
Reto · La ruta que cruza dos recursos
GET /proyectos/{id}/tareas es interesante porque toca dos recursos a la vez. Tiene que responder 404 si el proyecto no existe y 200 con [] si existe y no tiene tareas.
- Decide en qué service vive ese caso de uso, y justifícalo. Hay dos respuestas defendibles.
- Ese service va a necesitar saber de los dos repositorios, o de otro service. Decide cuál de las dos opciones y explica el criterio.
- Impleméntalo.
- Responde: si un service puede llamar a otro service, ¿qué peligro aparece? Piensa en dos servicios que se llamen entre sí.
- Comprueba con la colección que los dos casos siguen respondiendo lo mismo que en la UD3.
La pregunta 4 no la vamos a resolver hoy, pero tienes que saber verla venir.
Ver respuestas
1 · Del controller al service y del service al repository. Nunca al revés: una capa no conoce a la que la llama.
2 · Porque los DTO son el contrato HTTP, y el service no debe saber que existe una API web. Si los conociera, no se podría reutilizar desde una importación de archivos o una tarea programada.
3 · Que nadie de fuera pueda añadir ni borrar elementos saltándose el repositorio. La lista original queda protegida.
4 · Que el comportamiento observable no ha cambiado, que es la definición de refactorización. Si algo saliera en rojo, lo incorrecto sería el cambio, no la colección.
Cierre
15 minutos · resultado comprobable y explicación individual
Al terminar la sesión:
El contrato público no cambia y resulta posible seguir una petición a través de las tres capas.
Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.