Cómo crear tu primera API REST con Spring Boot y MySQL

D
DanisCh
(Actualizado: ) • 20 min de lectura
Cómo crear tu primera API REST con Spring Boot y MySQL
Java Programación

Spring Boot es el framework más usado en el mundo empresarial Java para construir APIs REST. Combina la potencia del ecosistema Spring con una configuración mínima que te permite tener un servidor funcionando en minutos. En esta guía vas a construir una API REST completa con operaciones CRUD (Crear, Leer, Actualizar y Eliminar) conectada a una base de datos MySQL, desde cero y paso a paso.

Al terminar tendrás una API funcional para gestionar productos, con endpoints documentados y listos para ser consumidos desde cualquier frontend o herramienta de testing como Postman.

Requisitos previos

Antes de empezar, asegúrate de tener instalado en tu máquina:

  • Java 21 o superior. Puedes verificarlo con java -version.
  • Maven 3.9+ o usar el Maven Wrapper que genera Spring Initializr.
  • MySQL 8.x ejecutándose localmente. Puedes instalarlo directamente o usar Docker: docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=root mysql:8.
  • Un IDE como IntelliJ IDEA (recomendado) o VS Code con la extensión de Java.
  • Postman o cualquier cliente HTTP para probar los endpoints.

Paso 1: Crear el proyecto con Spring Initializr

La forma más rápida de empezar un proyecto Spring Boot es usando Spring Initializr, la herramienta oficial disponible en start.spring.io. Configúralo así:

  • Project: Maven
  • Language: Java
  • Spring Boot: 3.3.x (la versión estable más reciente)
  • Group: com.ejemplo
  • Artifact: tienda-api
  • Packaging: Jar
  • Java: 21

En el apartado de dependencias, añade estas cuatro:

  • Spring Web — para crear controladores REST y gestionar peticiones HTTP.
  • Spring Data JPA — para acceder a la base de datos con el ORM Hibernate.
  • MySQL Driver — el driver JDBC para conectar con MySQL.
  • Validation — para validar los datos de entrada con anotaciones.

Haz clic en Generate, descarga el ZIP, descomprímelo y ábrelo en tu IDE.

Si prefieres la línea de comandos, el equivalente con Spring CLI es:

spring init \
  --dependencies=web,data-jpa,mysql,validation \
  --group-id=com.ejemplo \
  --artifact-id=tienda-api \
  --java-version=21 \
  tienda-api

La estructura del proyecto generado quedará así:

tienda-api/
├── src/
│   ├── main/
│   │   ├── java/com/ejemplo/tiendaapi/
│   │   │   └── TiendaApiApplication.java
│   │   └── resources/
│   │       └── application.properties
│   └── test/
│       └── java/com/ejemplo/tiendaapi/
│           └── TiendaApiApplicationTests.java
├── pom.xml
└── mvnw

Paso 2: Configurar la conexión a MySQL

Abre el archivo src/main/resources/application.properties y añade la configuración de conexión a la base de datos:

# Conexión a MySQL
spring.datasource.url=jdbc:mysql://localhost:3306/tienda_db?createDatabaseIfNotExist=true&useSSL=false&serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# JPA e Hibernate
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect

# Nombre de la aplicación
spring.application.name=tienda-api

# Puerto del servidor (8080 por defecto)
server.port=8080

El parámetro más importante es spring.jpa.hibernate.ddl-auto. Sus valores posibles son:

  • create: elimina y crea las tablas cada vez que arranca la aplicación. Solo para desarrollo inicial.
  • update: crea las tablas si no existen y aplica los cambios sin borrar datos. Útil en desarrollo.
  • validate: verifica que las tablas coinciden con las entidades pero no las modifica. Para producción.
  • none: no hace nada. Para producción cuando usas migraciones con Flyway o Liquibase.

El parámetro createDatabaseIfNotExist=true en la URL crea automáticamente la base de datos tienda_db si no existe, así no tienes que crearla manualmente.

Para entornos reales, nunca pongas credenciales directamente en el archivo de propiedades. Usa variables de entorno:

spring.datasource.username=${DB_USERNAME:root}
spring.datasource.password=${DB_PASSWORD:root}

Paso 3: Crear la entidad Producto

En Spring Data JPA, una entidad es una clase Java que se mapea a una tabla de la base de datos. Cada atributo de la clase corresponde a una columna de la tabla.

Crea el paquete model y dentro el archivo Producto.java:

// src/main/java/com/ejemplo/tiendaapi/model/Producto.java
package com.ejemplo.tiendaapi.model;

import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;

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

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

    @NotBlank(message = "El nombre es obligatorio")
    @Size(min = 2, max = 100, message = "El nombre debe tener entre 2 y 100 caracteres")
    @Column(nullable = false, length = 100)
    private String nombre;

    @Size(max = 500, message = "La descripción no puede superar los 500 caracteres")
    @Column(length = 500)
    private String descripcion;

    @NotNull(message = "El precio es obligatorio")
    @DecimalMin(value = "0.0", inclusive = false, message = "El precio debe ser mayor que cero")
    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal precio;

    @Min(value = 0, message = "El stock no puede ser negativo")
    @Column(nullable = false)
    private Integer stock = 0;

    @Column(nullable = false)
    private Boolean disponible = true;

    @Column(name = "creado_en", updatable = false)
    private LocalDateTime creadoEn;

    @Column(name = "actualizado_en")
    private LocalDateTime actualizadoEn;

    @PrePersist
    protected void onCreate() {
        creadoEn     = LocalDateTime.now();
        actualizadoEn = LocalDateTime.now();
    }

    @PreUpdate
    protected void onUpdate() {
        actualizadoEn = LocalDateTime.now();
    }

    // Constructores
    public Producto() {}

    public Producto(String nombre, String descripcion, BigDecimal precio, Integer stock) {
        this.nombre      = nombre;
        this.descripcion = descripcion;
        this.precio      = precio;
        this.stock       = stock;
    }

    // Getters y Setters
    public Long getId()                        { return id; }
    public void setId(Long id)                 { this.id = id; }

    public String getNombre()                  { return nombre; }
    public void setNombre(String nombre)       { this.nombre = nombre; }

    public String getDescripcion()             { return descripcion; }
    public void setDescripcion(String d)       { this.descripcion = d; }

    public BigDecimal getPrecio()              { return precio; }
    public void setPrecio(BigDecimal precio)   { this.precio = precio; }

    public Integer getStock()                  { return stock; }
    public void setStock(Integer stock)        { this.stock = stock; }

    public Boolean getDisponible()             { return disponible; }
    public void setDisponible(Boolean d)       { this.disponible = d; }

    public LocalDateTime getCreadoEn()         { return creadoEn; }
    public LocalDateTime getActualizadoEn()    { return actualizadoEn; }
}

Las anotaciones más importantes:

  • @Entity: marca la clase como una entidad JPA que se mapea a una tabla.
  • @Table(name = "productos"): especifica el nombre de la tabla en la base de datos.
  • @Id + @GeneratedValue: define la clave primaria con autoincremento gestionado por la base de datos.
  • @Column: personaliza cómo se mapea el atributo a la columna (nombre, longitud, nulabilidad).
  • @PrePersist y @PreUpdate: callbacks que se ejecutan automáticamente antes de guardar o actualizar.
  • Las anotaciones de validación (@NotBlank, @NotNull, @DecimalMin) se aplican cuando el controlador recibe datos del cliente.

Paso 4: Crear el repositorio

En Spring Data JPA, un repositorio es una interfaz que proporciona automáticamente los métodos de acceso a la base de datos. Solo tienes que extender JpaRepository y Spring genera toda la implementación en tiempo de ejecución.

Crea el paquete repository y dentro ProductoRepository.java:

// src/main/java/com/ejemplo/tiendaapi/repository/ProductoRepository.java
package com.ejemplo.tiendaapi.repository;

import com.ejemplo.tiendaapi.model.Producto;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.math.BigDecimal;
import java.util.List;

@Repository
public interface ProductoRepository extends JpaRepository<Producto, Long> {

    // Spring genera el SQL automáticamente a partir del nombre del método
    List<Producto> findByDisponibleTrue();

    List<Producto> findByNombreContainingIgnoreCase(String nombre);

    List<Producto> findByPrecioBetween(BigDecimal min, BigDecimal max);

    List<Producto> findByStockGreaterThan(int cantidad);

    // JPQL: query orientada a objetos (usa nombres de clases y atributos, no tablas)
    @Query("SELECT p FROM Producto p WHERE p.precio < :precio AND p.disponible = true")
    List<Producto> findDisponiblesConPrecioMenorA(@Param("precio") BigDecimal precio);

    // SQL nativo cuando necesitas algo que JPQL no puede expresar fácilmente
    @Query(value = "SELECT * FROM productos ORDER BY precio ASC LIMIT :limite", nativeQuery = true)
    List<Producto> findLosMasBaratos(@Param("limite") int limite);

    boolean existsByNombre(String nombre);
}

Al extender JpaRepository<Producto, Long> obtienes gratis métodos como save(), findById(), findAll(), deleteById(), count() y muchos más. Spring interpreta el nombre de los métodos que declares (como findByNombreContainingIgnoreCase) y genera la consulta SQL correspondiente automáticamente.

Paso 5: Crear los DTOs

Un DTO (Data Transfer Object) es un objeto que define exactamente qué datos entran y salen de la API. Separa la representación de la API de la entidad interna, lo que te da control total sobre qué expones y qué validas.

Crea el paquete dto con dos clases:

// src/main/java/com/ejemplo/tiendaapi/dto/ProductoRequest.java
// Lo que el cliente envía al crear o actualizar un producto
package com.ejemplo.tiendaapi.dto;

import jakarta.validation.constraints.*;
import java.math.BigDecimal;

public class ProductoRequest {

    @NotBlank(message = "El nombre es obligatorio")
    @Size(min = 2, max = 100)
    private String nombre;

    @Size(max = 500)
    private String descripcion;

    @NotNull(message = "El precio es obligatorio")
    @DecimalMin(value = "0.0", inclusive = false, message = "El precio debe ser mayor que cero")
    private BigDecimal precio;

    @Min(value = 0, message = "El stock no puede ser negativo")
    private Integer stock = 0;

    // Getters y Setters
    public String getNombre()                  { return nombre; }
    public void setNombre(String nombre)       { this.nombre = nombre; }
    public String getDescripcion()             { return descripcion; }
    public void setDescripcion(String d)       { this.descripcion = d; }
    public BigDecimal getPrecio()              { return precio; }
    public void setPrecio(BigDecimal precio)   { this.precio = precio; }
    public Integer getStock()                  { return stock; }
    public void setStock(Integer stock)        { this.stock = stock; }
}
// src/main/java/com/ejemplo/tiendaapi/dto/ProductoResponse.java
// Lo que la API devuelve al cliente
package com.ejemplo.tiendaapi.dto;

import com.ejemplo.tiendaapi.model.Producto;
import java.math.BigDecimal;
import java.time.LocalDateTime;

public class ProductoResponse {

    private Long          id;
    private String        nombre;
    private String        descripcion;
    private BigDecimal    precio;
    private Integer       stock;
    private Boolean       disponible;
    private LocalDateTime creadoEn;
    private LocalDateTime actualizadoEn;

    // Constructor que convierte una entidad en DTO
    public static ProductoResponse from(Producto producto) {
        ProductoResponse dto = new ProductoResponse();
        dto.id            = producto.getId();
        dto.nombre        = producto.getNombre();
        dto.descripcion   = producto.getDescripcion();
        dto.precio        = producto.getPrecio();
        dto.stock         = producto.getStock();
        dto.disponible    = producto.getDisponible();
        dto.creadoEn      = producto.getCreadoEn();
        dto.actualizadoEn = producto.getActualizadoEn();
        return dto;
    }

    // Getters (solo lectura, sin setters)
    public Long          getId()            { return id; }
    public String        getNombre()        { return nombre; }
    public String        getDescripcion()   { return descripcion; }
    public BigDecimal    getPrecio()        { return precio; }
    public Integer       getStock()         { return stock; }
    public Boolean       getDisponible()    { return disponible; }
    public LocalDateTime getCreadoEn()      { return creadoEn; }
    public LocalDateTime getActualizadoEn() { return actualizadoEn; }
}

Paso 6: Crear la capa de servicio

La capa de servicio contiene la lógica de negocio. El controlador recibe la petición HTTP, la pasa al servicio, el servicio la procesa y llama al repositorio. Esta separación hace el código más fácil de testear y mantener.

Crea el paquete service y dentro ProductoService.java:

// src/main/java/com/ejemplo/tiendaapi/service/ProductoService.java
package com.ejemplo.tiendaapi.service;

import com.ejemplo.tiendaapi.dto.ProductoRequest;
import com.ejemplo.tiendaapi.dto.ProductoResponse;
import com.ejemplo.tiendaapi.exception.ProductoNotFoundException;
import com.ejemplo.tiendaapi.exception.ProductoYaExisteException;
import com.ejemplo.tiendaapi.model.Producto;
import com.ejemplo.tiendaapi.repository.ProductoRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@Transactional
public class ProductoService {

    private final ProductoRepository repositorio;

    // Inyección de dependencias por constructor (recomendada sobre @Autowired)
    public ProductoService(ProductoRepository repositorio) {
        this.repositorio = repositorio;
    }

    @Transactional(readOnly = true)
    public List<ProductoResponse> obtenerTodos() {
        return repositorio.findAll()
                .stream()
                .map(ProductoResponse::from)
                .toList();
    }

    @Transactional(readOnly = true)
    public ProductoResponse obtenerPorId(Long id) {
        Producto producto = repositorio.findById(id)
                .orElseThrow(() -> new ProductoNotFoundException(id));
        return ProductoResponse.from(producto);
    }

    @Transactional(readOnly = true)
    public List<ProductoResponse> buscarPorNombre(String nombre) {
        return repositorio.findByNombreContainingIgnoreCase(nombre)
                .stream()
                .map(ProductoResponse::from)
                .toList();
    }

    @Transactional(readOnly = true)
    public List<ProductoResponse> obtenerDisponibles() {
        return repositorio.findByDisponibleTrue()
                .stream()
                .map(ProductoResponse::from)
                .toList();
    }

    public ProductoResponse crear(ProductoRequest request) {
        if (repositorio.existsByNombre(request.getNombre())) {
            throw new ProductoYaExisteException(request.getNombre());
        }

        Producto producto = new Producto(
                request.getNombre(),
                request.getDescripcion(),
                request.getPrecio(),
                request.getStock()
        );

        Producto guardado = repositorio.save(producto);
        return ProductoResponse.from(guardado);
    }

    public ProductoResponse actualizar(Long id, ProductoRequest request) {
        Producto producto = repositorio.findById(id)
                .orElseThrow(() -> new ProductoNotFoundException(id));

        producto.setNombre(request.getNombre());
        producto.setDescripcion(request.getDescripcion());
        producto.setPrecio(request.getPrecio());
        producto.setStock(request.getStock());

        Producto actualizado = repositorio.save(producto);
        return ProductoResponse.from(actualizado);
    }

    public void eliminar(Long id) {
        if (!repositorio.existsById(id)) {
            throw new ProductoNotFoundException(id);
        }
        repositorio.deleteById(id);
    }

    public ProductoResponse cambiarDisponibilidad(Long id, boolean disponible) {
        Producto producto = repositorio.findById(id)
                .orElseThrow(() -> new ProductoNotFoundException(id));
        producto.setDisponible(disponible);
        return ProductoResponse.from(repositorio.save(producto));
    }
}

Paso 7: Manejo de excepciones personalizadas

En lugar de dejar que Spring devuelva errores genéricos, vamos a definir excepciones propias y un manejador global que las convierta en respuestas JSON con el código HTTP correcto.

Crea el paquete exception:

// ProductoNotFoundException.java
package com.ejemplo.tiendaapi.exception;

public class ProductoNotFoundException extends RuntimeException {
    public ProductoNotFoundException(Long id) {
        super("Producto no encontrado con id: " + id);
    }
}
// ProductoYaExisteException.java
package com.ejemplo.tiendaapi.exception;

public class ProductoYaExisteException extends RuntimeException {
    public ProductoYaExisteException(String nombre) {
        super("Ya existe un producto con el nombre: " + nombre);
    }
}
// GlobalExceptionHandler.java
package com.ejemplo.tiendaapi.exception;

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

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    // Estructura estándar de respuesta de error
    private Map<String, Object> construirError(HttpStatus status, String mensaje) {
        Map<String, Object> error = new HashMap<>();
        error.put("timestamp", LocalDateTime.now().toString());
        error.put("status",    status.value());
        error.put("error",     status.getReasonPhrase());
        error.put("mensaje",   mensaje);
        return error;
    }

    @ExceptionHandler(ProductoNotFoundException.class)
    public ResponseEntity<Map<String, Object>> handleNotFound(ProductoNotFoundException ex) {
        return ResponseEntity
                .status(HttpStatus.NOT_FOUND)
                .body(construirError(HttpStatus.NOT_FOUND, ex.getMessage()));
    }

    @ExceptionHandler(ProductoYaExisteException.class)
    public ResponseEntity<Map<String, Object>> handleConflict(ProductoYaExisteException ex) {
        return ResponseEntity
                .status(HttpStatus.CONFLICT)
                .body(construirError(HttpStatus.CONFLICT, ex.getMessage()));
    }

    // Errores de validación de @Valid
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
        Map<String, String> erroresCampos = new HashMap<>();
        ex.getBindingResult().getAllErrors().forEach(error -> {
            String campo   = ((FieldError) error).getField();
            String mensaje = error.getDefaultMessage();
            erroresCampos.put(campo, mensaje);
        });

        Map<String, Object> respuesta = new HashMap<>();
        respuesta.put("timestamp", LocalDateTime.now().toString());
        respuesta.put("status",    400);
        respuesta.put("error",     "Validación fallida");
        respuesta.put("campos",    erroresCampos);

        return ResponseEntity.badRequest().body(respuesta);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<Map<String, Object>> handleGeneric(Exception ex) {
        return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(construirError(HttpStatus.INTERNAL_SERVER_ERROR,
                        "Error interno del servidor"));
    }
}

Paso 8: Crear el controlador REST

El controlador es la capa que expone los endpoints HTTP. Recibe las peticiones, llama al servicio y devuelve las respuestas con el código de estado correcto.

// src/main/java/com/ejemplo/tiendaapi/controller/ProductoController.java
package com.ejemplo.tiendaapi.controller;

import com.ejemplo.tiendaapi.dto.ProductoRequest;
import com.ejemplo.tiendaapi.dto.ProductoResponse;
import com.ejemplo.tiendaapi.service.ProductoService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/v1/productos")
@CrossOrigin(origins = "*")   // permite peticiones desde cualquier origen (ajustar en producción)
public class ProductoController {

    private final ProductoService servicio;

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

    // GET /api/v1/productos
    @GetMapping
    public ResponseEntity<List<ProductoResponse>> obtenerTodos() {
        return ResponseEntity.ok(servicio.obtenerTodos());
    }

    // GET /api/v1/productos/disponibles
    @GetMapping("/disponibles")
    public ResponseEntity<List<ProductoResponse>> obtenerDisponibles() {
        return ResponseEntity.ok(servicio.obtenerDisponibles());
    }

    // GET /api/v1/productos/buscar?nombre=laptop
    @GetMapping("/buscar")
    public ResponseEntity<List<ProductoResponse>> buscar(@RequestParam String nombre) {
        return ResponseEntity.ok(servicio.buscarPorNombre(nombre));
    }

    // GET /api/v1/productos/{id}
    @GetMapping("/{id}")
    public ResponseEntity<ProductoResponse> obtenerPorId(@PathVariable Long id) {
        return ResponseEntity.ok(servicio.obtenerPorId(id));
    }

    // POST /api/v1/productos
    @PostMapping
    public ResponseEntity<ProductoResponse> crear(@Valid @RequestBody ProductoRequest request) {
        ProductoResponse creado = servicio.crear(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(creado);
    }

    // PUT /api/v1/productos/{id}
    @PutMapping("/{id}")
    public ResponseEntity<ProductoResponse> actualizar(
            @PathVariable Long id,
            @Valid @RequestBody ProductoRequest request) {
        return ResponseEntity.ok(servicio.actualizar(id, request));
    }

    // PATCH /api/v1/productos/{id}/disponibilidad
    @PatchMapping("/{id}/disponibilidad")
    public ResponseEntity<ProductoResponse> cambiarDisponibilidad(
            @PathVariable Long id,
            @RequestBody Map<String, Boolean> body) {
        boolean disponible = body.getOrDefault("disponible", true);
        return ResponseEntity.ok(servicio.cambiarDisponibilidad(id, disponible));
    }

    // DELETE /api/v1/productos/{id}
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> eliminar(@PathVariable Long id) {
        servicio.eliminar(id);
        return ResponseEntity.noContent().build();
    }
}

Las anotaciones del controlador:

  • @RestController: combina @Controller y @ResponseBody. Todos los métodos devuelven datos serializados a JSON automáticamente.
  • @RequestMapping("/api/v1/productos"): prefijo de ruta para todos los endpoints de esta clase.
  • @GetMapping, @PostMapping, etc.: mapean cada método HTTP a un método Java.
  • @PathVariable: extrae un valor de la URL (/productos/42 → id = 42).
  • @RequestParam: extrae un parámetro de la query string (?nombre=laptop).
  • @RequestBody: deserializa el cuerpo JSON de la petición al objeto indicado.
  • @Valid: activa la validación de las anotaciones de constraint definidas en el DTO.

Paso 9: Arrancar y probar la API

Arranca la aplicación ejecutando la clase principal o con Maven:

./mvnw spring-boot:run

Si todo está correcto, verás en la consola algo como:

Tomcat started on port 8080 (http) with context path ''
Started TiendaApiApplication in 3.2 seconds

Hibernate habrá creado automáticamente la tabla productos en MySQL. Ahora prueba los endpoints:

Crear un producto (POST)

curl -X POST http://localhost:8080/api/v1/productos \
  -H "Content-Type: application/json" \
  -d '{
    "nombre": "Laptop Pro 15",
    "descripcion": "Portátil de alta gama con 32GB RAM",
    "precio": 1299.99,
    "stock": 10
  }'

Respuesta esperada (201 Created):

{
  "id": 1,
  "nombre": "Laptop Pro 15",
  "descripcion": "Portátil de alta gama con 32GB RAM",
  "precio": 1299.99,
  "stock": 10,
  "disponible": true,
  "creadoEn": "2026-05-15T10:30:00",
  "actualizadoEn": "2026-05-15T10:30:00"
}

Obtener todos los productos (GET)

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

Obtener un producto por ID (GET)

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

Buscar por nombre (GET)

curl "http://localhost:8080/api/v1/productos/buscar?nombre=laptop"

Actualizar un producto (PUT)

curl -X PUT http://localhost:8080/api/v1/productos/1 \
  -H "Content-Type: application/json" \
  -d '{
    "nombre": "Laptop Pro 15 v2",
    "descripcion": "Versión actualizada",
    "precio": 1199.99,
    "stock": 8
  }'

Cambiar disponibilidad (PATCH)

curl -X PATCH http://localhost:8080/api/v1/productos/1/disponibilidad \
  -H "Content-Type: application/json" \
  -d '{"disponible": false}'

Eliminar un producto (DELETE)

curl -X DELETE http://localhost:8080/api/v1/productos/1

Respuesta esperada: 204 No Content sin cuerpo.

Probar la validación

curl -X POST http://localhost:8080/api/v1/productos \
  -H "Content-Type: application/json" \
  -d '{"nombre": "", "precio": -5}'

Respuesta esperada (400 Bad Request):

{
  "timestamp": "2026-05-15T10:35:00",
  "status": 400,
  "error": "Validación fallida",
  "campos": {
    "nombre": "El nombre es obligatorio",
    "precio": "El precio debe ser mayor que cero"
  }
}

Paso 10: Añadir paginación y ordenación

Para APIs con muchos registros, devolver todos los datos de una vez no es práctico. Spring Data JPA tiene soporte nativo para paginación con Pageable.

// En el repositorio: añadir método paginado
Page<Producto> findByDisponibleTrue(Pageable pageable);
// En el servicio
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public Page<ProductoResponse> obtenerDisponiblesPaginados(Pageable pageable) {
    return repositorio.findByDisponibleTrue(pageable)
            .map(ProductoResponse::from);
}
// En el controlador
@GetMapping("/disponibles")
public ResponseEntity<Page<ProductoResponse>> obtenerDisponibles(
        @RequestParam(defaultValue = "0")  int pagina,
        @RequestParam(defaultValue = "10") int tamano,
        @RequestParam(defaultValue = "id") String ordenPor) {

    Pageable pageable = PageRequest.of(pagina, tamano, Sort.by(ordenPor));
    return ResponseEntity.ok(servicio.obtenerDisponiblesPaginados(pageable));
}

Ahora puedes paginar y ordenar con parámetros en la URL:

# Página 0, 5 elementos por página, ordenados por precio
curl "http://localhost:8080/api/v1/productos/disponibles?pagina=0&tamano=5&ordenPor=precio"

La respuesta incluye metadatos de paginación automáticamente: número de páginas, total de elementos, página actual y si hay página siguiente o anterior.

Paso 11: Configurar perfiles por entorno

En proyectos reales necesitas configuraciones distintas para desarrollo y producción. Spring Boot soporta perfiles con archivos de propiedades separados.

# application.properties (configuración común)
spring.application.name=tienda-api
server.port=8080

# application-dev.properties (desarrollo)
spring.datasource.url=jdbc:mysql://localhost:3306/tienda_db?createDatabaseIfNotExist=true
spring.datasource.username=root
spring.datasource.password=root
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

# application-prod.properties (producción)
spring.datasource.url=${DATABASE_URL}
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=false
# Activar un perfil al arrancar
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev

# O con la variable de entorno
export SPRING_PROFILES_ACTIVE=prod
java -jar tienda-api.jar

El pom.xml completo del proyecto

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
                             https://maven.apache.org/xsd/maven-4.0.0.xsd">

  <modelVersion>4.0.0</modelVersion>

  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.0</version>
    <relativePath/>
  </parent>

  <groupId>com.ejemplo</groupId>
  <artifactId>tienda-api</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <name>tienda-api</name>
  <description>API REST con Spring Boot y MySQL</description>
  <packaging>jar</packaging>

  <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>com.mysql</groupId>
      <artifactId>mysql-connector-j</artifactId>
      <scope>runtime</scope>
    </dependency>

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>

  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>

</project>

Resumen

Has construido una API REST completa con Spring Boot y MySQL siguiendo la arquitectura en capas estándar del mundo Java:

  • Entidad (@Entity): mapea la clase Java a la tabla de la base de datos con JPA e Hibernate.
  • Repositorio (JpaRepository): proporciona automáticamente todas las operaciones CRUD y permite declarar consultas adicionales por nombre de método o con @Query.
  • DTO: separa la representación de la API de la entidad interna, controlando qué datos entran y salen.
  • Servicio (@Service): contiene la lógica de negocio, gestiona transacciones y orquesta las llamadas al repositorio.
  • Controlador (@RestController): expone los endpoints HTTP, extrae los parámetros de la petición y delega en el servicio.
  • Manejador de excepciones (@RestControllerAdvice): centraliza el tratamiento de errores y garantiza que la API siempre devuelve respuestas JSON consistentes.

Desde aquí, los siguientes pasos naturales son añadir autenticación con Spring Security y JWT, documentación automática con Springdoc OpenAPI (Swagger UI), tests de integración con @SpringBootTest y migraciones de base de datos con Flyway.

Etiquetas: Java spring boot

¿Te ha gustado esta entrada?

Compártela con tus compañeros para que también sigan aprendiendo.

Comunidad y Comentarios

0 COMENTARIOS

No hay comentarios todavía. Sé el primero en compartir tu opinión.

Escribe tu opinión
Respondiendo a