Cómo construir una API RESTful con Spring Boot y PostgreSQL paso a paso

java Cómo construir una API RESTful con Spring Boot y PostgreSQL paso a paso

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

  1. Arrancar Postgres: docker compose up -d
  2. Construir y ejecutar: mvn spring-boot:run
  3. 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.

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