En Java es fácil caer en el “try { ... } catch (Exception e) { ... }
y ya” — pero construir sistemas robustos exige una estrategia clara: qué lanzar, dónde capturar, cómo reportar y cómo recuperarse. Aquí tienes una guía práctica, desde buenas prácticas del lenguaje hasta patrones de backend productivo con Spring.
1) Filosofía: ¿Error, Excepción o Resultado?
- Excepciones: eventos excepcionales que invalidan el flujo normal.
- Resultados explícitos: cuando el fallo es esperable (ej. validación).
- Logs/Métricas: convierten errores en observabilidad (SRE mindset).
Regla de oro:
- Errores de programación (NPE, index out of bounds) → unchecked.
- Fallos recuperables de negocio (validación, insuficiencia de saldo) → resultado explícito o excepción de dominio unchecked, capturada cerca de la frontera (API/servicio).
- Fallos de infraestructura (timeout, red, DB down) → excepción unchecked + reintentos/backoff + telemetría.
2) Checked vs Unchecked: decide con intención
- Checked (
IOException
) obligan a manejar/propagar; tienden a “ensuciar” firmas. - Unchecked (
RuntimeException
) simplifican APIs y son estándar en frameworks modernos.
Recomendación práctica (Java 17+):
- Usa unchecked para casi todo en aplicaciones (dominio e infraestructura).
- Reserva checked para librerías o integraciones donde el contrato requiera fuerza en manejo.
3) Jerarquía de excepciones de dominio (limpia y expresiva)
Diseña excepciones con intención y metadatos.
public abstract class DomainException extends RuntimeException {
private final String code;
private final Map<String, Object> details;
protected DomainException(String code, String message, Map<String, Object> details, Throwable cause) {
super(message, cause);
this.code = code;
this.details = details == null ? Map.of() : Map.copyOf(details);
}
public String code() { return code; }
public Map<String, Object> details() { return details; }
}
public final class InsufficientFundsException extends DomainException {
public InsufficientFundsException(String accountId, BigDecimal balance, BigDecimal amount) {
super(
"INSUFFICIENT_FUNDS",
"Saldo insuficiente para debitar " + amount,
Map.of("accountId", accountId, "balance", balance, "amount", amount),
null
);
}
}
Ventajas:
- Código legible en logs/JSON (
code
,details
). - Mapeo claro a HTTP (400/409) o a eventos de error
4) Errores vs Validación: no todo merece una excepción
Para validaciones esperables, prefiere resultados explícitos que no rompen el flujo.
Optional
para presencia/ausencia (no para errores detallados)
Optional<User> user = userRepo.findByEmail(email);
if (user.isEmpty()) return Result.fail("USER_NOT_FOUND");
Result<T,E>
consealed
(Java 17)
public sealed interface Result<T, E> permits Result.Ok, Result.Err {
record Ok<T, E>(T value) implements Result<T, E> {}
record Err<T, E>(E error) implements Result<T, E> {}
static <T,E> Ok<T,E> ok(T v){ return new Ok<>(v); }
static <T,E> Err<T,E> err(E e){ return new Err<>(e); }
}
// USO
Result<Order, String> r = service.placeOrder(cmd);
if (r instanceof Result.Err<Order, String> e) {
return Response.badRequest(e.error()); // sin excepciones para flujos esperables
}
5) Anti-patrones que matan la mantenibilidad
- Cachar
Exception
genérico y tragarlo:
try { ... } catch (Exception e) { /* nada */ } // ❌
- Perder el
cause
al relanzar
throw new RuntimeException("falló"); // ❌ sin cause
// ✅
throw new RuntimeException("falló", e);
- Excepciones para control de flujo normal (ej. usar
try/catch
para buscar en un mapa). - Logear tres veces el mismo error (controller, service, filter) → ruido.
- Mensajes crípticos sin contexto (IDs, inputs sanitizados)
6) Fronteras limpias: mapping por capas
- Dominio/Aplicación: lanza
DomainException
y no logees aquí (deja el log a la frontera). - Infraestructura: encapsula excepciones de proveedor (DB/HTTP) a las tuyas.
- Frontera API (controller/filter): mapea a HTTP/JSON y logea una sola vez
Spring Boot: @ControllerAdvice
+ Problem Details (RFC 7807 style)
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(DomainException.class)
public ResponseEntity<ProblemDetail> handleDomain(DomainException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
pd.setTitle(ex.code());
pd.setDetail(ex.getMessage());
ex.details().forEach(pd::setProperty);
return ResponseEntity.of(pd).build();
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ProblemDetail> handleUnexpected(Exception ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
pd.setTitle("UNEXPECTED_ERROR");
pd.setDetail("Ocurrió un error inesperado. Intenta más tarde.");
// log único con stacktrace + correlationId
return ResponseEntity.of(pd).build();
}
}
Conclusión
El manejo de errores en Java no se trata solo de envolver código en un try-catch
y cruzar los dedos.
Cuando defines qué lanzar, dónde capturarlo y cómo reportarlo, tu código deja de ser reactivo y empieza a ser intencional:
- Usas excepciones con propósito, no por inercia.
- Separas errores esperables de los inesperados.
- Centralizas la lógica de manejo para que tu aplicación sea predecible y mantenible.
- Das a tus consumidores (APIs, servicios internos o externos) respuestas claras y consistentes.
En pocas palabras: pasas de “apagar fuegos” a diseñar resiliencia en tu software.