Cómo construir una API RESTful con Spring Boot y PostgreSQL paso a paso
Objetivo: crear una API CRUD sencilla para gestionar usuarios usando Spring Boot, Spring Data JPA y PostgreSQL. Incluiré estructura de proyecto, código completo mínimo viable, configuración para desarrollo con Docker Compose y explicaciones del porqué de las decisiones.
Requisitos
- JDK 17+
- Maven 3.6+
- Docker (opcional, para Postgres)
- IDE (IntelliJ, VS Code...)
Estructura del proyecto
springboot-postgres-api/
├─ src/main/java/com/example/api/
│ ├─ ApiApplication.java
│ ├─ controller/
│ │ └─ UserController.java
│ ├─ dto/
│ │ └─ UserDto.java
│ ├─ entity/
│ │ └─ User.java
│ ├─ repository/
│ │ └─ UserRepository.java
│ ├─ service/
│ │ ├─ UserService.java
│ │ └─ impl/UserServiceImpl.java
│ └─ exception/
│ ├─ ResourceNotFoundException.java
│ └─ GlobalExceptionHandler.java
├─ src/main/resources/
│ └─ application.properties
└─ docker-compose.yml
pom.xml (dependencias clave)
<project xmlns="http://maven.apache.org/POM/4.0.0" ...>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>springboot-postgres-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.0</version>
</parent>
<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.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
application.properties
spring.datasource.url=jdbc:postgresql://localhost:5432/api_db
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
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);
}
}
Entity: User
package com.example.api.entity;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String email;
}
DTO: UserDto
package com.example.api.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.*;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserDto {
private Long id;
@NotBlank(message = "El nombre es obligatorio")
private String name;
@NotBlank
@Email(message = "Email inválido")
private String email;
}
Repository
package com.example.api.repository;
import com.example.api.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository {
Optional findByEmail(String email);
}
Service
package com.example.api.service;
import com.example.api.dto.UserDto;
import java.util.List;
public interface UserService {
UserDto create(UserDto dto);
UserDto getById(Long id);
List getAll();
UserDto update(Long id, UserDto dto);
void delete(Long id);
}
ServiceImpl
package com.example.api.service.impl;
import com.example.api.dto.UserDto;
import com.example.api.entity.User;
import com.example.api.exception.ResourceNotFoundException;
import com.example.api.repository.UserRepository;
import com.example.api.service.UserService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
@Service
@Transactional
public class UserServiceImpl implements UserService {
private final UserRepository repo;
public UserServiceImpl(UserRepository repo) {
this.repo = repo;
}
private UserDto toDto(User u) {
return UserDto.builder().id(u.getId()).name(u.getName()).email(u.getEmail()).build();
}
private User toEntity(UserDto d) {
return User.builder().name(d.getName()).email(d.getEmail()).build();
}
@Override
public UserDto create(UserDto dto) {
// simple uniqueness check
repo.findByEmail(dto.getEmail()).ifPresent(u -> {
throw new IllegalArgumentException("Email already in use");
});
User saved = repo.save(toEntity(dto));
return toDto(saved);
}
@Override
public UserDto getById(Long id) {
User u = repo.findById(id).orElseThrow(() -> new ResourceNotFoundException("User not found"));
return toDto(u);
}
@Override
public List getAll() {
return repo.findAll().stream().map(this::toDto).collect(Collectors.toList());
}
@Override
public UserDto update(Long id, UserDto dto) {
User u = repo.findById(id).orElseThrow(() -> new ResourceNotFoundException("User not found"));
u.setName(dto.getName());
u.setEmail(dto.getEmail());
User saved = repo.save(u);
return toDto(saved);
}
@Override
public void delete(Long id) {
if (!repo.existsById(id)) throw new ResourceNotFoundException("User not found");
repo.deleteById(id);
}
}
Controller
package com.example.api.controller;
import com.example.api.dto.UserDto;
import com.example.api.service.UserService;
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/users")
public class UserController {
private final UserService service;
public UserController(UserService service) {
this.service = service;
}
@PostMapping
public ResponseEntity<UserDto> create(@Valid @RequestBody UserDto dto) {
UserDto created = service.create(dto);
return ResponseEntity.created(URI.create("/api/users/" + created.getId())).body(created);
}
@GetMapping("/{id}")
public ResponseEntity<UserDto> getById(@PathVariable Long id) {
return ResponseEntity.ok(service.getById(id));
}
@GetMapping
public ResponseEntity<List<UserDto>> getAll() {
return ResponseEntity.ok(service.getAll());
}
@PutMapping("/{id}")
public ResponseEntity<UserDto> update(@PathVariable Long id, @Valid @RequestBody UserDto dto) {
return ResponseEntity.ok(service.update(id, dto));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
}
Manejo de excepciones
package com.example.api.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String msg) { super(msg); }
}
// GlobalExceptionHandler
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.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<Map<String, String>> handleNotFound(ResourceNotFoundException ex) {
Map<String,String> body = new HashMap<>();
body.put("error", ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String,String>> handleValidation(MethodArgumentNotValidException ex) {
Map<String,String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(fe -> errors.put(fe.getField(), fe.getDefaultMessage()));
return ResponseEntity.badRequest().body(errors);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String,String>> handleBadRequest(IllegalArgumentException ex) {
Map<String,String> body = new HashMap<>();
body.put("error", ex.getMessage());
return ResponseEntity.badRequest().body(body);
}
}
Docker Compose (Postgres)
version: '3.8'
services:
db:
image: postgres:15
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: api_db
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Ejecutar localmente
- Arrancar Postgres: docker compose up -d
- Construir y ejecutar: mvn spring-boot:run
- Probar endpoints con curl o Postman:
curl -X POST -H "Content-Type: application/json" -d '{"name":"Ana","email":"ana@example.com"}' http://localhost:8080/api/users
Por qué esta arquitectura y decisiones
- Separación controller/service/repository: facilita pruebas unitarias y cambiar capa de persistencia sin afectar lógica.
- DTOs: evita exponer la entidad JPA (mismo modelo para persistencia y API puede causar problemas y sobreexposición).
- Validación con jakarta.validation: entrada validada automáticamente y errores manejables.
- Transactional en service: asegura integridad en operaciones compuestas.
- Docker Compose: reproducibilidad del entorno de base de datos entre desarrolladores y CI.
Mejoras y consideraciones (seguridad, rendimiento, producción)
- Seguridad: añadir Spring Security y JWT para endpoints privados; sanitizar entradas y limitar tasas.
- Migraciones de esquema: usar Flyway o Liquibase en lugar de hibernate.ddl-auto=update en producción.
- Mapeo: para aplicaciones grandes, usar MapStruct para mapeos DTO↔Entity.
- Transacciones: ajustar la granularidad y considerar aislamiento si hay concurrencia alta.
- Indices: añadir índices en columnas buscadas frecuentemente (email ya único).
- Pruebas: añadir unit tests para service y controller tests (MockMvc / TestRestTemplate).
Pruebas rápidas sugeridas
// Ejemplo de test con MockMvc (simplificado)
@SpringBootTest
@AutoConfigureMockMvc
public class UserControllerTest {
@Autowired
private MockMvc mvc;
@Test
void createUser() throws Exception {
String json = "{\"name\":\"Test\",\"email\":\"t@example.com\"}";
mvc.perform(post("/api/users").contentType(MediaType.APPLICATION_JSON).content(json))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").exists());
}
}
Consejo avanzado: en sistemas reales, coloca la lógica de negocio en servicios idempotentes y evita operaciones latentes en controladores—así facilitas retries y diseño resiliente. Advertencia: no uses hibernate.ddl-auto=update en production sin control de migraciones; puedes perder datos en cambios de esquema. Siguiente paso sugerido: integra autenticación (OAuth2/JWT) y Flyway para migraciones, luego añade una capa de caché (Redis) para lecturas intensivas.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación