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.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación