Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado cerrar una candidata con evidencias de calidad. En Servidor continúas la implementación del mismo producto.
Se explica
25 minutos · explicación y demostración
La ampliación ya tiene criterios y cambios de datos definidos. Hoy implementarás un recorrido completo a través de las capas. Un corte vertical conecta entrada, regla, persistencia y respuesta de un caso de uso, de modo que se pueda probar antes de añadir los demás.
El antipatrón de las capas horizontales
Un error clásico al iniciar un proyecto es trabajar por estratos horizontales:
- Semana 1: Creamos todas las entidades JPA de todas las tablas.
- Semana 2: Creamos todos los repositorios e interfaces.
- Semana 3: Creamos los servicios.
- Semana 4: Creamos los controladores y probamos si algo funciona.
Si en la cuarta semana descubres que la forma en que diseñaste las relaciones en JPA dificulta las consultas que necesita el cliente web, tienes que reescribir las tres semanas anteriores.
La estrategia del Corte Vertical (Vertical Slice)
La ingeniería moderna construye el software por cortes verticales:
- 1. DTO Request validado
- 2. Controlador REST con ruta y seguridad
- 3. Servicio con lógica de negocio
- 4. Repositorio JPA y tabla PostgreSQL
- 5. DTO Response y cabecera Location
En lugar de construir el 100 % de las entidades sin probarlas, construyes un único caso de uso de principio a fin:
- Si completas el alta de proyectos en 3 horas, tienes una aplicación que compila, arranca, persiste datos en PostgreSQL, pasa sus tests y devuelve respuestas HTTP estándar.
- Tienes una base sólida y probada sobre la que construir los siguientes casos de uso con total confianza.
Ampliar mediante una operación completa
Un corte vertical añade una operación que puede ejecutarse de extremo a extremo: entrada HTTP, validación, regla de negocio, persistencia y respuesta. Permite descubrir pronto un desajuste entre DTO y servicio o una restricción de la base de datos. Escribir primero todas las entidades nuevas y dejar las pruebas HTTP para el final retrasa ese descubrimiento.
El punto de partida es el producto del trimestre, no un esqueleto vacío. Se elige una ampliación pendiente, se identifica qué clases existentes deben cambiar y se conserva el contrato de las operaciones que ya funcionan. Si una regla cambia, se actualiza expresamente su criterio de aceptación y su prueba.
La operación termina cuando hay una petición válida comprobada, un rechazo relevante y una prueba que reproduce el resultado. Únicamente entonces se replica el patrón en la siguiente ampliación. El ejemplo de alta que aparece en la práctica sirve para seguir todas las piezas; en el proyecto propio debe aplicarse al caso de uso pendiente, sin volver a implementar un alta que ya esté terminada.
Se trabaja
140 minutos · implementación guiada sobre el proyecto propio
Paso 1 · Retomar el proyecto y preparar la comprobación
- Abre el primer criterio de aceptación de la sesión 47 y localiza los DTO, servicio y repositorio que afectará.
- Escribe la petición y respuesta esperadas antes de modificar clases. Reutiliza las capas existentes y añade solo las piezas que requiere el caso.
- Prepara los datos previos y una prueba HTTP del recorrido. El ejemplo de alta sirve como patrón si tu ampliación realiza otra operación.
Paso 2 · El primer corte vertical: Alta de Proyectos
Los bloques muestran las piezas de un recorrido, en orden de dependencia: DTO, entidad, repositorio, servicio y controlador. Localiza la pieza equivalente que ya exista y amplíala; crea un archivo nuevo solo cuando la ampliación lo requiera. Antes de copiar un constructor de DTO o un acceso del servicio, comprueba que existen sus componentes y campos. Conserva las reglas anteriores y conecta los campos nuevos de entrada a persistencia y salida. Prueba primero una sola alta y su consulta antes de añadir el resto de casos.
package com.ejemplo.gestor.proyecto.dto;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDate;
public record CrearProyectoRequest(
@NotBlank(message = "El código de proyecto es obligatorio")
@Pattern(regexp = "^PRJ-\\d{4}-\\d{3}$", message = "El código debe seguir el formato PRJ-AAAA-NNN (ej: PRJ-2026-001)")
String codigo,
@NotBlank(message = "El nombre del proyecto no puede estar vacío")
@Size(min = 3, max = 100, message = "El nombre debe tener entre 3 y 100 caracteres")
String nombre,
@Size(max = 1000, message = "La descripción no puede exceder los 1000 caracteres")
String descripcion,
@NotNull(message = "El presupuesto es obligatorio")
@DecimalMin(value = "100.00", message = "El presupuesto mínimo de un proyecto es de 100.00 €")
BigDecimal presupuestoTotal,
@NotNull(message = "La latitud es obligatoria")
@DecimalMin("-90.0") @DecimalMax("90.0")
Double latitud,
@NotNull(message = "La longitud es obligatoria")
@DecimalMin("-180.0") @DecimalMax("180.0")
Double longitud,
@NotNull(message = "La fecha de inicio es obligatoria")
LocalDate fechaInicio,
@NotNull(message = "La fecha estimada de fin es obligatoria")
LocalDate fechaFinEstimada
) {}
package com.ejemplo.gestor.proyecto.model;
import com.ejemplo.gestor.usuario.model.Usuario;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
@Entity
@Table(name = "proyectos")
public class Proyecto {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 20)
private String codigo;
@Column(nullable = false, length = 100)
private String nombre;
@Column(columnDefinition = "TEXT")
private String descripcion;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 30)
private EstadoProyecto estado = EstadoProyecto.PLANIFICADO;
@Column(name = "presupuesto_total", nullable = false, precision = 12, scale = 2)
private BigDecimal presupuestoTotal;
// Coordenadas: Double, no BigDecimal. La precisión decimal exacta
// solo hace falta donde un redondeo cuesta dinero, y ese es el caso
// del presupuesto, no el de una latitud.
@Column(nullable = false)
private Double latitud;
@Column(nullable = false)
private Double longitud;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "responsable_id", nullable = false)
private Usuario responsable;
@Version
private Long version;
@Column(name = "fecha_inicio", nullable = false)
private LocalDate fechaInicio;
@Column(name = "fecha_fin_estimada", nullable = false)
private LocalDate fechaFinEstimada;
@Column(name = "fecha_creacion", nullable = false, updatable = false)
private OffsetDateTime fechaCreacion = OffsetDateTime.now();
// Constructores, getters y setters
public Proyecto() {}
public Long getId() { return id; }
public String getCodigo() { return codigo; }
public void setCodigo(String codigo) { this.codigo = codigo; }
public String getNombre() { return nombre; }
public void setNombre(String nombre) { this.nombre = nombre; }
public void setDescripcion(String descripcion) { this.descripcion = descripcion; }
public EstadoProyecto getEstado() { return estado; }
public void setEstado(EstadoProyecto estado) { this.estado = estado; }
public BigDecimal getPresupuestoTotal() { return presupuestoTotal; }
public void setPresupuestoTotal(BigDecimal presupuestoTotal) { this.presupuestoTotal = presupuestoTotal; }
public Double getLatitud() { return latitud; }
public void setLatitud(Double latitud) { this.latitud = latitud; }
public Double getLongitud() { return longitud; }
public void setLongitud(Double longitud) { this.longitud = longitud; }
public Usuario getResponsable() { return responsable; }
public void setResponsable(Usuario responsable) { this.responsable = responsable; }
public LocalDate getFechaInicio() { return fechaInicio; }
public void setFechaInicio(LocalDate fechaInicio) { this.fechaInicio = fechaInicio; }
public LocalDate getFechaFinEstimada() { return fechaFinEstimada; }
public void setFechaFinEstimada(LocalDate fechaFinEstimada) { this.fechaFinEstimada = fechaFinEstimada; }
}
package com.ejemplo.gestor.proyecto.repository;
import com.ejemplo.gestor.proyecto.model.Proyecto;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface ProyectoRepository extends JpaRepository<Proyecto, Long> {
boolean existsByCodigo(String codigo);
Optional<Proyecto> findByCodigo(String codigo);
}
package com.ejemplo.gestor.proyecto.service;
import com.ejemplo.gestor.proyecto.dto.CrearProyectoRequest;
import com.ejemplo.gestor.proyecto.dto.ProyectoDetalleResponse;
import com.ejemplo.gestor.proyecto.model.Proyecto;
import com.ejemplo.gestor.proyecto.repository.ProyectoRepository;
import com.ejemplo.gestor.usuario.model.Usuario;
import com.ejemplo.gestor.usuario.repository.UsuarioRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ProyectoService {
private static final Logger log = LoggerFactory.getLogger(ProyectoService.class);
private final ProyectoRepository proyectoRepository;
private final UsuarioRepository usuarioRepository;
public ProyectoService(ProyectoRepository proyectoRepository, UsuarioRepository usuarioRepository) {
this.proyectoRepository = proyectoRepository;
this.usuarioRepository = usuarioRepository;
}
@Transactional
public ProyectoDetalleResponse crearProyecto(CrearProyectoRequest request, String usernameResponsable) {
log.info("Creando nuevo proyecto con código '{}' para responsable '{}'", request.codigo(), usernameResponsable);
// Regla de negocio 1: El código de proyecto debe ser único
if (proyectoRepository.existsByCodigo(request.codigo())) {
throw new IllegalArgumentException("Ya existe un proyecto con el código: " + request.codigo());
}
// Regla de negocio 2: La fecha fin no puede ser anterior a fecha inicio
if (request.fechaFinEstimada().isBefore(request.fechaInicio())) {
throw new IllegalArgumentException("La fecha estimada de fin no puede ser anterior a la fecha de inicio");
}
Usuario responsable = usuarioRepository.findByUsername(usernameResponsable)
.orElseThrow(() -> new IllegalStateException("Usuario responsable no encontrado en el sistema"));
Proyecto proyecto = new Proyecto();
proyecto.setCodigo(request.codigo());
proyecto.setNombre(request.nombre());
proyecto.setDescripcion(request.descripcion());
proyecto.setPresupuestoTotal(request.presupuestoTotal());
proyecto.setLatitud(request.latitud());
proyecto.setLongitud(request.longitud());
proyecto.setResponsable(responsable);
proyecto.setFechaInicio(request.fechaInicio());
proyecto.setFechaFinEstimada(request.fechaFinEstimada());
proyecto = proyectoRepository.save(proyecto);
log.info("Proyecto persistido exitosamente con ID={}", proyecto.getId());
return new ProyectoDetalleResponse(
proyecto.getId(),
proyecto.getCodigo(),
proyecto.getNombre(),
proyecto.getEstado().name(),
proyecto.getPresupuestoTotal(),
responsable.getNombreCompleto()
);
}
}
package com.ejemplo.gestor.proyecto.controller;
import com.ejemplo.gestor.proyecto.dto.CrearProyectoRequest;
import com.ejemplo.gestor.proyecto.dto.ProyectoDetalleResponse;
import com.ejemplo.gestor.proyecto.service.ProyectoService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.net.URI;
@RestController
@RequestMapping("/api/v1/proyectos")
public class ProyectoController {
private final ProyectoService proyectoService;
public ProyectoController(ProyectoService proyectoService) {
this.proyectoService = proyectoService;
}
@PostMapping
@PreAuthorize("hasAnyRole('JEFE_PROYECTO', 'ADMINISTRADOR')")
public ResponseEntity<ProyectoDetalleResponse> crearProyecto(
@Valid @RequestBody CrearProyectoRequest request,
@AuthenticationPrincipal UserDetails usuario) {
ProyectoDetalleResponse response = proyectoService.crearProyecto(request, usuario.getUsername());
// Cabecera Location estándar: http://localhost:8080/api/v1/proyectos/{id}
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(response.id())
.toUri();
return ResponseEntity.created(location).body(response);
}
}
Paso 3 · Prueba de Integración con MockMvc
Prepara una base de pruebas exclusiva y activa su perfil. @WithMockUser(username="jefe1") simula la identidad, pero el servicio busca además ese usuario en PostgreSQL: el test debe crearlo previamente con el mismo username y con los campos obligatorios. Prepara también cualquier entidad referenciada por la petición. Genera los datos desde el test y comprueba el id devuelto, sin asumir el 1. Después del caso permitido añade el de validación o regla rechazada y verifica que no crea registros.
package com.ejemplo.gestor.proyecto;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Map;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
class ProyectoCorteVerticalIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
@WithMockUser(username = "jefe1", roles = {"JEFE_PROYECTO"})
void crearProyecto_conDatosValidos_devuelve201YCabeceraLocation() throws Exception {
var payload = Map.of(
"codigo", "PRJ-2026-099",
"nombre", "Parque Eólico La Muela",
"descripcion", "Instalación de 10 aerogeneradores",
"presupuestoTotal", 450000.00,
"latitud", 41.58,
"longitud", -1.12,
"fechaInicio", "2026-04-01",
"fechaFinEstimada", "2026-12-31"
);
mockMvc.perform(post("/api/v1/proyectos")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(payload)))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"))
.andExpect(jsonPath("$.codigo").value("PRJ-2026-099"))
.andExpect(jsonPath("$.estado").value("PLANIFICADO"));
}
}
Paso 4 · Implementar el segundo corte: Consulta de Proyecto por ID
Construye el corte vertical simétrico de lectura:
- Implementa
GET /api/v1/proyectos/{id}. - Si el proyecto existe, devuelve
200 OKcon el DTOProyectoDetalleResponse. - Si no existe, lanza una excepción de recurso no encontrado capturada por el
GlobalExceptionHandlerdevolviendo404 Not Founden formato Problem Details RFC 7807. - Añade el test correspondiente en MockMvc verificando tanto el caso de éxito (
200) como el de recurso inexistente (404).
Paso 5 · Comprobar y registrar el resultado del proyecto
- Ejecuta el caso desde HTTP y comprueba tanto la respuesta como el dato persistido, con autenticación y validación aplicadas.
- Implementa y comprueba la consulta que permite observar su resultado. Ejecuta también las pruebas anteriores para detectar cambios involuntarios.
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 · Generación automática y atómica del código de proyecto
En lugar de que el usuario introduzca el código manualmente (PRJ-2026-001), automatiza su generación en el servicio:
- Diseña un método en el repositorio que obtenga el número secuencial más alto del año en curso.
- Formatea la cadena de forma atómica (
PRJ-+ año actual +-+ número formateado a 3 dígitos con ceros a la izquierda). - Asegura mediante control de concurrencia que si dos usuarios dan de alta un proyecto simultáneamente, no se produzca un choque de clave duplicada.
Location, validación de fechas, seguridad por rol y test con MockMvc.Ver respuestas
1 · Porque valida inmediatamente la integración de todas las piezas (red, serialización, seguridad, lógica de negocio y base de datos) desde el primer día, detectando problemas arquitectónicos antes de que se extiendan a otras entidades.
2 · La URI absoluta del recurso recién creado (por ejemplo: http://servidor/api/v1/proyectos/42) para que el cliente pueda consultarlo o enlazarlo de inmediato sin construir la URL a ciegas.
3 · Porque involucra la comparación cruzada entre dos campos distintos del DTO, requiriendo validaciones a nivel de clase o lógica de negocio en el servicio.
4 · Inyecta un usuario simulado en el SecurityContext de Spring Security durante la ejecución del test, permitiendo verificar endpoints protegidos sin necesidad de generar un token JWT real.
Cierre
15 minutos · resultado comprobable y explicación individual
Al terminar la sesión:
Una operación nueva funciona completa y las operaciones existentes siguen verificadas.
Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.