Cómo construir una API REST robusta con Spring Boot 3 y Java 17

java Cómo construir una API REST robusta con Spring Boot 3 y Java 17

Cómo construir una API REST robusta con Spring Boot 3 y Java 17

Guía práctica de una API REST siguiendo buenas prácticas: estructura de carpetas, DTOs, validación, manejo de excepciones, pruebas e imagen Docker. Código mínimo funcional y explicaciones del porqué.

Por qué este enfoque

  • Separación de capas (controller, service, repository) para mantener lógica de negocio fuera de la capa HTTP.
  • DTOs para desacoplar entidad persistente del contrato público.
  • Validación en entrada y manejo centralizado de errores para respuestas consistentes.
  • Tests de integración/contract para asegurar comportamiento.

Estructura de carpetas

src
└─ main
   └─ java
      └─ com.example.api
         ├─ ApiApplication.java
         ├─ controller
         │  └─ ItemController.java
         ├─ service
         │  └─ ItemService.java
         ├─ repository
         │  └─ ItemRepository.java
         ├─ model
         │  └─ Item.java
         ├─ dto
         │  └─ ItemDto.java
         └─ exception
            ├─ ApiExceptionHandler.java
            └─ NotFoundException.java

src/test
└─ java
   └─ com.example.api
      └─ controller
         └─ ItemControllerTest.java

pom.xml (resumen)

<project xmlns="http://maven.apache.org/POM/4.0.0" ...>
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.example</groupId>
  <artifactId>api</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <properties>
    <java.version>17</java.version>
    <spring.boot.version>3.1.0</spring.boot.version>
  </properties>
  <dependencies>
    <dependency>spring-boot-starter-web</dependency>
    <dependency>spring-boot-starter-data-jpa</dependency>
    <dependency>spring-boot-starter-validation</dependency>
    <dependency>com.h2database:h2</dependency>
    <dependency>spring-boot-starter-test (scope test)</dependency>
  </dependencies>
</project>

application.properties (desarrollo)

spring.datasource.url=jdbc:h2:mem:db;DB_CLOSE_DELAY=-1
spring.datasource.driverClassName=org.h2.Driver
spring.jpa.hibernate.ddl-auto=update
server.port=8080

Clase principal

package com.example.api;

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

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

Entidad (model/Item.java)

package com.example.api.model;

import jakarta.persistence.*;

@Entity
@Table(name = "items")
public class Item {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private String description;

    // getters y setters

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }
}

DTO (dto/ItemDto.java) - validación en entrada

package com.example.api.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public class ItemDto {
    private Long id;

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

    @Size(max = 500)
    private String description;

    // getters y setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }
}

Repositorio (repository/ItemRepository.java)

package com.example.api.repository;

import com.example.api.model.Item;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ItemRepository extends JpaRepository<Item, Long> {
}

Servicio (service/ItemService.java)

package com.example.api.service;

import com.example.api.dto.ItemDto;
import com.example.api.exception.NotFoundException;
import com.example.api.model.Item;
import com.example.api.repository.ItemRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;

@Service
public class ItemService {
    private final ItemRepository repo;

    public ItemService(ItemRepository repo) { this.repo = repo; }

    public List<ItemDto> findAll() {
        return repo.findAll().stream().map(this::toDto).collect(Collectors.toList());
    }

    public ItemDto findById(Long id) {
        return repo.findById(id).map(this::toDto).orElseThrow(() -> new NotFoundException("Item no encontrado"));
    }

    public ItemDto create(ItemDto dto) {
        Item item = new Item();
        item.setName(dto.getName());
        item.setDescription(dto.getDescription());
        Item saved = repo.save(item);
        return toDto(saved);
    }

    public ItemDto update(Long id, ItemDto dto) {
        Item item = repo.findById(id).orElseThrow(() -> new NotFoundException("Item no encontrado"));
        item.setName(dto.getName());
        item.setDescription(dto.getDescription());
        return toDto(repo.save(item));
    }

    public void delete(Long id) {
        if (!repo.existsById(id)) throw new NotFoundException("Item no encontrado");
        repo.deleteById(id);
    }

    private ItemDto toDto(Item item) {
        ItemDto d = new ItemDto();
        d.setId(item.getId());
        d.setName(item.getName());
        d.setDescription(item.getDescription());
        return d;
    }
}

Controlador (controller/ItemController.java)

package com.example.api.controller;

import com.example.api.dto.ItemDto;
import com.example.api.service.ItemService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.List;

@RestController
@RequestMapping("/api/items")
public class ItemController {
    private final ItemService service;
    public ItemController(ItemService service) { this.service = service; }

    @GetMapping
    public List<ItemDto> list() { return service.findAll(); }

    @GetMapping("/{id}")
    public ItemDto get(@PathVariable Long id) { return service.findById(id); }

    @PostMapping
    public ResponseEntity<ItemDto> create(@Valid @RequestBody ItemDto dto) {
        ItemDto created = service.create(dto);
        return ResponseEntity.created(URI.create("/api/items/" + created.getId())).body(created);
    }

    @PutMapping("/{id}")
    public ItemDto update(@PathVariable Long id, @Valid @RequestBody ItemDto dto) {
        return service.update(id, dto);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        service.delete(id);
        return ResponseEntity.noContent().build();
    }
}

Manejo centralizado de excepciones (exception/ApiExceptionHandler.java)

package com.example.api.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import java.util.HashMap;
import java.util.Map;

@ControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(NotFoundException.class)
    public ResponseEntity<Map<String, Object>> handleNotFound(NotFoundException ex) {
        Map<String,Object> body = new HashMap<>();
        body.put("error", "not_found");
        body.put("message", ex.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
        Map<String,String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(err -> errors.put(err.getField(), err.getDefaultMessage()));
        Map<String,Object> body = new HashMap<>();
        body.put("error", "validation");
        body.put("fields", errors);
        return ResponseEntity.badRequest().body(body);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<Map<String, Object>> handleGeneric(Exception ex) {
        Map<String,Object> body = new HashMap<>();
        body.put("error", "internal");
        body.put("message", "Error interno");
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
    }
}
package com.example.api.exception;

public class NotFoundException extends RuntimeException {
    public NotFoundException(String message) { super(message); }
}

Test de controlador (src/test/.../ItemControllerTest.java)

package com.example.api.controller;

import com.example.api.dto.ItemDto;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
public class ItemControllerTest {

    @Autowired
    MockMvc mockMvc;

    @Autowired
    ObjectMapper mapper;

    @Test
    void createWhenValidReturnsCreated() throws Exception {
        ItemDto dto = new ItemDto();
        dto.setName("Test item");
        dto.setDescription("desc");
        mockMvc.perform(post("/api/items")
                .contentType(MediaType.APPLICATION_JSON)
                .content(mapper.writeValueAsString(dto)))
            .andExpect(status().isCreated());
    }
}

Dockerfile

FROM maven:3.8.6-eclipse-temurin-17 as build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn -DskipTests package -q

FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java","-jar","/app/app.jar"]

Buenas prácticas y explicaciones clave

  • Usa DTOs para evitar exponer campos internos o relaciones perezosas de JPA.
  • Valida en el límite (controller) y delega reglas de negocio al servicio.
  • Manejo centralizado de excepciones garantiza respuesta coherente y predecible para consumidores.
  • Escribe tests que cubran los contratos (status codes, body) y casos de error.
  • Considera paginación y filtros en endpoints que listan muchos recursos.

Planteamientos de seguridad y rendimiento

Para producción:

  • Usa autenticación basada en JWT y roles. Implementa un filtro que valide el token antes de llegar al controller.
  • Configura CORS de forma restrictiva y habilita HTTPS en el proxy/load balancer.
  • Usa connection pool (HikariCP viene por defecto). Ajusta cache y uso de índices en base de datos.
  • Activa logs estructurados y métricas (Micrometer) para monitorización.

Optimización rápida

Si la latencia de tus endpoints es alta: habilita SELECT fields concretos cuando no necesitas la entidad completa, añade índices en columnas usadas en WHERE y consider caching a nivel de servicio o Redis.

Siguiente paso recomendado

Implementa autenticación JWT, añade paginación con Pageable en los list endpoints y cubre los flujos críticos con tests de integración que usen una base de datos en memoria para CI. Consejo avanzado: separa la capa de mapeo a DTOs en componentes dedicados o usa MapStruct para mantener el servicio limpio y facilitar pruebas unitarias.

Advertencia: evita usar entidades JPA directamente como modelos de respuesta; las relaciones lazy pueden revelar datos inesperados y causar N+1 queries si no se controlan.

Comentarios
¿Quieres comentar?

Inicia sesión con Telegram para participar en la conversación


Comentarios (0)

Aún no hay comentarios. ¡Sé el primero en comentar!

Iniciar Sesión