Crear una API REST con Spring Boot es uno de los caminos más habituales en el desarrollo backend con Java. Spring Boot reduce enormemente la configuración necesaria y te permite tener una API funcional en cuestión de minutos. Pero «funcional» y «bien construida» no son lo mismo.

En esta guía vamos a construir una API REST completa paso a paso: desde la creación del proyecto hasta el manejo de errores, validación de datos, paginación y documentación. Al terminar tendrás no solo una API que funciona sino una API que sigue las buenas prácticas que se esperan en un entorno profesional.

En este artículo:

Qué es REST y por qué importa hacerlo bien

REST (Representational State Transfer) es un estilo arquitectural para diseñar APIs web. No es un protocolo ni un estándar formal, sino un conjunto de restricciones que, cuando se cumplen, producen APIs predecibles, escalables y fáciles de entender.

Los principios más importantes de REST son: usar HTTP correctamente (los verbos GET, POST, PUT, PATCH, DELETE para las operaciones CRUD), que cada recurso tenga su propia URL, que las respuestas sean sin estado (el servidor no guarda información del cliente entre peticiones) y que se use el protocolo HTTP de forma semántica (códigos de estado correctos, headers apropiados).

El error más común es llamar «API REST» a cualquier API que use HTTP, aunque viole todos sus principios. Una API que usa POST para todo, devuelve siempre 200 aunque haya un error, y pone el tipo de operación en la URL (como /api/obtenerUsuario o /api/crearProducto) no es REST, es RPC sobre HTTP. No es que sea incorrecta, pero no tiene las ventajas de un diseño RESTful correcto.

Crear el proyecto con Spring Initializr

La forma más rápida de crear un proyecto Spring Boot es usando Spring Initializr en start.spring.io. La configuración recomendada para una API REST es:

Descarga el proyecto, descomprímelo y ábrelo en IntelliJ IDEA o VS Code con la extensión de Java. La estructura inicial que genera Spring Initializr ya es un proyecto Maven funcional con todas las dependencias configuradas.

Estructura recomendada del proyecto

La organización del código es crucial para que el proyecto sea mantenible a medida que crece. La estructura recomendada para una API REST mediana en Spring Boot organiza el código por dominio (feature), no por capa técnica:

src/main/java/com/tuempresa/api/
├── config/               # Configuración de Spring (CORS, seguridad, etc.)
│   └── CorsConfig.java
├── exception/            # Excepciones y manejo global de errores
│   ├── GlobalExceptionHandler.java
│   ├── RecursoNoEncontradoException.java
│   └── ErrorResponse.java
├── producto/             # Módulo de productos (ejemplo de módulo por dominio)
│   ├── Producto.java               # Entidad JPA
│   ├── ProductoRepository.java     # Repositorio JPA
│   ├── ProductoService.java        # Lógica de negocio
│   ├── ProductoController.java     # Endpoints REST
│   ├── dto/
│   │   ├── ProductoRequestDTO.java # DTO para recibir datos
│   │   └── ProductoResponseDTO.java # DTO para enviar datos
│   └── ProductoMapper.java         # Conversión entidad <-> DTO
├── usuario/              # Módulo de usuarios (misma estructura)
└── ApiApplication.java   # Clase principal

Esta organización por dominio (también llamada «vertical slice architecture») tiene ventajas claras frente a la organización por capas técnicas (controller/, service/, repository/ en carpetas separadas). Cuando trabajas en la funcionalidad de productos, todos los archivos que necesitas están en el mismo paquete. Es más fácil navegar, más fácil de entender y más fácil de extraer a un microservicio si algún día lo necesitas.

Entidad, Repositorio y Service

La entidad es la clase Java que se mapea a una tabla de la base de datos con JPA. Con Lombok reducimos el boilerplate de getters, setters y constructores:

@Entity
@Table(name = "productos")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Producto {

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

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

    @Column(columnDefinition = "TEXT")
    private String descripcion;

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

    @Column(nullable = false)
    private Integer stock;

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

    @Column(nullable = false, updatable = false)
    @CreationTimestamp
    private LocalDateTime creadoEn;

    @Column(nullable = false)
    @UpdateTimestamp
    private LocalDateTime actualizadoEn;
}

El repositorio extiende JpaRepository y nos da todos los métodos CRUD básicos además de la posibilidad de definir queries derivadas del nombre del método:

public interface ProductoRepository extends JpaRepository<Producto, Long> {

    // Spring Data JPA genera la query automáticamente a partir del nombre
    Page<Producto> findByCategoria(String categoria, Pageable pageable);

    List<Producto> findByStockGreaterThan(Integer stock);

    boolean existsByNombre(String nombre);

    // Query personalizada con JPQL
    @Query("SELECT p FROM Producto p WHERE p.precio BETWEEN :min AND :max")
    List<Producto> findByRangoPrecio(
        @Param("min") BigDecimal min,
        @Param("max") BigDecimal max
    );
}

El service contiene la lógica de negocio. Es importante que sea el service quien lance las excepciones de dominio, no el controller:

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ProductoService {

    private final ProductoRepository repo;
    private final ProductoMapper mapper;

    public Page<ProductoResponseDTO> findAll(Pageable pageable) {
        return repo.findAll(pageable).map(mapper::toResponseDTO);
    }

    public ProductoResponseDTO findById(Long id) {
        return repo.findById(id)
            .map(mapper::toResponseDTO)
            .orElseThrow(() -> new RecursoNoEncontradoException("Producto no encontrado: " + id));
    }

    @Transactional
    public ProductoResponseDTO create(ProductoRequestDTO dto) {
        if (repo.existsByNombre(dto.getNombre())) {
            throw new IllegalArgumentException("Ya existe un producto con ese nombre");
        }
        Producto producto = mapper.toEntity(dto);
        return mapper.toResponseDTO(repo.save(producto));
    }

    @Transactional
    public ProductoResponseDTO update(Long id, ProductoRequestDTO dto) {
        Producto existente = repo.findById(id)
            .orElseThrow(() -> new RecursoNoEncontradoException("Producto no encontrado: " + id));
        mapper.updateEntityFromDTO(dto, existente);
        return mapper.toResponseDTO(repo.save(existente));
    }

    @Transactional
    public void delete(Long id) {
        if (!repo.existsById(id)) {
            throw new RecursoNoEncontradoException("Producto no encontrado: " + id);
        }
        repo.deleteById(id);
    }
}

El Controller: diseñar los endpoints correctamente

El controller define los endpoints de la API. Debe ser lo más delgado posible: solo recibe la petición, delega al service y devuelve la respuesta. La lógica va en el service, no en el controller.

@RestController
@RequestMapping("/api/v1/productos")
@RequiredArgsConstructor
@Tag(name = "Productos", description = "Gestión del catálogo de productos")
public class ProductoController {

    private final ProductoService service;

    @GetMapping
    @Operation(summary = "Listar todos los productos con paginación")
    public ResponseEntity<Page<ProductoResponseDTO>> findAll(
            @PageableDefault(size = 20, sort = "nombre") Pageable pageable) {
        return ResponseEntity.ok(service.findAll(pageable));
    }

    @GetMapping("/{id}")
    @Operation(summary = "Obtener un producto por su ID")
    public ResponseEntity<ProductoResponseDTO> findById(@PathVariable Long id) {
        return ResponseEntity.ok(service.findById(id));
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    @Operation(summary = "Crear un nuevo producto")
    public ResponseEntity<ProductoResponseDTO> create(
            @Valid @RequestBody ProductoRequestDTO dto) {
        ProductoResponseDTO creado = service.create(dto);
        URI location = URI.create("/api/v1/productos/" + creado.getId());
        return ResponseEntity.created(location).body(creado);
    }

    @PutMapping("/{id}")
    @Operation(summary = "Actualizar un producto existente")
    public ResponseEntity<ProductoResponseDTO> update(
            @PathVariable Long id,
            @Valid @RequestBody ProductoRequestDTO dto) {
        return ResponseEntity.ok(service.update(id, dto));
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    @Operation(summary = "Eliminar un producto")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        service.delete(id);
        return ResponseEntity.noContent().build();
    }
}

DTOs: separar la API del modelo de datos

Un error muy común en APIs Spring Boot es exponer directamente las entidades JPA como respuesta de los endpoints. Esto es una mala práctica por varias razones: expones la estructura interna de tu base de datos, puedes crear referencias circulares al serializar relaciones bidireccionales, y cualquier cambio en la base de datos rompe la API pública.

La solución es usar DTOs (Data Transfer Objects): clases simples que representan exactamente lo que la API recibe y devuelve, separadas de las entidades:

// DTO para recibir datos del cliente (Request)
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ProductoRequestDTO {

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

    private String descripcion;

    @NotNull(message = "El precio es obligatorio")
    @DecimalMin(value = "0.01", message = "El precio debe ser mayor que 0")
    @Digits(integer = 8, fraction = 2)
    private BigDecimal precio;

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

    @NotBlank(message = "La categoría es obligatoria")
    private String categoria;
}

// DTO para enviar datos al cliente (Response)
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ProductoResponseDTO {
    private Long id;
    private String nombre;
    private String descripcion;
    private BigDecimal precio;
    private Integer stock;
    private String categoria;
    private LocalDateTime creadoEn;
}

Manejo global de errores con @ControllerAdvice

Las APIs profesionales devuelven errores en un formato consistente y estructurado. No pueden devolver en un caso el stack trace de Java, en otro un HTML de error genérico de Spring y en otro un JSON. La forma correcta de manejar esto en Spring Boot es con un @ControllerAdvice:

@RestControllerAdvice
public class GlobalExceptionHandler {

    // Error cuando no se encuentra un recurso → 404
    @ExceptionHandler(RecursoNoEncontradoException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(
            RecursoNoEncontradoException ex, HttpServletRequest request) {
        ErrorResponse error = ErrorResponse.builder()
            .timestamp(LocalDateTime.now())
            .status(404)
            .error("Not Found")
            .message(ex.getMessage())
            .path(request.getRequestURI())
            .build();
        return ResponseEntity.status(404).body(error);
    }

    // Error de validación → 400
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(
            MethodArgumentNotValidException ex, HttpServletRequest request) {
        Map<String, String> errores = new LinkedHashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(e ->
            errores.put(e.getField(), e.getDefaultMessage())
        );
        ErrorResponse error = ErrorResponse.builder()
            .timestamp(LocalDateTime.now())
            .status(400)
            .error("Validation Failed")
            .message("Los datos enviados no son válidos")
            .validationErrors(errores)
            .path(request.getRequestURI())
            .build();
        return ResponseEntity.badRequest().body(error);
    }

    // Error genérico no controlado → 500
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneric(
            Exception ex, HttpServletRequest request) {
        ErrorResponse error = ErrorResponse.builder()
            .timestamp(LocalDateTime.now())
            .status(500)
            .error("Internal Server Error")
            .message("Ha ocurrido un error interno. Por favor, inténtalo más tarde.")
            .path(request.getRequestURI())
            .build();
        return ResponseEntity.internalServerError().body(error);
    }
}

Paginación y ordenación

Devolver listas completas sin paginación es un error grave en APIs de producción. Si la tabla tiene 100.000 productos y devuelves todos en cada petición, tu API es inutilizable. Spring Data JPA tiene soporte nativo para paginación con la interfaz Pageable:

// En el repositorio
Page<Producto> findAll(Pageable pageable);

// En el service
public Page<ProductoResponseDTO> findAll(Pageable pageable) {
    return repo.findAll(pageable).map(mapper::toResponseDTO);
}

// En el controller
@GetMapping
public ResponseEntity<Page<ProductoResponseDTO>> findAll(
        @PageableDefault(size = 20, sort = "nombre", direction = Sort.Direction.ASC)
        Pageable pageable) {
    return ResponseEntity.ok(service.findAll(pageable));
}

// Peticiones de ejemplo:
// GET /api/v1/productos                          → página 0, 20 elementos
// GET /api/v1/productos?page=1&size=10           → página 1, 10 elementos
// GET /api/v1/productos?sort=precio,desc         → ordenados por precio descendente
// GET /api/v1/productos?sort=categoria&sort=nombre → múltiple ordenación

La respuesta de tipo Page de Spring incluye automáticamente metadatos de paginación: número de página, tamaño, total de elementos y total de páginas. El cliente siempre sabe cuántos recursos existen y cómo navegar entre páginas.

Con esto tienes los fundamentos para construir una API REST profesional con Spring Boot. El siguiente paso natural es añadir seguridad con JWT, que puedes ver en detalle en nuestra guía sobre autenticación JWT en Spring Boot. Y si quieres conectar esta API con un frontend Angular, tenemos también una guía paso a paso sobre cómo conectar Angular con Spring Boot.