MVC explicado paso a paso con un CRUD en Spring Boot

David TorresInformática y FPLeave a Comment

Titular sobre fondo azul de SASE: MVC en Spring Boot, paso a paso

Hace poco preparé unos apuntes para un alumno de DAM que estaba haciendo un CRUD de productos con Spring Boot. La teoría de MVC la tenía: Modelo, Vista, Controlador. Lo que no veía era dónde estaba cada letra en su proyecto, ni por qué había un ProductoService y un ProductoRepository si MVC solo habla de tres piezas. Es una duda que se repite mucho, así que he convertido aquellos apuntes en este artículo.

Todo el código es el del proyecto: una API REST de productos con Spring Boot 4.1, Java 21 y PostgreSQL. La idea en una frase:

Separar la aplicación en piezas con una sola responsabilidad cada una, para que cada parte sea fácil de entender, cambiar y probar por separado.

Qué es MVC

MVC (Modelo-Vista-Controlador) es un patrón de arquitectura: una forma acordada de organizar el código. No es una librería ni algo que se instale; es una manera de repartir el trabajo entre tres piezas:

  • Modelo: los datos y las reglas sobre ellos. Qué es un producto, qué campos tiene, cómo se guarda. En el proyecto, la clase Producto, con el servicio y el repositorio.
  • Vista: lo que ve quien usa la aplicación. En una página web es el HTML; en una API REST como esta, es el JSON de la respuesta, que genera Spring.
  • Controlador: el intermediario. Recibe la petición, pide al modelo lo que haga falta y elige qué devolver. En el proyecto, ProductoController.

¿Y el Service y el Repository?

Es la confusión típica. MVC original solo habla de tres piezas, pero en Spring la «M» se reparte en varias capas para no tenerlo todo en una clase: el Service (la lógica de negocio), el Repository (el acceso a la base de datos) y la entidad Producto (los datos). Por eso se habla de «MVC más arquitectura en capas», o de Controller-Service-Repository: son dos ideas que se usan juntas.

¿Por qué tanta separación? Imagina un único método que lee la URL, abre la conexión a PostgreSQL, escribe el SQL, comprueba si el producto existe, monta el JSON a mano y lo devuelve. Funciona hasta que quieres cambiar de base de datos, añadir una regla («no se puede vender con stock 0») o probar solo una parte: entonces hay que tocarlo todo. Con capas, cada cambio afecta a una sola pieza.

Qué hace cada pieza, y qué no debe hacer

Ayuda pensar en un restaurante: cada persona tiene un trabajo y no se mete en el de las demás.

  • Cliente (Postman, el navegador, una app móvil). Es quien come: pide un plato y espera a que se lo traigan. Envía una petición HTTP (verbo, URL y, a veces, un JSON) y recibe una respuesta (un código de estado y un JSON). No sabe nada de Java ni de la base de datos.
  • Controller (@RestController). El camarero: toma nota, lo pasa a cocina y trae el plato, pero no cocina. Sí le toca decir qué URL atiende cada método, leer los datos de la petición (@PathVariable, @RequestBody) y elegir el código HTTP. No le toca la lógica de negocio ni el acceso a la base de datos: nunca usa el repositorio directamente.
  • Service (@Service). El cocinero: sabe la receta y decide cómo se prepara cada plato. Sí le toca la lógica de negocio, es decir, las reglas («si no existe, error», «el id lo pone la base de datos», validaciones, cálculos), y las transacciones (@Transactional). No sabe nada de HTTP: ni URLs, ni códigos 200 o 404, ni JSON.
  • Repository (@Repository, JpaRepository). El encargado del almacén: trae y guarda ingredientes sin preguntar para qué. Sí le toca guardar, buscar, actualizar y borrar en la base de datos, y Spring Data JPA escribe ese código por ti: solo se declara la interfaz. No decide reglas de negocio.
  • Entidad (@Entity). Los ingredientes y el plato: lo que pasa de mano en mano. Producto es una clase Java normal con anotaciones que dicen cómo se guarda en una tabla: cada objeto es una fila y cada atributo, una columna. Viaja por todas las capas.
  • Base de datos (PostgreSQL en Docker). La despensa: donde se guarda todo, aunque se cierre el restaurante. Solo habla SQL; Hibernate, la implementación de JPA que usa Spring, traduce entre objetos Java y SQL.

La regla de oro: cada capa solo habla con la de al lado, y siempre en el mismo sentido: Controller → Service → Repository → base de datos. Las respuestas vuelven por el mismo camino al revés. Nadie se salta una capa.

El viaje de una petición

Así recorre la aplicación una petición para leer el producto 7. Fíjate en qué viaja entre capas en cada momento: primero texto HTTP, luego objetos Java, luego SQL, y de vuelta.

Buscar el producto 7: la petición baja por las capas hasta PostgreSQL y la respuesta vuelve como JSON.
  1. El cliente envía una petición HTTP. Postman envía GET /api/productos/7: GET significa «quiero leer» y la URL dice qué, el producto 7. Spring busca el método que encaja con @RequestMapping("/api/productos") y @GetMapping("/{id}").
  2. El controlador pasa el pedido al servicio. @PathVariable saca el 7 de la URL y lo convierte en un Long. A partir de aquí ya no hay HTTP: solo una llamada normal a un método Java.
  3. El servicio pide el producto al repositorio. La regla de negocio es sencilla: «búscalo y, si no existe, error». Para buscarlo usa el repositorio.
  4. El repositorio traduce a SQL. Nadie ha escrito findById: lo da JpaRepository. Hibernate lo convierte en un SELECT y lo envía a PostgreSQL. Con spring.jpa.show-sql=true lo ves en la consola.
  5. PostgreSQL devuelve la fila. La base de datos solo entiende de tablas y filas.
  6. La fila se convierte en un objeto Java: el Modelo. Hibernate crea un Producto y rellena sus atributos con las columnas. Llega dentro de un Optional, una caja que puede estar vacía, porque el producto podría no existir.
  7. El servicio devuelve el producto. Como la caja no está vacía, orElseThrow saca el Producto y lo devuelve.
  8. Spring convierte el objeto en JSON: la Vista. Como el controlador es un @RestController, Spring (con la librería Jackson) convierte el Producto en JSON y responde 200 OK. En una API REST, ese JSON es la «V» de MVC.

Y si el producto no existe

Ahora la misma petición con el id 99, que no está en la tabla. El viaje de ida es idéntico; lo interesante es la vuelta.

Pedir el producto 99: la base de datos devuelve cero filas y es el servicio quien lo convierte en un error, que Spring responde con un 404.

Los cuatro primeros pasos son los de antes, con 99 en lugar de 7. Después:

  1. No hay ninguna fila, y para la base de datos no es un error. La consulta ha funcionado; simplemente no ha encontrado nada.
  2. El repositorio devuelve una caja vacía. Para eso está el Optional: en lugar de devolver null y arriesgarse a un NullPointerException, devuelve Optional.empty().
  3. El servicio decide que esto sí es un error. Esa es la regla de negocio: orElseThrow lanza ProductoNoEncontradoException. Nadie la captura con try/catch, así que sube y atraviesa el controlador.
  4. Spring convierte la excepción en un 404. La excepción lleva @ResponseStatus(HttpStatus.NOT_FOUND), así que Spring responde con un 404. El servicio no sabe nada de HTTP: solo lanza una excepción con significado.
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ProductoNoEncontradoException extends RuntimeException {

    public ProductoNoEncontradoException(Long id) {
        super("No existe ningún producto con id " + id);
    }
}

Un detalle que despista: si arrancas el proyecto con ./mvnw spring-boot:run, el cuerpo de ese 404 trae el mensaje de la excepción («No existe ningún producto con id 99») y hasta la traza completa del error. Es cosa de Spring Boot DevTools, que está en el proyecto y solo actúa en desarrollo. En producción, por defecto, la respuesta solo dice el código y Not Found: la traza no debe llegar nunca al cliente.

Dónde vive cada parte

Cada capa tiene su paquete, y el nombre ya dice qué es: si buscas dónde se atiende una URL, ve a controller; si buscas una regla de negocio, a service.

crud/
├── compose.yaml                      PostgreSQL en Docker
├── pom.xml                           dependencias (Maven)
└── src/
    ├── main/java/com/saseformacion/crud/
    │   ├── CrudApplication.java      el main: arranca Spring
    │   ├── controller/
    │   │   └── ProductoController.java
    │   ├── service/
    │   │   └── ProductoService.java
    │   ├── repository/
    │   │   └── ProductoRepository.java
    │   ├── model/
    │   │   └── Producto.java
    │   └── exception/
    │       └── ProductoNoEncontradoException.java
    ├── main/resources/
    │   └── application.properties    conexión a la base de datos
    └── test/java/…                   los tests

@SpringBootApplication, en CrudApplication, le dice a Spring que busque componentes en ese paquete y en sus subpaquetes. Por eso todas las capas van dentro de com.saseformacion.crud.

El controlador solo traduce HTTP a Java y llama al servicio. Un método por operación del CRUD:

// Resumido: solo las anotaciones y la firma de cada método
@RestController
@RequestMapping("/api/productos")
public class ProductoController {

    @GetMapping              public List<Producto> listar()
    @GetMapping("/{id}")     public Producto buscarPorId(@PathVariable Long id)
    @PostMapping             public Producto crear(@RequestBody Producto producto)
    @PutMapping("/{id}")     public Producto actualizar(@PathVariable Long id, @RequestBody Producto producto)
    @DeleteMapping("/{id}")  public void eliminar(@PathVariable Long id)
}

El repositorio es una interfaz vacía. Al extender JpaRepository<Producto, Long> (la entidad y el tipo de su id), Spring Data JPA genera todos los métodos al arrancar. La anotación @Repository es opcional en estas interfaces, porque Spring Data las encuentra por extender JpaRepository; el proyecto la pone para que se vea qué capa es:

@Repository
public interface ProductoRepository extends JpaRepository<Producto, Long> {
    // findAll, findById, save, deleteById, existsById…
    // los genera Spring Data JPA
}

Y la entidad dice cómo se guarda un producto en la tabla productos:

@Entity
@Table(name = "productos")
public class Producto {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 100)
    private String nombre;

    @Column(length = 500)
    private String descripcion;

    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal precio;

    @Column(nullable = false)
    private Integer stock;

    // constructores, getters y setters
}

La separación también se nota en los tests: ProductoControllerTest prueba el controlador con un servicio falso (un mock), sin base de datos, y ProductoServiceTest prueba el servicio contra PostgreSQL. Sin capas, no se podría probar una parte sin arrastrar las demás.

Quién crea los objetos: la inyección de dependencias

En el proyecto no aparece nunca new ProductoService(). Al arrancar, Spring hace esto:

  1. main() llama a SpringApplication.run(...), y Spring recorre com.saseformacion.crud y sus subpaquetes buscando anotaciones.
  2. Para crear el controlador necesita un ProductoService, y para el servicio, un ProductoRepository. Así que empieza por abajo: Spring Data JPA ve la interfaz que extiende JpaRepository y crea un objeto que la implementa.
  3. Crea el servicio pasándole ese repositorio por el constructor.
  4. Crea el controlador pasándole el servicio.
  5. Arranca Tomcat, el servidor web, en el puerto 8080. La aplicación ya puede recibir peticiones.

A cada uno de esos objetos que crea y gestiona Spring se le llama bean, y a pasarle a cada clase lo que necesita, en lugar de que lo cree ella, inyección de dependencias. En el código se ve en los constructores:

public class ProductoController {

    private final ProductoService productoService;

    public ProductoController(ProductoService productoService) {
        this.productoService = productoService;
    }
}

¿Por qué por constructor? Porque se ve claramente qué necesita cada clase para funcionar, el campo puede ser final y en los tests se le puede pasar una pieza falsa. Si la clase tiene un único constructor, no hace falta anotarlo. También existe @Autowired encima del campo, que verás en muchos tutoriales, pero se recomienda el constructor.

Chuleta: CRUD, HTTP, capas y SQL

La misma operación tiene un nombre distinto en cada capa. En el controlador y el servicio, los métodos se llaman igual que la operación (listar, buscarPorId, crear, actualizar, eliminar):

CRUDHTTPRepositorySQLRespuesta
Leer todosGET /api/productosfindAll()SELECT200 OK
Leer unoGET /api/productos/7findById(7)SELECT … WHERE id = 7200 OK o 404
CrearPOST /api/productos + JSONsave(producto)INSERT201 Created
ActualizarPUT /api/productos/7 + JSONfindById + saveSELECT + UPDATE200 OK o 404
BorrarDELETE /api/productos/7existsById + deleteByIdSELECT + DELETE204 No Content o 404

Al crear, save recibe un producto con el id a null y Hibernate genera un INSERT; PostgreSQL asigna el id y el objeto vuelve con él. Por eso el servicio hace producto.setId(null) antes de guardar: el id lo decide la base de datos, y así nadie puede sobrescribir un producto existente enviando uno.

Los códigos de estado se leen por la primera cifra: 2xx, ha ido bien; 4xx, el cliente se ha equivocado (un 400 por datos inválidos, un 404 porque no existe); 5xx, el servidor se ha equivocado.

La «V»: API REST o páginas HTML

El Controller, el Service y el Repository son iguales en los dos casos. Lo único que cambia es qué devuelve el controlador, es decir, la Vista:

API REST, con @RestController. Devuelve datos en JSON, y quien los muestra es otro programa: una web en JavaScript, una app Android o Postman. Es el caso de este proyecto:

{"nombre":"Teclado","descripcion":"Teclado mecánico","precio":49.99,"stock":10,"id":7}

MVC clásico, con @Controller y Thymeleaf. Devuelve el nombre de una plantilla HTML y mete los datos en un Model; el servidor genera la página completa. Necesita la dependencia de Thymeleaf:

model.addAttribute("productos", lista);
return "productos/lista";  // templates/productos/lista.html

Errores frecuentes

  • El controlador llama al repositorio. Funciona, pero la lógica de negocio acaba repartida por los controladores y la separación deja de servir. El controlador habla con el servicio, siempre.
  • Lógica de negocio en el controlador. Si el controlador comprueba si el producto existe o calcula algo, está haciendo el trabajo del servicio. Su trabajo es traducir HTTP a Java y elegir el código de respuesta.
  • Crear los servicios con new. Un new ProductoService(...) deja fuera a Spring: tendrías que conseguir tú el repositorio, y el objeto no sería un bean, así que su @Transactional no haría nada. Se piden por el constructor y Spring los pasa.
  • Pensar que «no encontrado» es un error de la base de datos. Para PostgreSQL, cero filas es un resultado válido. Quien decide que es un error es el servicio, y quien lo convierte en un 404 es Spring.
  • Responder 200 a todo. Crear es 201, borrar sin nada que devolver es 204 y lo que no existe es 404. El cliente decide qué hacer según el código.

Comprueba lo que has entendido

1. ¿Qué capa decide si un producto que no existe es un error?

Ver la respuesta

El Service. Es una regla de negocio. Para la base de datos y el repositorio, cero filas no es un error: es un resultado válido.

2. En una API REST, ¿qué es la «Vista» de MVC?

Ver la respuesta

El JSON que se devuelve al cliente. Con @RestController, Spring convierte lo que devuelve el controlador en JSON. Con Thymeleaf, la vista sería una página HTML.

3. ¿Puede el Controller llamar directamente a ProductoRepository?

Ver la respuesta

Técnicamente sí, pero no debe: cada capa solo habla con la siguiente. Si no, la lógica de negocio acaba repartida por los controladores.

4. ¿Quién escribe el código de findById(), save() o deleteById()?

Ver la respuesta

Spring Data JPA, automáticamente. Solo se declara la interfaz que extiende JpaRepository<Producto, Long>; Spring Data genera la implementación al arrancar, y Hibernate genera el SQL.

5. ¿Qué hace @RequestBody en crear(@RequestBody Producto producto)?

Ver la respuesta

Convierte el JSON del cuerpo de la petición en un objeto Producto. Sacar un valor de la URL es trabajo de @PathVariable.

6. ¿Quién crea el objeto ProductoService y se lo pasa al controlador?

Ver la respuesta

Spring, al arrancar: es la inyección de dependencias. Crea un bean de cada clase anotada y lo pasa por el constructor a quien lo necesita. Se crea una vez, no en cada petición.

7. Un cliente pide DELETE /api/productos/7 y todo va bien. ¿Qué código recibe?

Ver la respuesta

204 No Content: la operación se ha hecho y no hay nada que devolver. 201 es para crear, y 404 sería si el producto 7 no existiera.

8. ¿Qué viaja entre el Repository y PostgreSQL?

Ver la respuesta

SQL y filas de tablas. Entre Controller, Service y Repository viajan objetos Java; entre el cliente y el Controller, HTTP con JSON.

Si solo te quedas con siete ideas

  1. MVC separa la aplicación en Modelo (datos), Vista (lo que se muestra) y Controlador (el intermediario).
  2. En Spring, la «M» se divide en capas: Service (reglas de negocio) y Repository (base de datos), más la entidad con los datos.
  3. El flujo siempre es Cliente → Controller → Service → Repository → base de datos, y la respuesta vuelve por el mismo camino.
  4. Cada capa solo habla con la siguiente. El controlador nunca toca el repositorio.
  5. El controlador traduce HTTP a Java; el repositorio traduce Java a SQL, gracias a JPA y Hibernate.
  6. Spring crea los objetos y los conecta por el constructor. No se hace new de un servicio ni de un repositorio.
  7. En una API REST, la Vista es el JSON; con Thymeleaf sería una página HTML.

El proyecto y los apuntes interactivos

El proyecto completo está en GitHub, con licencia MIT: gsanox/crud-springboot. Necesitas Java 21 y Docker; con docker compose up -d y ./mvnw spring-boot:run tienes la API en marcha, y ./mvnw test ejecuta sus tests. Lo mejor que puedes hacer después de leer esto es arrancarlo y seguir tú mismo una petición: mira en la consola el SQL que genera Hibernate y prueba a pedir un producto que no existe.

Los apuntes de los que sale este artículo también están publicados, en su versión interactiva: MVC explicado paso a paso. Ahí puedes elegir la operación (buscar, crear, borrar o pedir uno que no existe), avanzar paso a paso, abrir cada fichero del proyecto y hacer la autoevaluación con puntuación.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *