Cheatsheet Spring Boot
Framework Java para aplicações enterprise e APIs REST
Spring Boot
Setup e CLI
Crear proyecto
# Spring Initializr (start.spring.io) o CLI: spring init --build=maven \ --dependencies=web,jpa,mysql \ --java-version=21 mi-app cd mi-app ./mvnw spring-boot:run
spring init genera la estructura del proyecto. --dependencies define los starters iniciales. El mvnw es el Maven wrapper incluido. Accede en localhost:8080.
Comandos Maven
./mvnw spring-boot:run # ejecutar ./mvnw clean package # generar JAR ./mvnw test # correr tests java -jar target/app.jar # ejecutar JAR # Gradle alternativo: ./gradlew bootRun ./gradlew bootJar
spring-boot:run inicia en modo dev. clean package genera el JAR ejecutable en target/. El JAR incluye el servidor embedded — basta java -jar.
Estructura del proyecto
src/main/java/com/app/ Application.java # main controller/ model/ repository/ service/ dto/ src/main/resources/ application.properties static/ templates/
Application.java es el punto de entrada. La organización sigue capas: controller, service, repository y model. Los recursos están en resources/.
Dependencias Gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.0'
id 'io.spring.dependency-management' version '1.1.5'
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.mysql:mysql-connector-j'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}build.gradle es la alternativa a Maven. El plugin org.springframework.boot gestiona versiones. runtimeOnly es para drivers solo necesarios en runtime. testImplementation para dependencias de test.
Clase principal
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}@SpringBootApplication combina @Configuration, @EnableAutoConfiguration y @ComponentScan. El método main inicia el servidor embedded Tomcat.
DevTools
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
# application.properties:
spring.devtools.restart.enabled=true
spring.devtools.livereload.enabled=truedevtools activa el restart automático al cambiar código. Incluye LiveReload para el navegador. Está marcado optional para no incluirse en el JAR final. Solo útil en desarrollo.
Dependencias Maven
<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>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
</dependencies>Cada starter agrupa dependencias relacionadas. starter-web incluye Tomcat y Jackson. starter-data-jpa incluye Hibernate. El mysql-connector-j es el driver JDBC.
Requisitos Java
# Spring Boot 3.x requiere: # - Java 17+ (recomendado 21) # - Jakarta EE (no javax) # Verificar versión: java -version # Maven wrapper actualiza: ./mvnw wrapper:wrapper -Dmaven=3.9.6
Spring Boot 3 migró de javax.* a jakarta.*. Requiere Java 17 como mínimo. Usa Java 21 para virtual threads. El maven-wrapper evita la instalación global de Maven.
Controllers REST
REST Controller
@RestController
@RequestMapping("/api/posts")
public class PostController {
private final PostService service;
public PostController(PostService service) {
this.service = service;
}
@GetMapping
public List<Post> listar() {
return service.findAll();
}
}@RestController combina @Controller + @ResponseBody. @RequestMapping define la ruta base. La inyección es por constructor. Devuelve JSON automáticamente vía Jackson.
ResponseEntity
@PostMapping
public ResponseEntity<Post> crear(@RequestBody PostDTO dto) {
Post post = service.create(dto);
return ResponseEntity
.status(HttpStatus.CREATED)
.header("X-Post-Id", post.getId().toString())
.body(post);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> borrar(@PathVariable Long id) {
service.delete(id);
return ResponseEntity.noContent().build();
}ResponseEntity permite controlar status, headers y cuerpo. HttpStatus.CREATED devuelve 201. noContent() devuelve 204 sin cuerpo. Úsalo cuando necesites headers personalizados.
Métodos HTTP
@GetMapping // GET /api/posts
@GetMapping("/{id}") // GET /api/posts/1
@PostMapping // POST /api/posts
@PutMapping("/{id}") // PUT /api/posts/1
@PatchMapping("/{id}") // PATCH /api/posts/1
@DeleteMapping("/{id}") // DELETE /api/posts/1Cada anotación mapea un verbo HTTP. @GetMapping para lectura, @PostMapping para creación. @PutMapping sustituye todo, @PatchMapping actualiza parcialmente. @DeleteMapping elimina.
Exception handler global
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNotFound(NotFoundException ex) {
return new ErrorResponse(ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handlisValidtion(MethodArgumentNotValidException ex) {
String msg = ex.getBindingResult().getFieldErrors()
.stream().map(e -> e.getDefaultMessage())
.collect(Collectors.joining(", "));
return new ErrorResponse(msg);
}
}@RestControllerAdvice intercepta excepciones de todos los controllers. @ExceptionHandler define qué excepción tratar. @ResponseStatus define el código HTTP. Centraliza el manejo de errores.
Path y Body
@GetMapping("/{id}")
public Post ver(@PathVariable Long id) {
return service.findById(id);
}
@PostMapping
public Post crear(@RequestBody @Valid PostDTO dto) {
return service.create(dto);
}@PathVariable extrae valores de la URL. @RequestBody deserializa el JSON del cuerpo. @Valid activa la validación Bean Validation antes de entrar al método.
Headers y cookies
@GetMapping("/download")
public ResponseEntity<byte[]> download() {
byte[] datos = service.generarPdf();
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_TYPE, "application/pdf")
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=informe.pdf")
.body(datos);
}
@GetMapping("/perfil")
public String perfil(@CookieValue("sesion") String token) {
return service.getPerfil(token);
}HttpHeaders define headers de respuesta. CONTENT_DISPOSITION fuerza la descarga. @CookieValue lee cookies de la petición. Usa ResponseCookie para crear cookies con opciones.
Request params
@GetMapping
public List<Post> buscar(
@RequestParam String termino,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String categoria
) {
return service.search(termino, page, size, categoria);
}@RequestParam lee query strings (?termino=x&page=0). defaultValue evita el error si falta. required = false lo hace opcional. Ideal para filtros y paginación.
Validación de entrada
public record PostDTO(
@NotBlank(message = "Título obligatorio")
@Size(min = 5, max = 200)
String título,
@NotNull
@Positive
Integer categoriaId
) {}
// En el controller:
@PostMapping
public Post crear(@RequestBody @Valid PostDTO dto) { ... }@NotBlank rechaza strings vacíos. @Size limita la longitud. @Positive exige valor > 0. @Valid en el controller activa la validación. Los errores generan 400 Bad Request.
Entities e JPA
Definir Entity
@Entity
@Table(name = "posts")
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
private String título;
@Column(columnDefinition = "TEXT")
private String contenido;
private LocalDateTime creadoEn;
@PrePersist
void prePersist() {
this.creadoEn = LocalDateTime.now();
}
}@Entity marca la clase como tabla JPA. @Id + @GeneratedValue define la clave autoincremental. @Column personaliza la columna. @PrePersist se ejecuta antes de insertar.
Relación ManyToMany
@Entity
public class Post {
@ManyToMany
@JoinTable(
name = "post_tags",
joinColumns = @JoinColumn(name = "post_id"),
inverseJoinColumns = @JoinColumn(name = "tag_id")
)
private Set<Tag> tags = new HashSet<>();
}
@Entity
public class Tag {
@ManyToMany(mappedBy = "tags")
private Set<Post> posts = new HashSet<>();
}@ManyToMany crea la tabla intermedia automática. @JoinTable define el nombre y las FKs. Usa Set para evitar duplicados. Solo un lado es dueño — el otro usa mappedBy.
Anotaciones de columna
@Column(length = 200, nullable = false) private String título; @Column(unique = true) private String slug; @Lob private String textoGrande; @Enumerated(EnumType.STRING) private Status status; @Temporal(TemporalType.TIMESTAMP) private Date actualizadoEn;
@Column define constraints de la columna. @Lob para textos grandes (CLOB/BLOB). @Enumerated(STRING) guarda el nombre del enum, no el ordinal. @Temporal para tipos de fecha legados.
DTOs con records
// Request DTO
public record CrearPostDTO(
@NotBlank String título,
@NotBlank String contenido,
Long autorId
) {}
// Response DTO
public record PostResponse(
Long id,
String título,
String autorNombre,
LocalDateTime creadoEn
) {
public static PostResponse from(Post p) {
return new PostResponse(
p.getId(), p.getTitulo(),
p.getAutor().getNombre(), p.getCreadoEn());
}
}record (Java 16+) crea DTOs inmutables sin boilerplate. Separa request de response. El método estático from() convierte Entity en DTO. Nunca expongas Entities directamente en la API.
Relación ManyToOne
@Entity
public class Comentario {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String texto;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id", nullable = false)
private Post post;
}@ManyToOne indica muchos comentarios para un post. FetchType.LAZY carga solo al acceder (evita N+1). @JoinColumn define la FK en la tabla. Prefiere siempre LAZY.
Bean Validation
public class UserDTO {
@NotBlank(message = "Nombre obligatorio")
@Size(min = 2, max = 100)
private String nombre;
@Email(message = "Email inválido")
@NotBlank
private String email;
@Min(value = 18, message = "Edad mínima: 18")
private int edad;
@Pattern(regexp = "^[A-Z]{2}$")
private String país;
}@NotBlank rechaza nulo y vacío. @Email valida el formato. @Min/@Max para límites numéricos. @Pattern usa regex. El mensaje se devuelve en el error 400.
Relación OneToMany
@Entity
public class Post {
@OneToMany(mappedBy = "post",
cascade = CascadeType.ALL,
orphanRemoval = true)
private List<Comentario> comentarios = new ArrayList<>();
public void addComentario(Comentario c) {
comentarios.add(c);
c.setPost(this);
}
public void removeComentario(Comentario c) {
comentarios.remove(c);
c.setPost(null);
}
}@OneToMany es el lado inverso de la relación. mappedBy apunta al campo dueño. cascade = ALL propaga operaciones. orphanRemoval borra hijos eliminados de la lista. Mantén métodos helper para sincronizar ambos lados.
Auditoría JPA
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Post {
@CreatedDate
private LocalDateTime creadoEn;
@LastModifiedDate
private LocalDateTime actualizadoEn;
@CreatedBy
private String creadoPor;
}
// Activar:
@Configuration
@EnableJpaAuditing
public class AuditConfig {}@EnableJpaAuditing activa la auditoría global. @CreatedDate se rellena en la creación. @LastModifiedDate se actualiza en cada save. @CreatedBy requiere un bean AuditorAware.
Repositories
Interfaz base
@Repository
public interface PostRepository
extends JpaRepository<Post, Long> {
// Métodos heredados:
// save(), findById(), findAll(),
// delete(), count(), existsById()
}JpaRepository proporciona CRUD completo sin código. El genérico es <Entity, TipoId>. @Repository es opcional (Spring lo detecta automáticamente). Incluye paginación y ordenación.
Paginación y ordenación
// En el controller:
@GetMapping
public Page<Post> listar(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "creadoEn") String sort
) {
return repo.findAll(PageRequest.of(page, size,
Sort.by(sort).descending()));
}
// La respuesta incluye:
// content[], totalElements, totalPages, numberPageRequest.of() define página y tamaño. Sort.by() ordena por campo. Page incluye metadatos: total, páginas, actual. La paginación empieza en 0. Serializa directo a JSON.
Query methods
public interface PostRepository
extends JpaRepository<Post, Long> {
List<Post> findByActivoTrue();
List<Post> findByTituloContaining(String termino);
List<Post> findByAutorNombreAndActivo(String nombre, boolean activo);
List<Post> findByViewsGreaterThanOrderByCreadoEnDesc(int min);
long countByActivoTrue();
boolean existsBySlug(String slug);
Optional<Post> findBySlug(String slug);
}Spring genera la query por el nombre del método. findBy + campo + condición. Containing hace LIKE. OrderBy ordena. countBy y existsBy para agregaciones. Sin SQL manual.
Specifications (filtros dinámicos)
public interface PostRepository extends
JpaRepository<Post, Long>,
JpaSpecificationExecutor<Post> {}
// Construir filtro dinámico:
Specification<Post> spec = Specification.where(null);
if (título != null) {
spec = spec.and((root, q, cb) ->
cb.like(root.get("título"), "%" + título + "%"));
}
if (activo != null) {
spec = spec.and((root, q, cb) ->
cb.equal(root.get("activo"), activo));
}
List<Post> resultados = repo.findAll(spec);JpaSpecificationExecutor activa filtros dinámicos. Specification construye predicados programáticamente. Ideal para búsquedas con múltiples filtros opcionales. Se combina con and()/or().
@Query JPQL
@Query("SELECT p FROM Post p WHERE p.views > :min " +
"AND p.activo = true ORDER BY p.creadoEn DESC")
List<Post> findPopulares(@Param("min") int min);
@Query("SELECT new com.app.dto.PostResumenDTO(p.id, p.título) " +
"FROM Post p WHERE p.autor.id = :autorId")
List<PostResumenDTO> resumenesPorAutor(@Param("autorId") Long id);@Query permite JPQL personalizado. :param son parámetros nombrados con @Param. JPQL usa nombres de clases, no tablas. SELECT new proyecta directamente a DTOs.
Projections
// Interface-based projection:
public interface PostResumen {
Long getId();
String getTitulo();
String getAutorNombre();
}
List<PostResumen> findByActivoTrue();
// Closed projection (JPQL):
@Query("SELECT p.id AS id, p.título AS título " +
"FROM Post p")
List<PostResumen> listarResumenes();Projections devuelven solo los campos necesarios. La interfaz define getters con alias. Reduce tráfico y memoria vs. la Entity completa. Úsalas para listados e informes. Spring implementa la interfaz automáticamente.
Query nativa
@Query(value = "SELECT * FROM posts " +
"WHERE MATCH(título, contenido) AGAINST(:termino)",
nativeQuery = true)
List<Post> busquedaFullText(@Param("termino") String termino);
@Modifying
@Query(value = "UPDATE posts SET views = views + 1 WHERE id = :id",
nativeQuery = true)
void incrementarViews(@Param("id") Long id);nativeQuery = true usa SQL directo de la BD. Útil para funcionalidades específicas (full-text, JSON). @Modifying es obligatorio para UPDATE/DELETE. Combínalo con @Transactional.
Services e DI
Service layer
@Service
public class PostService {
private final PostRepository repo;
private final AutorRepository autorRepo;
public PostService(PostRepository repo,
AutorRepository autorRepo) {
this.repo = repo;
this.autorRepo = autorRepo;
}
public List<Post> findAll() {
return repo.findAll();
}
}@Service marca la capa de negocio. La inyección por constructor es el patrón recomendado. Spring crea el singleton automáticamente. Los services orquestan repositories y reglas de negocio.
@Component y @Bean
@Component // auto-detectado por ComponentScan
public class SlugGenerator {
public String generar(String título) {
return título.toLowerCase().replaceAll("\s+", "-");
}
}
@Configuration
public class AppConfig {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
@Bean
@Scope("prototype")
public Notificacion notificacion() {
return new Notificacion();
}
}@Component registra clases en el contexto. @Bean en @Configuration registra objetos de terceros. @Scope("prototype") crea una instancia nueva en cada inyección. Por defecto, los beans son singletons.
CRUD en el service
public Post create(CrearPostDTO dto) {
Autor autor = autorRepo.findById(dto.autorId())
.orElseThrow(() -> new NotFoundException("Autor no encontrado"));
Post post = new Post();
post.setTitulo(dto.título());
post.setContenido(dto.contenido());
post.setAutor(autor);
return repo.save(post);
}
public void delete(Long id) {
if (!repo.existsById(id)) {
throw new NotFoundException("Post no encontrado");
}
repo.deleteById(id);
}orElseThrow lanza excepción si no existe. Valida las reglas de negocio antes de persistir. save() hace INSERT o UPDATE según el ID. Separa la validación de la persistencia.
Events y listeners
// Definir evento:
public record PostCreadoEvent(Post post) {}
// Publicar:
@Service
public class PostService {
private final ApplicationEventPublisher publisher;
public void create(PostDTO dto) {
Post post = repo.save(new Post(dto));
publisher.publishEvent(new PostCreadoEvent(post));
}
}
// Escuchar:
@Component
public class NotificarSuscriptores {
@EventListener
public void onPostCreado(PostCreadoEvent event) {
// enviar emails, notificaciones...
}
}ApplicationEventPublisher publica eventos. @EventListener reacciona de forma desacoplada. Ideal para efectos secundarios (emails, logs). Usa @Async para procesar en background.
@Transactional
@Transactional
public Post crearConComentarios(CrearPostDTO dto) {
Post post = repo.save(new Post(dto));
for (String texto : dto.comentarios()) {
comentarioRepo.save(new Comentario(post, texto));
}
return post;
// Si falla, todo se revierte (rollback)
}
@Transactional(readOnly = true)
public List<Post> listar() {
return repo.findAll();
}@Transactional garantiza atomicidad — todo o nada. Si ocurre una excepción, hace rollback. readOnly = true optimiza lecturas. Colócalo en el service, no en el controller.
Scheduled tasks
@Configuration
@EnableScheduling
public class ScheduleConfig {}
@Component
public class TareasProgramadas {
@Scheduled(cron = "0 0 2 * * ?")
public void limpiarTokensExpirados() {
tokenRepo.deleteByExpiraEnBefore(LocalDateTime.now());
}
@Scheduled(fixedRate = 60000) // cada 60s
public void sincronizarCache() {
cacheService.refresh();
}
}@EnableScheduling activa el planificador. cron define la expresión (2 de la madrugada). fixedRate ejecuta cada X ms. Útil para limpiezas, syncs e informes. No usar en múltiples instancias sin lock.
Inyección de dependencias
// Recomendado: constructor (implícito con un solo constructor)
@Service
public class EmailService {
private final MailSender sender;
public EmailService(MailSender sender) {
this.sender = sender;
}
}
// Alternativa: @Autowired en el campo (evitar)
@Autowired
private MailSender sender;
// Qualifier para múltiples implementaciones:
@Autowired
@Qualifier("smtp")
private MailSender sender;La inyección por constructor es testeable e inmutable. @Autowired en el campo dificulta los tests. @Qualifier elige entre múltiples beans del mismo tipo. Con un constructor, @Autowired es opcional.
Configuração
application.properties
server.port=8080 spring.application.name=mi-app spring.datasource.url=jdbc:mysql://localhost:3306/mi_bd spring.datasource.username=root spring.datasource.password=secret spring.jpa.hibernate.ddl-auto=validate spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true
application.properties centraliza la configuración. ddl-auto=validate verifica el schema sin alterarlo. show-sql loguea queries en dev. En producción usa validate o none, nunca update.
CORS
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:3000")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}CORS permite peticiones de otros dominios. allowedOrigins restringe orígenes (nunca uses * con credentials). maxAge cachea el preflight. Esencial para APIs consumidas por frontends separados.
application.yml
spring:
datasource:
url: jdbc:mysql://localhost:3306/mi_bd
username: root
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate
show-sql: false
server:
port: 8080
servlet:
context-path: /apiFormato YAML alternativo al properties. ${DB_PASSWORD} lee de variables de entorno. context-path prefija todas las rutas. La indentación define la jerarquía. Elige un formato y mantén la consistencia.
Variables de entorno
# application.properties con placeholders:
spring.datasource.password=${DB_PASSWORD}
app.jwt.secret=${JWT_SECRET}
app.mail.api-key=${MAIL_API_KEY:default-key}
# .env (con spring-dotenv):
DB_PASSWORD=super_secret
JWT_SECRET=clave-jwt-aqui
# Docker:
# docker run -e DB_PASSWORD=secret app${VAR} resuelve de env, system properties o args. :default define el fallback. Nunca commitees secrets en el properties. Usa spring-dotenv para archivos .env en dev. En prod, usa env vars o Vault.
Profiles
# application-dev.properties spring.datasource.url=jdbc:h2:mem:dev spring.jpa.show-sql=true # application-prod.properties spring.datasource.url=jdbc:mysql://prod-server/bd spring.jpa.show-sql=false # Activar: spring.profiles.active=dev # O vía CLI: java -jar app.jar --spring.profiles.active=prod
Los Profiles separan la configuración por entorno. Los archivos siguen application-{profile}.properties. spring.profiles.active selecciona el activo. Ideal para dev, staging y prod. Puedes combinar múltiples perfiles.
Beans condicionales
@Configuration
public class CacheConfig {
@Bean
@ConditionalOnProperty(name = "app.cache.enabled",
havingValue = "true")
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("posts");
}
@Bean
@ConditionalOnMissingBean
public Clock clock() {
return Clock.systemDefaultZone();
}
}@ConditionalOnProperty crea el bean solo si la propiedad existe. @ConditionalOnMissingBean evita duplicados. @Profile("dev") restringe por entorno. Útil para configuración modular y testeable.
@Value y @ConfigurationProperties
// Valor individual:
@Value("${app.upload.max-size:10MB}")
private String maxUpload;
// Grupo tipado:
@ConfigurationProperties(prefix = "app.mail")
public record MailProperties(
String host,
int port,
String from
) {}
// Activar:
@Configuration
@EnableConfigurationProperties(MailProperties.class)
public class AppConfig {}@Value inyecta una propiedad con default (:10MB). @ConfigurationProperties mapea un prefijo a una clase. Más seguro y testeable que múltiples @Value. Valida con @Validated.
Avançado e Deploy
Actuator (health y métricas)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
# application.properties:
management.endpoints.web.exposure.include=health,metrics,info
management.endpoint.health.show-details=always
# Endpoints:
# GET /actuator/health
# GET /actuator/metrics/jvm.memory.used
# GET /actuator/infoActuator expone endpoints de monitorización. /health para load balancers y Kubernetes. /metrics se integra con Prometheus/Grafana. show-details muestra BD, disco, etc. Protégelo con Security.
Docker y deploy
# Build: ./mvnw clean package -DskipTests # Dockerfile: FROM eclipse-temurin:21-jre-alpine WORKDIR /app COPY target/*.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] # Build y run: docker build -t mi-app . docker run -p 8080:8080 \ -e DB_PASSWORD=secret mi-app
eclipse-temurin:21-jre-alpine es la imagen base ligera. El JAR es autosuficiente con Tomcat embedded. -e pasa variables de entorno. Usa multi-stage build para optimizar. Publica en Docker Hub o ECR.
Swagger / OpenAPI
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.5.0</version>
</dependency>
# Acceder en:
# http://localhost:8080/swagger-ui.html
# http://localhost:8080/v3/api-docs
// Personalizar:
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info().title("API Posts").version("1.0"));
}springdoc-openapi genera documentación automática. swagger-ui.html es la interfaz interactiva. Detecta endpoints, DTOs y validaciones. Anota con @Operation y @Schema para detalles extra.
Flyway (migraciones)
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
# src/main/resources/db/migration/
# V1__crear_posts.sql
# V2__add_columna_slug.sql
# V3__crear_tabla_tags.sql
spring.flyway.enabled=true
spring.jpa.hibernate.ddl-auto=validateFlyway gestiona migraciones de BD versionadas. Los ficheros siguen V{n}__descripcion.sql. Se ejecutan en orden — nunca edites los aplicados. ddl-auto=validate confirma que el schema coincide. Esencial para equipos y CI/CD.
Cache con @Cacheable
@Configuration
@EnableCaching
public class CacheConfig {}
@Service
public class PostService {
@Cacheable(value = "posts", key = "#id")
public Post findById(Long id) {
return repo.findById(id).orElseThrow();
}
@CacheEvict(value = "posts", key = "#post.id")
public Post update(Post post) {
return repo.save(post);
}
@CacheEvict(value = "posts", allEntries = true)
public void limpiarCache() {}
}@EnableCaching activa el soporte de caché. @Cacheable guarda el resultado — las llamadas siguientes no se ejecutan. @CacheEvict invalida al actualizar. key usa SpEL. Usa Redis en producción con spring-boot-starter-data-redis.
Logging y SLF4J
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Service
public class PostService {
private static final Logger log =
LoggerFactory.getLogger(PostService.class);
public Post create(PostDTO dto) {
log.info("Crear post: {}", dto.título());
try {
Post p = repo.save(new Post(dto));
log.debug("Post creado con id={}", p.getId());
return p;
} catch (Exception e) {
log.error("Fallo al crear post", e);
throw e;
}
}
}
# application.properties:
logging.level.com.app=DEBUGSLF4J es la API de logging estándar. Usa {} como placeholder (evita concatenación). log.info para eventos, log.error con excepción para el stacktrace. logging.level controla la verbosidad por paquete. Usa @Slf4j de Lombok para simplificar.
Async y @CompletableFuture
@Configuration
@EnableAsync
public class AsyncConfig {}
@Service
public class EmailService {
@Async
public CompletableFuture<Void> enviarAsync(String to) {
// operación lenta (envío de email)
mailSender.send(to, "Asunto", "Cuerpo");
return CompletableFuture.completedFuture(null);
}
}
// En el controller:
emailService.enviarAsync("user@mail.com");
return ResponseEntity.accepted().build(); // 202@EnableAsync activa la ejecución asíncrona. @Async corre en un hilo separado. El controller responde inmediatamente con 202. CompletableFuture permite encadenar resultados. Ideal para emails y notificaciones.
Segurança
Security config base
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(
HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm ->
sm.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
}@EnableWebSecurity activa Spring Security. csrf.disable() para APIs stateless. STATELESS no crea sesión HTTP. permitAll() libera rutas públicas. El resto exige autenticación.
Login endpoint
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@PostMapping("/login")
public ResponseEntity<TokenResponse> login(
@RequestBody @Valid LoginRequest req) {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
req.email(), req.clave()));
String token = jwtService.generateToken(req.email());
return ResponseEntity.ok(new TokenResponse(token));
}
@PostMapping("/registro")
public ResponseEntity<Void> registrar(
@RequestBody @Valid RegistroRequest req) {
userService.create(req);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
}AuthenticationManager valida credenciales contra el UserDetailsService. Si falla, lanza BadCredentialsException. El token JWT se devuelve al cliente. Los endpoints de auth deben ser permitAll().
Password encoder
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
// Registro:
String hash = encoder.encode("clave123");
user.setPassword(hash);
// Login:
boolean válido = encoder.matches("clave123", user.getPassword());BCryptPasswordEncoder genera hash con salt automático. El parámetro 12 es el coste (fuerza). matches() compara texto con hash. Nunca guardes contraseñas en texto plano. Cada hash es único incluso para la misma contraseña.
Reglas por role
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/posts/**")
.hasAnyRole("ADMIN", "MODERADOR")
.anyRequest().authenticated()
);
// En el controller (method security):
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(Long id) { ... }
@PreAuthorize("#id == authentication.principal.id")
public PerfilDTO getPerfil(Long id) { ... }hasRole() verifica la authority ROLE_*. @PreAuthorize protege métodos individuales. #id accede a parámetros vía SpEL. Actívalo con @EnableMethodSecurity. Combina URL rules con method security.
JWT - generar token
@Service
public class JwtService {
@Value("${app.jwt.secret}")
private String secret;
public String generateToken(String email) {
return Jwts.builder()
.subject(email)
.issuedAt(new Date())
.expiration(new Date(
System.currentTimeMillis() + 86400000))
.signWith(getSigningKey())
.compact();
}
private Key getSigningKey() {
return Keys.hmacShaKeyFor(secret.getBytes());
}
}Jwts.builder() construye el token JWT. subject identifica al usuario. expiration define la validez (24h aquí). hmacShaKeyFor usa clave HMAC. Guarda el secret en env, nunca en el código.
UserDetailsService
@Service
public class CustomUserDetailsService
implements UserDetailsService {
private final UserRepository repo;
@Override
public UserDetails loadUserByUsername(String email)
throws UsernameNotFoundException {
User user = repo.findByEmail(email)
.orElseThrow(() ->
new UsernameNotFoundException(email));
return org.springframework.security
.core.userdetails.User.builder()
.username(user.getEmail())
.password(user.getPassword())
.roles(user.getRole().name())
.build();
}
}UserDetailsService carga el usuario para la autenticación. loadUserByUsername es llamado por el AuthenticationManager. Devuelve UserDetails con roles. Spring Security compara la contraseña con el encoder.
JWT - validar (filtro)
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest req,
HttpServletResponse res,
FilterChain chain) throws Exception {
String token = extractToken(req);
if (token != null && jwtService.isValid(token)) {
String email = jwtService.getEmail(token);
var auth = new UsernamePasswordAuthenticationToken(
email, null, List.of());
SecurityContextHolder.getContext()
.setAuthentication(auth);
}
chain.doFilter(req, res);
}
}OncePerRequestFilter se ejecuta una vez por petición. Extrae el token del header Authorization: Bearer. Si es válido, define la autenticación en el SecurityContext. Registra el filtro antes del UsernamePasswordAuthenticationFilter.
Testes
Test de integración
@SpringBootTest
class PostServiceTest {
@Autowired
private PostService service;
@Autowired
private PostRepository repo;
@Test
void crearPost() {
var dto = new CrearPostDTO("Título", "Texto", 1L);
Post post = service.create(dto);
assertNotNull(post.getId());
assertEquals("Título", post.getTitulo());
assertTrue(repo.existsById(post.getId()));
}
}@SpringBootTest levanta el contexto completo. Úsalo para tests de integración reales. @Autowired inyecta beans reales. assertNotNull y assertEquals son de JUnit 5. Más lento pero más realista.
Testcontainers
@SpringBootTest
@Testcontainers
class IntegrationTest {
@Container
static MySQLContainer<?> mysql =
new MySQLContainer<>("mysql:8.0")
.withDatabaseName("test_db");
@DynamicPropertySource
static void config(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url",
mysql::getJdbcUrl);
registry.add("spring.datasource.username",
mysql::getUsername);
registry.add("spring.datasource.password",
mysql::getPassword);
}
}@Testcontainers levanta contenedores Docker para tests. MySQLContainer crea un MySQL real efímero. @DynamicPropertySource inyecta la URL dinámica. Requiere Docker instalado. Tests más fiables que H2.
MockMvc (testear API)
@WebMvcTest(PostController.class)
class PostControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private PostService service;
@Test
void listarPosts() throws Exception {
when(service.findAll()).thenReturn(List.of(
new Post(1L, "Post 1")));
mockMvc.perform(get("/api/posts"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].título")
.value("Post 1"));
}
}@WebMvcTest carga solo la capa web. @MockBean sustituye dependencias por mocks. MockMvc simula peticiones HTTP sin servidor. jsonPath verifica campos del JSON. Rápido y aislado.
Testear con perfiles
# src/test/resources/application-test.properties
spring.datasource.url=jdbc:h2:mem:test
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
// En el test:
@SpringBootTest
@ActiveProfiles("test")
class AppIntegrationTest {
@Test
void contextoCarga() {
// Si llega aquí, contexto OK
}
}@ActiveProfiles("test") usa la config de test. create-drop crea y borra el schema en cada run. H2 en memoria es rápido para CI. Colócalo en src/test/resources/. Separa siempre la config de test de la de producción.
Mockito (unit tests)
@ExtendWith(MockitoExtension.class)
class PostServiceUnitTest {
@Mock
private PostRepository repo;
@InjectMocks
private PostService service;
@Test
void crearPostExito() {
when(repo.save(any())).thenAnswer(inv -> {
Post p = inv.getArgument(0);
p.setId(1L);
return p;
});
Post resultado = service.create(dto);
assertEquals(1L, resultado.getId());
verify(repo).save(any());
}
}@ExtendWith(MockitoExtension.class) activa Mockito. @Mock crea dependencias falsas. @InjectMocks inyecta los mocks en el service. when/thenReturn define el comportamiento. verify confirma las llamadas.
AssertJ y tests REST
@Test
void crearPostRetorna201() throws Exception {
String json = """
{"título": "Nuevo Post", "contenido": "Texto aquí"}
""";
mockMvc.perform(post("/api/posts")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").isNumber())
.andExpect(jsonPath("$.título").value("Nuevo Post"));
}
// Con RestTemplate/WebTestClient:
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(body.getTitulo()).contains("Post");mockMvc.perform() envía peticiones con body JSON. jsonPath valida la estructura de la respuesta. assertThat de AssertJ es más legible. Los text blocks (\"\"\") facilitan el JSON inline. Combina assertions de status + body.
Data JPA test
@DataJpaTest
class PostRepositoryTest {
@Autowired
private PostRepository repo;
@Autowired
private TestEntityManager em;
@Test
void findBySlug() {
em.persist(new Post("Post Prueba", "slug-prueba"));
Optional<Post> encontrado =
repo.findBySlug("slug-prueba");
assertTrue(encontrado.isPresent());
assertEquals("Post Prueba", encontrado.get().getTitulo());
}
}@DataJpaTest testea solo la capa JPA con H2 en memoria. TestEntityManager inserta datos de prueba. Cada test hace rollback automático. Ideal para validar queries y constraints.