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.Productoes 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.
- El cliente envía una petición HTTP. Postman envía
GET /api/productos/7:GETsignifica «quiero leer» y la URL dice qué, el producto 7. Spring busca el método que encaja con@RequestMapping("/api/productos")y@GetMapping("/{id}"). - El controlador pasa el pedido al servicio.
@PathVariablesaca el7de la URL y lo convierte en unLong. A partir de aquí ya no hay HTTP: solo una llamada normal a un método Java. - 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.
- El repositorio traduce a SQL. Nadie ha escrito
findById: lo daJpaRepository. Hibernate lo convierte en unSELECTy lo envía a PostgreSQL. Conspring.jpa.show-sql=truelo ves en la consola. - PostgreSQL devuelve la fila. La base de datos solo entiende de tablas y filas.
- La fila se convierte en un objeto Java: el Modelo. Hibernate crea un
Productoy rellena sus atributos con las columnas. Llega dentro de unOptional, una caja que puede estar vacía, porque el producto podría no existir. - El servicio devuelve el producto. Como la caja no está vacía,
orElseThrowsaca elProductoy lo devuelve. - Spring convierte el objeto en JSON: la Vista. Como el controlador es un
@RestController, Spring (con la librería Jackson) convierte elProductoen JSON y responde200 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.
Los cuatro primeros pasos son los de antes, con 99 en lugar de 7. Después:
- No hay ninguna fila, y para la base de datos no es un error. La consulta ha funcionado; simplemente no ha encontrado nada.
- El repositorio devuelve una caja vacía. Para eso está el
Optional: en lugar de devolvernully arriesgarse a unNullPointerException, devuelveOptional.empty(). - El servicio decide que esto sí es un error. Esa es la regla de negocio:
orElseThrowlanzaProductoNoEncontradoException. Nadie la captura contry/catch, así que sube y atraviesa el controlador. - Spring convierte la excepción en un 404. La excepción lleva
@ResponseStatus(HttpStatus.NOT_FOUND), así que Spring responde con un404. 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:
main()llama aSpringApplication.run(...), y Spring recorrecom.saseformacion.crudy sus subpaquetes buscando anotaciones.- Para crear el controlador necesita un
ProductoService, y para el servicio, unProductoRepository. Así que empieza por abajo: Spring Data JPA ve la interfaz que extiendeJpaRepositoryy crea un objeto que la implementa. - Crea el servicio pasándole ese repositorio por el constructor.
- Crea el controlador pasándole el servicio.
- 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):
| CRUD | HTTP | Repository | SQL | Respuesta |
|---|---|---|---|---|
| Leer todos | GET /api/productos | findAll() | SELECT | 200 OK |
| Leer uno | GET /api/productos/7 | findById(7) | SELECT … WHERE id = 7 | 200 OK o 404 |
| Crear | POST /api/productos + JSON | save(producto) | INSERT | 201 Created |
| Actualizar | PUT /api/productos/7 + JSON | findById + save | SELECT + UPDATE | 200 OK o 404 |
| Borrar | DELETE /api/productos/7 | existsById + deleteById | SELECT + DELETE | 204 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.htmlErrores 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. Unnew ProductoService(...)deja fuera a Spring: tendrías que conseguir tú el repositorio, y el objeto no sería un bean, así que su@Transactionalno 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
404es Spring. - Responder
200a todo. Crear es201, borrar sin nada que devolver es204y lo que no existe es404. 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
- MVC separa la aplicación en Modelo (datos), Vista (lo que se muestra) y Controlador (el intermediario).
- En Spring, la «M» se divide en capas: Service (reglas de negocio) y Repository (base de datos), más la entidad con los datos.
- El flujo siempre es Cliente → Controller → Service → Repository → base de datos, y la respuesta vuelve por el mismo camino.
- Cada capa solo habla con la siguiente. El controlador nunca toca el repositorio.
- El controlador traduce HTTP a Java; el repositorio traduce Java a SQL, gracias a JPA y Hibernate.
- Spring crea los objetos y los conecta por el constructor. No se hace
newde un servicio ni de un repositorio. - 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.

