---
name: java-experto
description: >
  Java moderno con Spring Boot, JPA/Hibernate, records, sealed classes y streams.
  Cargar cuando se implementen servicios Spring Boot, entidades JPA, repositorios
  Spring Data, lógica de negocio Java 17+ o se configure application.yml/profiles.
version: "1.0.0"
herramientasPermitidas: [Read, Grep]
exclusiones:
  - "No cargar para escribir tests JUnit o de integración Spring — para testing cargar `java-testing`."
  - "No cargar para diseñar patrones Java avanzados (records, sealed classes, pattern matching) — para diseño cargar `java-patrones`."
  - "No cargar para errores de compilación Java o Maven/Gradle — para build errors cargar `build-errors-java`."
  - "No cargar para servicios Kotlin/Android — Kotlin tiene patrones distintos (coroutines, Compose); cargar `kotlin-experto`."
evolvable: true  # default para skill estandar
---
# Java Experto — Spring Boot y Java Moderno

Cubre el stack Java moderno: Spring Boot 3, JPA/Hibernate, Spring Data, records,
sealed classes y Stream API avanzado. Aplica a servicios REST, acceso a datos y
lógica de dominio en proyectos Java 17+.

## Cuándo cargar este skill

Invoca `Skill("java-experto")` cuando:

- Se implementen controllers, services o repositories Spring Boot
- Se definan entidades JPA o relaciones Hibernate
- Se usen records como DTOs o value objects
- Se configure Spring Profiles o application.yml
- Se use Stream API con collectors, groupingBy o flatMap complejos
- Se implemente concurrencia con CompletableFuture

## Cuándo NO cargar

- La tarea es escribir tests JUnit, MockMvc o de integración con TestContainers — para testing cargar `java-testing`.
- La pregunta es sobre patrones Java modernos (records como value objects, sealed classes, pattern matching) — para diseño cargar `java-patrones`.
- Los errores son de compilación Java, Maven o Gradle — para build errors cargar `build-errors-java`.
- El proyecto es Kotlin/Android — Kotlin tiene patrones distintos (coroutines, Hilt, Compose); cargar `kotlin-experto`.

## Conceptos clave

### Spring Boot Autoconfiguration

Spring Boot configura beans automáticamente según el classpath. El desarrollador
declara dependencias; Spring Boot registra la infraestructura. Sobreescribir solo
lo necesario con `@Bean` en una `@Configuration`.

### JPA Entity Lifecycle

Ciclo: Transient -> Managed -> Detached -> Removed. Un entity managed dentro de
una transacción activa se sincroniza automáticamente al flush. Fuera de transacción
es Detached y los cambios no persisten.

### Records como DTOs

Los records son inmutables, ideales para DTOs de request/response. No mezclar
records con entidades JPA — las entidades requieren estado mutable y constructor
sin argumentos.

### Sealed Classes para dominios cerrados

`sealed interface` + `permits` define un conjunto cerrado de subtipos conocidos
en tiempo de compilación. Permite pattern matching exhaustivo en switch.

## Reglas obligatorias

### Separar entidad de DTO — NUNCA usar entity como DTO

Exponer entidades JPA directamente en la API serializa relaciones no deseadas,
rompe el encapsulamiento y crea dependencias de schema en el contrato de API.

**Correcto**:
```java
// Entidad — solo para persistencia
@Entity
public class Pedido {
    @Id @GeneratedValue UUID id;
    @ManyToOne(fetch = FetchType.LAZY) Cliente cliente;
    @OneToMany(mappedBy = "pedido", cascade = CascadeType.ALL)
    List<ItemPedido> items;
}

// DTO — solo para transporte
public record PedidoResponse(UUID id, String clienteNombre, List<ItemResponse> items) {}
```

### Inyectar por constructor — NUNCA @Autowired en campos

La inyección en campos oculta dependencias, impide tests unitarios sin contenedor
y viola el principio de inversión de dependencias.

```java
// MAL
@Service
public class PedidoService {
    @Autowired private PedidoRepository repo; // no testeable sin contexto Spring
}

// BIEN
@Service
@RequiredArgsConstructor
public class PedidoService {
    private final PedidoRepository repo; // testeable con mock directo
}
```

### Lógica en services — NUNCA en controllers

El controller orquesta HTTP: valida input, delega al service, mapea respuesta.
No contiene if/else de negocio, cálculos ni acceso directo a repositorios.

### @Transactional solo donde hay escritura

Anotar con `@Transactional(readOnly = true)` los métodos de solo lectura.
Evitar `@Transactional` en la clase entera — propaga transacciones innecesarias
y bloquea recursos.

### FetchType.LAZY por defecto en relaciones

`FetchType.EAGER` en `@ManyToOne` y `@OneToMany` ejecuta JOINs automáticos en
cada query. Usar LAZY y cargar explícitamente con JOIN FETCH cuando se necesite.

## Patrones recomendados

### Controller delgado

```java
@RestController
@RequestMapping("/v1/pedidos")
@RequiredArgsConstructor
public class PedidoController {
    private final PedidoService service;

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public PedidoResponse crear(@Valid @RequestBody CrearPedidoRequest req) {
        return service.crear(req);
    }

    @GetMapping("/{id}")
    public PedidoResponse obtener(@PathVariable UUID id) {
        return service.obtener(id)
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
    }
}
```

### Record como DTO con validación

```java
public record CrearPedidoRequest(
    @NotNull UUID clienteId,
    @NotEmpty List<@Valid ItemRequest> items
) {}

public record ItemRequest(
    @NotNull UUID productoId,
    @Positive int cantidad
) {}
```

### Sealed interface para resultados de dominio

```java
public sealed interface ResultadoPago permits ResultadoPago.Aprobado,
    ResultadoPago.Rechazado, ResultadoPago.Pendiente {

    record Aprobado(String transaccionId) implements ResultadoPago {}
    record Rechazado(String motivo) implements ResultadoPago {}
    record Pendiente(Duration tiempoEstimado) implements ResultadoPago {}
}

// Uso con switch exhaustivo (no requiere default)
String mensaje = switch (resultado) {
    case ResultadoPago.Aprobado a -> "Aprobado: " + a.transaccionId();
    case ResultadoPago.Rechazado r -> "Rechazado: " + r.motivo();
    case ResultadoPago.Pendiente p -> "Pendiente " + p.tiempoEstimado();
};
```

### Stream API con collectors avanzados

```java
// groupingBy con downstream collector
Map<String, DoubleSummaryStatistics> estadisticasPorCategoria = productos.stream()
    .collect(Collectors.groupingBy(
        Producto::categoria,
        Collectors.summarizingDouble(Producto::precio)
    ));

// partitioningBy para bifurcar
Map<Boolean, List<Pedido>> pedidosPorEstado = pedidos.stream()
    .collect(Collectors.partitioningBy(p -> p.estatus() == Estatus.COMPLETADO));
```

### CompletableFuture para operaciones paralelas

```java
CompletableFuture<Cliente> clienteFuture = CompletableFuture
    .supplyAsync(() -> clienteService.obtener(clienteId), executor);

CompletableFuture<List<Producto>> productosFuture = CompletableFuture
    .supplyAsync(() -> productoService.listar(filtro), executor);

return clienteFuture.thenCombine(productosFuture,
    (cliente, productos) -> new ResumenResponse(cliente, productos))
    .get(5, TimeUnit.SECONDS);
```

## Anti-patrones conocidos

### Optional.get() sin verificar — NUNCA hacer esto

```java
// MAL — lanza NoSuchElementException si esta vacio
Optional<Usuario> u = repo.findById(id);
return u.get(); // excepcion silenciosa

// BIEN
return repo.findById(id)
    .orElseThrow(() -> new NotFoundException("Usuario no encontrado: " + id));
```

### Transacción demasiado larga

```java
// MAL — transaccion abierta durante llamada HTTP externa
@Transactional
public void procesarPedido(UUID id) {
    Pedido pedido = repo.findById(id).orElseThrow();
    String token = pagoExternoClient.obtenerToken(); // llamada HTTP dentro de tx
    pedido.marcarEnProceso(token);
}

// BIEN — llamada externa fuera de transaccion
public void procesarPedido(UUID id) {
    String token = pagoExternoClient.obtenerToken(); // fuera de tx
    guardarToken(id, token);                         // tx minima
}

@Transactional
private void guardarToken(UUID id, String token) {
    Pedido pedido = repo.findById(id).orElseThrow();
    pedido.marcarEnProceso(token);
}
```

### Null en colecciones

```java
// MAL — NullPointerException en iteracion
List<String> tags = null;
tags.forEach(System.out::println);

// BIEN — colecciones vacias en lugar de null
List<String> tags = Collections.emptyList();
// o en entidad JPA
@OneToMany
List<Tag> tags = new ArrayList<>();
```

## Checklist de verificación

- [ ] Entidades JPA no se exponen directamente como response body
- [ ] Toda inyección es por constructor (no @Autowired en campos)
- [ ] @Transactional solo en métodos de escritura; readOnly=true en consultas
- [ ] FetchType.LAZY en todas las relaciones; JOIN FETCH explicito cuando se necesite
- [ ] Controllers sin lógica de negocio: solo validación + delegación + mapeo
- [ ] Optional usado con orElseThrow o orElse, nunca con .get() directo
- [ ] Colecciones inicializadas vacías, nunca null

## Referencias

- Spring Boot Reference: https://docs.spring.io/spring-boot/docs/current/reference/html/
- JPA Spec (Jakarta Persistence 3.1): https://jakarta.ee/specifications/persistence/
- Java Records JEP 395: https://openjdk.org/jeps/395
- Sealed Classes JEP 409: https://openjdk.org/jeps/409

## Gotchas / Errores comunes no obvios

**`@Transactional` en un método privado o llamado desde la misma clase no activa la transacción**: Spring implementa `@Transactional` con un proxy AOP — si el método es privado o se llama con `this.metodo()` desde la misma clase, el proxy no intercepta la llamada y no hay transacción. Fix: mover el método anotado a otro bean, o inyectar la misma clase (self-injection) como dependencia para forzar el paso por el proxy.

**`FetchType.LAZY` con serialización JSON lanza `LazyInitializationException` en colecciones no cargadas**: si una entidad con relación lazy se serializa a JSON fuera de una sesión Hibernate abierta (por ejemplo, después de que el método `@Transactional` ya retornó), Jackson intenta acceder a la colección lazy y Hibernate lanza la excepción. Fix: usar DTOs y mapear explícitamente los datos necesarios dentro de la transacción, o configurar `spring.jpa.open-in-view=false` y manejar la carga en el service.

**`Optional.get()` sin `isPresent()` lanza `NoSuchElementException` en lugar de `NullPointerException`**: el propósito de `Optional` es reemplazar null, pero usar `.get()` directamente reproduce el mismo problema con un tipo de excepción diferente. Fix: usar siempre `.orElseThrow(() -> new EntidadNotFoundException(...))` para dar un mensaje de error semántico, nunca `.get()` directo.

**Records no pueden ser entidades JPA**: `@Entity record Producto(String nombre)` compila pero falla en runtime porque JPA requiere un constructor sin parámetros y campos mutables. Cause: los records son inmutables por diseño. Fix: usar records solo como DTOs de transferencia; definir entidades JPA como clases con `@Entity`, `@Id` y campos anotados.

---
*Skill creado con swl:crear-skill el 2026-03-31. Versión 1.0.0.*
