Spring Boot: de cero a una API REST

Spring Boot: de cero a una API REST

Spring es el framework de Java más usado en el mundo empresarial, y Spring Boot es la forma en que hoy se construye casi todo lo nuevo con él. Este texto recorre el camino completo: qué resuelve Spring, cómo se ve un proyecto de Spring Boot por dentro, cómo se crea uno desde cero, cómo funciona la inyección de dependencias y cómo termina expuesto en un endpoint REST que recibe y devuelve JSON.

Qué es Spring y por qué se usa en aplicaciones modernas

Spring nació en 2002 con una idea muy concreta: la mayoría del código de una aplicación no es lógica de negocio, sino plomería. Abrir una conexión, leer una petición, escribir una respuesta, manageear transacciones, resolver un identificador.

Spring llama a esa plomería contenedor de dependencias y se encarga de crearla, configurarla y conectarla. Tu clase se ocupa del negocio y Spring se ocupa del resto.

// Sin Spring: tú construyes todo el grafo de objetos a mano.
public class PedidoController {
    private final PedidoService pedidoService;

    public PedidoController() {
        PedidoRepository repositorio = new JdbcPedidoRepository();
        this.pedidoService = new PedidoService(repositorio);
    }
}

Ese constructor es el problema. La clase que vive en la capa web decide qué implementación usar en la capa de persistencia, así que cambiar de JDBC a JPA significa editar el controlador. La dependencia no es explícita: está escondida dentro de un new.

Spring invierte el control. Ya no construyes tú, describes qué necesitas y el contenedor lo resuelve.

// Con Spring: el contenedor resuelve las dependencias.
@Service
public class PedidoService {
    private final PedidoRepository repositorio;

    public PedidoService(PedidoRepository repositorio) {
        this.repositorio = repositorio;
    }
}

Lo que hace que esto importa en aplicaciones modernas no es la Sintaxis, son las consecuencias.

  • Desacoplamiento real. El controlador ya no menciona ninguna implementación concreta. El código de negocio se puede probar sin servidor web, sin base de datos y sin despliegue.
  • Transacciones declarativas. Un @Transactional sobre un método define un límite de consistencia sin una sola línea de control manual de commits.
  • Estandarización. Configuración, validación, seguridad, persistencia y observabilidad tienen un comportamiento por defecto conocido, lo que hace que un proyecto nuevo sea legible para alguien que no lo escribió.
  • El ecosistema es el activo real. Spring Security, Spring Data, Spring Batch, Spring Cloud y Spring AI están construidos sobre el mismo contenedor y se integran con el mismo modelo mental.

Spring Boot y la estructura de un proyecto

Spring Framework es la base. Spring Boot es la capa de encima que elimina la configuración manual, y esa es la diferencia entre escribir veinte archivos para arrancar un servidor web o escribir uno.

Spring Boot aporta autoconfiguración, starters que traen dependencias coherentes ya agrupadas, y un servidor embebido (Tomcat o Jetty) para que la aplicación sea un jar ejecutable sin instalar nada.

<!-- pom.xml : solo las dependencias que realmente usa el proyecto -->
<project>
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.4</version>
  </parent>

  <groupId>com.ejemplo</groupId>
  <artifactId>api-productos</artifactId>
  <version>0.0.1-SNAPSHOT</version>

  <properties>
    <java.version>21</java.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>
</project>

El starter-parent importa la gestión de versiones de todas las dependencias transitivas. Esa es una de las razones por las que en Spring Boot casi nunca se escriben números de versión: declarar spring-boot-starter-web sin versión ya es correcto.

La estructura de carpetas separa por capa técnica, no por caso de uso. Es la convención que Spring asume por defecto, y funciona porque el escaneo de componentes encuentra clases por paquete.

api-productos/
├── pom.xml
├── mvnw
├── src/main/java/com/ejemplo/api/
│   ├── ApiProductosApplication.java
│   ├── controller/ProductoController.java
│   ├── dto/ProductoRequest.java
│   ├── dto/ProductoResponse.java
│   ├── exception/ProductoNoEncontradoException.java
│   ├── exception/GlobalExceptionHandler.java
│   ├── repository/ProductoRepository.java
│   └── service/ProductoService.java
├── src/main/resources/
│   └── application.properties
└── src/test/java/com/ejemplo/api/
    └── ProductoControllerTest.java

Una observación sobre los nombres de paquete: controller, service y repository funcionan bien cuando el proyecto es una API. En cuanto el dominio crece, la convención empieza a empujar y aparecen los controller/pedido, service/pedido. Organizar por dominio y no por capa funciona mejor en cuanto el número de casos de uso crece.

Crear una aplicación Spring Boot desde cero

La forma más rápida es Spring Initializr (start.spring.io) o su equivalente por línea de comandos con spring. Se eligen el lenguaje, la versión de Java y los starters, y se descarga un proyecto que ya compila.

curl https://start.spring.io/starter.zip \
  -d type=maven-project \
  -d language=java \
  -d javaVersion=21 \
  -d dependencies=web,data-jpa,validation \
  -o api-productos.zip
# En la raíz del proyecto descargado
./mvnw spring-boot:run

Spring Boot busca una clase con @SpringBootApplication y usa su paquete como raíz del escaneo de componentes. Por eso el paquete de esa clase importa: si se queda en el nivel superior, encuentra todas las subclases; si se hunde, no encuentra nada.

package com.ejemplo.api;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ApiProductosApplication {

    public static void main(String[] args) {
        SpringApplication.run(ApiProductosApplication.class, args);
    }
}

Esa anotación es tres anotaciones en una: @SpringBootConfiguration (es una configuración), @ComponentScan (registra las clases anotadas con @Component y sus equivalentes) y @EnableAutoConfiguration (activa la configuración automática según lo que haya en el classpath).

Por eso agregar spring-boot-starter-data-jpa y una interfaz que extienda JpaRepository es suficiente para tener una capa de persistencia funcionando. Spring detecta la presencia de la librería, detecta el datasource, detecta el dialecto y monta todo sin una sola línea de configuración.

Para comprobarlo de inmediato:

curl http://localhost:8080/api/productos

Inversión de Control e Inyección de Dependencias

IoC (Inversión de Control) es el principio: el control sobre quién crea los objetos pasa de tu código al contenedor. DI (Dependency Injection) es el mecanismo concreto con el que el contenedor te los entrega.

Spring administra los beans como singletons por defecto: dentro de una aplicación existe una sola instancia de cada clase anotada. Eso tiene una consecuencia importante, una instancia compartida debe ser segura para usar desde varios hilos, así que nunca debes guardar estado de una petición en un campo del bean.

Las anotaciones que declaran un bean son:

  • @Component, la anotación genérica.
  • @Service, @Repository y @Controller, que son @Component con semántica propia. Son equivalentes a nivel de contenedor y se usan para que el código diga qué papel juega la clase.

La inyección por constructor es la forma recomendada, y la que Spring usa cuando hay un solo constructor sin anotación.

@Service
public class ProductoService {

    private final ProductoRepository repositorio;
    private final InventarioClient inventario;

    public ProductoService(ProductoRepository repositorio, InventarioClient inventario) {
        this.repositorio = repositorio;
        this.inventario = inventario;
    }

    public List<ProductoResponse> listar(String categoria, boolean soloActivos) {
        return repositorio.findByCategoria(categoria, soloActivos)
                .stream()
                .map(this::aResponse)
                .toList();
    }

    private ProductoResponse aResponse(Producto producto) {
        return new ProductoResponse(producto.getId(), producto.getNombre(),
                producto.getPrecio(), inventario.disponible(producto.getSku()));
    }
}

Ventajas concretas de esta forma frente a inyectar campos o llamar a un singleton estático:

  • El campo final garantiza que la dependencia no puede cambiar.
  • La dependencia es visible en la firma, no escondida.
  • La clase se puede construir en una prueba sin Spring, pasando una implementación falsa.
  • No usa campos nulos hasta que Spring los inyecta, así que es imposible olvidar uno.
@Component
class InventarioClient {

    boolean disponible(String sku) {
        return true;
    }
}

Cuando necesitas integrar una librería de terceros o aplicar configuración condicional, declaras el bean tú mismo con @Bean. Es el punto de entrada natural para envolver una dependencia externa en una clase propia.

@Configuration
public class ConfiguracionAlmacenamiento {

    @Bean
    public Almacenamiento almacenamiento(Environment entorno) {
        String bucket = entorno.getProperty("almacenamiento.bucket");
        return new AlmacenamientoS3(bucket);
    }
}

Controladores REST con @RestController

Un controlador es una clase que recibe peticiones HTTP. @RestController le dice a Spring que cada valor de retorno debe serializarse a JSON y escribir en el cuerpo de la respuesta, en lugar de resolverse como el nombre de una vista (eso último es lo que hace @Controller).

package com.ejemplo.api.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/productos")
public class ProductoController {

    private final ProductoService productoService;

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

    @GetMapping
    public ResponseEntity<List<ProductoResponse>> listar(
            @RequestParam(required = false) String categoria,
            @RequestParam(defaultValue = "false") boolean soloActivos) {
        return ResponseEntity.ok(productoService.listar(categoria, soloActivos));
    }
}

@RequestMapping en la clase fija el prefijo y @GetMapping en el método completa la ruta. Un GET /api/productos?categoria=tecnologia resuelve a ese método.

Devolver ResponseEntity en lugar del objeto directo es la opción más explícita: permite elegir el código de estado y los encabezados de respuesta sin cambiar la firma del método.

Rutas y métodos HTTP con @GetMapping y @PostMapping

Spring deduce el verbo HTTP del nombre de la anotación: @GetMapping, @PostMapping, @PutMapping, @PatchMapping y @DeleteMapping. También se pueden declarar explícitamente con @RequestMapping(method = RequestMethod.GET), que es la forma que verás en código más antiguo.

Las operaciones CRUD se traducen casi de forma directa:

@RestController
@RequestMapping("/api/productos")
public class ProductoController {

    private final ProductoService productoService;

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

    @GetMapping
    public List<ProductoResponse> listar() {
        return productoService.listar(null, false);
    }

    @GetMapping("/{id}")
    public ProductoResponse obtener(@PathVariable Long id) {
        return productoService.obtener(id);
    }

    @PostMapping
    public ResponseEntity<ProductoResponse> crear(@RequestBody ProductoRequest request) {
        ProductoResponse creado = productoService.crear(request);
        return ResponseEntity
                .created(URI.create("/api/productos/" + creado.id()))
                .body(creado);
    }

    @PutMapping("/{id}")
    public ProductoResponse actualizar(@PathVariable Long id,
                                       @RequestBody ProductoRequest request) {
        return productoService.actualizar(id, request);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void eliminar(@PathVariable Long id) {
        productoService.eliminar(id);
    }
}

Tres detalles que conviene tener claros:

  • GET, PUT y DELETE deben ser idempotentes. Repetir la misma llamada debe dejar el sistema en el mismo estado.
  • POST no lo es. Es el verbo para crear, y por eso devuelve 201 Created con la ubicación del recurso nuevo.
  • En @PutMapping("/{id}") el id viene de la ruta y el cuerpo trae los datos. Si no coinciden, el cuerpo manda o el id manda, pero eso ya es una decisión de diseño.

Las variables de ruta se declaran entre llaves y se leen con @PathVariable. Los signos de interrogación abren la zona de query string, que es lo que se lee con @RequestParam.

Recepción de parámetros y respuestas en JSON

Los tres mecanismos para recibir datos conviven y cada uno tiene su lugar.

OrigenAnotaciónEjemplo
Ruta@PathVariableGET /api/productos/42
Query string@RequestParamGET /api/productos?categoria=tecnologia
Cuerpo@RequestBodyPOST /api/productos con JSON

El cuerpo se recibe con @RequestBody y se convierte automáticamente a un objeto mediante Jackson, la librería de serialización que Spring Boot trae por defecto. Igual que en el resto del framework, lo más práctico es declarar records para los DTOs en lugar de setters y getters.

package com.ejemplo.api.dto;

import jakarta.validation.constraints.*;

public record ProductoRequest(
        @NotBlank(message = "el nombre es obligatorio")
        @Size(max = 120, message = "el nombre no puede exceder 120 caracteres")
        String nombre,

        @NotNull(message = "el precio es obligatorio")
        @Positive(message = "el precio debe ser mayor que cero")
        BigDecimal precio,

        @NotBlank(message = "el sku es obligatorio")
        String sku
) {
}

Un record es una clase inmutable con los campos como componentes del constructor. Al ser inmutable, se puede compartir entre hilos sin sincronización, y al deserializarse de forma automática evita tener un constructor vacío y un ciclo de setters que deja el objeto incompleto a medio construir.

Las anotaciones de jakarta.validation se ejecutan al deserializar, antes de que el método reciba nada. Para que funcionen hay que declararlo explícitamente.

@PostMapping
public ResponseEntity<ProductoResponse> crear(@Valid @RequestBody ProductoRequest request) {
    ProductoResponse creado = productoService.crear(request);
    return ResponseEntity
            .created(URI.create("/api/productos/" + creado.id()))
            .body(creado);
}

@Valid es lo que dispara las validaciones. Sin él, el objeto llega al servicio sin comprobar.

package com.ejemplo.api.dto;

import java.math.BigDecimal;

public record ProductoResponse(
        Long id,
        String nombre,
        BigDecimal precio,
        String sku,
        boolean disponible
) {
}

Este record es el que se serializa. Separar el DTO de entrada del de salida evita dos problemas habituales: filtrar hacia el cliente campos internos que no debería ver, y que un cambio en la base de datos rompa el contrato público de la API.

{
  "id": 42,
  "nombre": "Teclado mecánico",
  "precio": 1899.00,
  "sku": "TEC-001",
  "disponible": true
}

Sobre el status code, la convención es que el código lo pone el servidor y el cliente solo lo reporta.

CódigoSignificadoCuándo
200 OKCorrectoLa operación se completó
201 CreatedCreadoRecurso nuevo, con Location
204 No ContentSin cuerpoBorrado exitoso
400 Bad RequestEntrada inválidaFaltan campos o no validan
404 Not FoundNo existeEl id no corresponde a nada
500 Internal Server ErrorError del servidorExcepción no controlada

Spring Boot dentro de una arquitectura de microservicios

Un microservicio es, en lo esencial, un proceso que se despliega de forma independiente, con su propia base de datos o su propio esquema, y que se comunica con los demás por red. Spring Boot es el punto de partida habitual en Java porque produce exactamente ese formato: un proceso, un jar, una configuración externa.

CREATE TABLE producto (
    id    BIGINT PRIMARY KEY,
    nombre VARCHAR(120) NOT NULL,
    precio DECIMAL(10, 2) NOT NULL,
    sku   VARCHAR(40) UNIQUE NOT NULL
);
package com.ejemplo.api.repository;

import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductoRepository extends JpaRepository<Producto, Long> {

    java.util.List<Producto> findByNombreContainingIgnoreCase(String texto);
}

Eso es todo. Spring Data genera la implementación, detecta el nombre del método, escribe el SELECT y arma el paginador. Los servicios no comparten modelo de datos entre sí: cada uno expone su propia API.

Lo que Spring Boot resuelve dentro de este escenario concreto:

  • Autoconfiguración. El mismo jar funciona en desarrollo y en producción cambiando solo las propiedades. No hay perfiles de configuración embebidos en el código.
  • Configuración por entorno. Los valores sensibles (contraseñas, URLs) se leen de variables de entorno o de un gestor de secretos, nunca se escriben en el repositorio.
  • Escalado independiente. Como cada servicio es un proceso con su propio nombre, se pueden escalar réplicas de uno sin tocar los demás.
  • Observabilidad. Los endpoints de Actuator exponen métricas y salud sin escribir código de instrumentación.
# application.properties
spring.application.name=api-productos
server.port=8080

spring.datasource.url=${DB_URL}
spring.datasource.username=${DB_USER}
spring.datasource.password=${DB_PASSWORD}

management.endpoints.web.exposure.include=health,info,metrics
export DB_URL=jdbc:postgresql://localhost:5432/productos
export DB_USER=app
export DB_PASSWORD=***
./mvnw spring-boot:run

${DB_PASSWORD} es una referencia a una variable de entorno. Si no existe, la aplicación falla al arrancar en lugar de levantar con una contraseña vacía, que es el comportamiento correcto para un despliegue.

Lo que Spring Boot no resuelve es el problema distribuido. No descubren servicios entre sí, no reintentan de forma fiable ni te dan trazabilidad. Eso es territorio de Spring Cloud, y sigue siendo un problema inherente a cualquier arquitectura de microservicios, sin importar el lenguaje.

La respuesta honesta a la pregunta de si necesitas microservicios: no automatices la distribución. Un monolito modular bien diseñado es más fácil de desplegar, más rápido de ejecutar y más barato de operar que diez servicios que se llaman por red. Los microservicios se justifican cuando los equipos y los ciclos de despliegue son independientes entre sí, no cuando el proyecto simplemente creció.

Manejo de errores con @ExceptionHandler

Sin tratamiento explícito, cualquier excepción no controlada produce una respuesta con el stack trace completo. Spring Boot sí define una respuesta por defecto con código 500, pero el cuerpo puede filtrar nombres de clases, rutas y mensajes de la base de datos.

La forma más simple de evitarlo es lanzar excepciones propias que expresen el problema en el dominio y atraparlas en un manejador.

package com.ejemplo.api.exception;

public class ProductoNoEncontradoException extends RuntimeException {

    private final Long id;

    public ProductoNoEncontradoException(Long id) {
        super("No existe un producto con el id " + id);
        this.id = id;
    }

    public Long getId() {
        return id;
    }
}
package com.ejemplo.api.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.Map;
import java.util.stream.Collectors;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ProductoNoEncontradoException.class)
    public ResponseEntity<Map<String, Object>> productoNoEncontrado(
            ProductoNoEncontradoException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(Map.of(
                        "error", "PRODUCTO_NO_ENCONTRADO",
                        "mensaje", ex.getMessage()
                ));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> validacionFallida(
            MethodArgumentNotValidException ex) {
        Map<String, String> campos = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .collect(Collectors.toMap(
                        error -> error.getField(),
                        error -> error.getDefaultMessage(),
                        (primero, segundo) -> primero
                ));

        return ResponseEntity.badRequest().body(Map.of(
                "error", "VALIDACION",
                "campos", campos
        ));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<Map<String, Object>> errorInesperado(Exception ex) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(Map.of(
                        "error", "ERROR_INTERNO",
                        "mensaje", "Ocurrió un error inesperado"
                ));
    }
}

@RestControllerAdvice es la versión global de @ExceptionHandler: en lugar de aplicar a un controlador, aplica a todos. El método anotado con @ExceptionHandler se ejecuta cuando se lanza una excepción del tipo indicado, y el código que devuelve es la respuesta HTTP completa.

Un detalle importante sobre el último método: el @ExceptionHandler(Exception.class) es una red de seguridad, no un lugar para escribir lógica. Su trabajo es evitar que un error interno llegue al cliente con información sensible. En desarrollo conviene registrar el stack trace completo en el log, y nunca en la respuesta.

Los errores de validación también tienen atajo. ResponseStatusException permite declarar el código HTTP junto con el error sin escribir una clase nueva.

@DeleteMapping("/{id}")
public void eliminar(@PathVariable Long id) {
    if (!repositorio.existsById(id)) {
        throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Producto no encontrado");
    }
    repositorio.deleteById(id);
}

Lo que el cliente recibe queda consistente, con un cuerpo con la misma forma en todos los casos.

{
  "error": "PRODUCTO_NO_ENCONTRADO",
  "mensaje": "No existe un producto con el id 999"
}

La idea importante

Spring Boot no es una colección de anotaciones. Es un contenedor que se encarga del ciclo de vida de los objetos y una capa de convenciones que elimina la configuración que nunca aporta valor.

Lo que vale la pena retener de todo lo anterior es una sola idea: el framework resuelve la plomería para que tú escribas lógica de negocio. Inyectar dependencias, mapear rutas, convertir JSON y manejar errores existen porque son tareas que se repitieron en todas las aplicaciones y que siempre se resolvieron de la misma forma.

Cuando una clase de Spring no parece suficiente, el problema suele estar en un detalle de diseño: un constructor con demasiadas dependencias indica que la clase hace demasiadas cosas, y un manejador global que se encoge de todo indica que las excepciones no están expresadas en el dominio. Arreglar el diseño detrás del síntoma casi siempre da menos código que la anotación que lo esconde.

© Arturo Juárez Monroy