Programación en Java
Excepciones en Java: checked vs unchecked, try-with-resources y buen diseño de errores
Principiante9 min de lecturaActualizado
Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés
Las excepciones son la forma que tiene un método Java de avisar de que no pudo hacer lo que prometía. Esta guía explica la jerarquía y las reglas de propagación, cuándo usar excepciones checked o unchecked, cómo try-with-resources cierra bien los recursos, cómo diseñar tus propios tipos de excepción y cómo registrar y traducir errores sin perder la causa. Aprenderás a escribir un manejo de errores que falla de forma visible, limpia y una sola vez.
Lo que aprenderás
- Explicar la jerarquía de Throwable y cómo se propaga una excepción por la pila de llamadas
- Elegir entre excepciones checked y unchecked según el tipo de fallo
- Usar correctamente try-with-resources y el multi-catch, incluidas las excepciones suprimidas
- Diseñar excepciones propias que aporten contexto y conserven la causa
- Evitar los antipatrones clásicos: tragarse excepciones, capturar Throwable, usar excepciones como control de flujo
Una excepción es la señal de que un método no pudo cumplir su contrato, y viaja hacia arriba por la pila de llamadas hasta que algo la trata. El diseño de Java tiene dos rasgos característicos: las excepciones son objetos normales con una jerarquía de clases, y el compilador obliga a tratarlas en una de las ramas de esa jerarquía. Ambas cosas se explican en una frase y se usan mal con facilidad, y usarlas mal sale caro: errores silenciados que aparecen semanas después como datos corruptos, stack traces que apuntan al sitio equivocado, recursos que quedan sin liberar en cada fallo. Esta guía cubre la mecánica, las decisiones de diseño y los patrones que aguantan en producción. El código está escrito para Java 21.
La jerarquía y qué significa cada rama
Throwable
├── Error JVM-level failures: OutOfMemoryError, StackOverflowError, LinkageError
└── Exception
├── RuntimeException unchecked: NullPointerException, IllegalArgumentException,
│ IllegalStateException, IndexOutOfBoundsException, ArithmeticException
└── (everything else) checked: IOException, SQLException, InterruptedException, ParseException
Los Errors indican situaciones de las que una aplicación no debería intentar recuperarse. Capturar OutOfMemoryError y seguir adelante rara vez tiene sentido, porque la JVM puede estar ya en un estado inconsistente.
Las excepciones checked extienden Exception pero no RuntimeException. Un método que pueda lanzar una debe declararla con throws, y quien lo llama debe capturarla o declararla a su vez. El compilador lo exige, y esa es precisamente la idea: el fallo forma parte de la firma del método y quien llama no puede ignorarlo por descuido.
Las excepciones unchecked extienden RuntimeException. Pueden lanzarse en cualquier sitio sin declararlas y, por convención, representan errores de programación: un null donde se necesitaba un objeto, un argumento fuera de rango, un método llamado en un estado incorrecto. No se espera que quien llama las capture; la solución es corregir el bug.
El propio Throwable lleva un mensaje, una causa, un stack trace capturado al construirse y una lista de excepciones suprimidas. Capturar el stack trace es lo que hace que las excepciones salgan caras frente a un valor de retorno (recorrer la pila cuesta microsegundos, no nanosegundos), y por eso las excepciones deberían marcar situaciones excepcionales y no resultados esperados.
Propagación: try, catch, finally
Cuando una sentencia lanza una excepción, la ejecución abandona el resto del bloque y la JVM busca en las sentencias try que la rodean, empezando por la más interna, un catch cuyo tipo coincida. Si ninguno coincide en el método actual, el método termina de forma abrupta y la búsqueda continúa en quien lo llamó. Una excepción que llega a la cima de un thread sin handler termina ese thread, después de que su handler de excepciones no capturadas haya impreso la traza; si era el último thread no daemon, la JVM se cierra.
static int parsePort(String raw) {
try {
int port = Integer.parseInt(raw);
if (port < 1 || port > 65_535) {
throw new IllegalArgumentException("port out of range: " + port);
}
return port;
} catch (NumberFormatException e) {
throw new IllegalArgumentException("port is not a number: " + raw, e); // translate, keep cause
} finally {
System.out.println("parsePort finished"); // runs either way
}
}
Las cláusulas catch se prueban en orden, y poner una cláusula para un supertipo antes que la de un subtipo es un error de compilación, porque la segunda nunca podría ejecutarse. El multi-catch (catch (IOException | SQLException e)) trata varios tipos sin relación entre sí en un solo bloque; la variable es implícitamente final.
finally se ejecuta tanto si el bloque try termina normalmente como si hace return o lanza una excepción. Sirve para la limpieza que debe ocurrir pase lo que pase. Dos reglas lo mantienen seguro: nunca hagas return desde un finally (descarta en silencio cualquier excepción pendiente) y nunca dejes que finally lance (una excepción ahí sustituye a la original y oculta el fallo real).
try-with-resources
Antes de Java 7, cerrar bien un stream exigía un try/finally anidado con comprobaciones de null, e incluso el código más cuidadoso perdía la excepción original si close() también fallaba. try-with-resources se encarga de todo: cualquier objeto que implemente AutoCloseable declarado entre los paréntesis se cierra automáticamente al terminar el bloque, en orden inverso al de declaración.
Path source = Path.of("input.csv");
Path target = Path.of("output.csv");
try (BufferedReader in = Files.newBufferedReader(source);
BufferedWriter out = Files.newBufferedWriter(target)) {
String line;
while ((line = in.readLine()) != null) {
out.write(line.toUpperCase());
out.newLine();
}
} catch (IOException e) {
throw new UncheckedIOException("Failed to copy " + source + " to " + target, e);
}
Si el bloque lanza una excepción y close() también, se propaga la del bloque y el fallo de cierre se le adjunta mediante addSuppressed; puedes consultarlo con getSuppressed(). Desde Java 9 se puede usar directamente una variable efectivamente final declarada antes: try (existingConnection) { ... }.
Implementa AutoCloseable en tus propias clases que gestionen un recurso (una conexión, un lock, un directorio temporal) y documenta si close() es idempotente. Closeable es la subinterfaz específica de E/S, cuyo close() lanza IOException y debe ser idempotente.
Elegir entre checked y unchecked
Esta es la decisión de diseño que divide a los desarrolladores Java. La pauta de los autores del lenguaje es clara: excepciones checked para situaciones de las que cabe esperar razonablemente que quien llama se recupere, unchecked para los errores de programación. En la práctica, tres fuerzas empujan al código moderno hacia las unchecked:
- Lambdas y streams. Las interfaces funcionales como
FunctionySupplierno declaran excepciones checked, así que una excepción checked dentro de una lambda hay que capturarla y envolverla allí mismo. La guía de streams muestra lo ruidoso que resulta. - Capas. Una
SQLExceptionde la capa de persistencia no significa nada para un controlador web. Cada capa tendría que declararla o envolverla, de modo que la contaminación de firmas se extiende por toda la aplicación. - Frameworks. La jerarquía
DataAccessExceptionde Spring, laPersistenceExceptionde JPA y la mayoría de clientes HTTP son unchecked, y Kotlin, que corre sobre la misma JVM, eliminó por completo las excepciones checked.
Una política que funciona: mantener las excepciones checked en los bordes donde existe una recuperación concreta (FileNotFoundException cuando el usuario eligió el archivo, InterruptedException en código concurrente) y traducir todo lo demás a excepciones unchecked con un tipo descriptivo en cuanto entra en tu código. UncheckedIOException existe en el JDK justo para esta traducción.
| Situación | Qué usar |
|---|---|
| Quien llama pasó un argumento incorrecto | IllegalArgumentException (o NullPointerException mediante Objects.requireNonNull) |
| El objeto no está en el estado adecuado para la llamada | IllegalStateException |
| Operación no soportada por esta implementación | UnsupportedOperationException |
| Falló un recurso externo y es plausible reintentar o usar una alternativa | Checked, o un tipo unchecked propio del dominio en la frontera |
| Se viola una regla de negocio (saldo insuficiente) | Excepción de dominio, normalmente unchecked, o un tipo resultado |
| Bug detectado internamente | AssertionError o IllegalStateException |
Diseñar tus propias excepciones
Una excepción propia se justifica cuando quien llama necesita capturarla por separado o cuando lleva un contexto que un tipo genérico no puede llevar. Mantén la clase pequeña, extiende la base adecuada y ofrece los constructores estándar para que siempre se pueda adjuntar una causa:
public class PaymentDeclinedException extends RuntimeException {
private final String paymentId;
private final String declineCode;
public PaymentDeclinedException(String paymentId, String declineCode, Throwable cause) {
super("Payment %s declined with code %s".formatted(paymentId, declineCode), cause);
this.paymentId = paymentId;
this.declineCode = declineCode;
}
public String paymentId() { return paymentId; }
public String declineCode() { return declineCode; }
}
Los campos permiten a un handler actuar sobre datos estructurados (asignar un código de rechazo a un estado HTTP, decidir si reintentar) sin analizar el mensaje. Una jerarquía ayuda cuando los handlers necesitan distinta granularidad: PaymentException como base, PaymentDeclinedException y PaymentGatewayUnavailableException como hojas; quien llama puede capturar la base o una hoja.
Nombra las excepciones con el sufijo Exception y describe el problema, no la capa («OrderNotFoundException», no «RepositoryException»). Haz que el mensaje sea útil para quien lea un log a las 3 de la madrugada: incluye identificadores y valores, nunca datos sensibles como contraseñas o números de tarjeta completos.
Encadenamiento y traducción
Cuando capturas una excepción de bajo nivel y lanzas otra de más alto nivel, pasa la original como causa. Así el stack trace imprime secciones Caused by: hasta el fondo, que a menudo es la única forma de encontrar el fallo real. Lanzar una nueva excepción sin la causa es lo más dañino que puedes hacerle a la depuración futura.
La traducción debe hacerse una sola vez, en la frontera de la capa. Si cada método vuelve a envolver, la traza se convierte en una cebolla de cinco capas con mensajes idénticos.
Manejo: dónde y cómo
Un buen manejo sigue una regla práctica: captura tarde, lanza pronto. Lanza en cuanto detectes el problema (valida los argumentos al principio del método) y captura solo donde puedas hacer algo útil. Las opciones útiles son:
- Recuperarse: reintentar, usar un valor por defecto, recurrir a una caché.
- Traducir: envolver en un tipo que entiendan quienes llaman a la capa actual, con la causa.
- Informar y parar: en una frontera (handler HTTP, listener de mensajes,
main), registrar con la traza completa y convertir en una respuesta o un código de salida.
Todo lo demás (registrar y relanzar, capturar y devolver null, bloques catch vacíos) empeora las cosas. Registrar y relanzar produce la misma traza dos veces, una en cada capa; registra solo en la frontera.
// Boundary handler: the one place that catches broadly
public HttpResponse handle(HttpRequest request) {
try {
return service.process(request);
} catch (PaymentDeclinedException e) {
return HttpResponse.status(402).body(e.declineCode()); // expected domain outcome
} catch (RuntimeException e) {
log.error("Unhandled failure for request {}", request.id(), e); // full trace, once
return HttpResponse.status(500).body("internal error");
}
}
Pasa el objeto de la excepción como último argumento al logger, no e.getMessage(): los frameworks de logging solo imprimen la traza completa cuando reciben el Throwable.
InterruptedException
InterruptedException merece su propia regla. La lanzan los métodos bloqueantes (sleep, join, BlockingQueue.take) cuando otro thread pide a este que se detenga. Tragársela anula esa petición. Si no puedes propagarla, restaura el flag con Thread.currentThread().interrupt(); y sal del trabajo lo antes posible. La guía de concurrencia explica el protocolo.
Excepciones y rendimiento
Construir una excepción rellena el stack trace, lo que recorre todos los frames del thread actual. En una pila de llamadas profunda eso son decenas de microsegundos: insignificante para un fallo real, ruinoso en un bucle que lanza para indicar «no encontrado». Cuando una excepción de verdad tiene que ser barata (la señal de control interna de una biblioteca, por ejemplo), las opciones son reutilizar una instancia preasignada o sobrescribir fillInStackTrace para que devuelva this; ambas sacrifican depuración a cambio de velocidad y corresponden al código de bibliotecas, no a la lógica de aplicación.
El compilador JIT también optimiza un método caliente que lanza una y otra vez eliminando el stack trace pasado un tiempo (-XX:-OmitStackTraceInFastThrow desactiva este comportamiento), y por eso un log de producción puede mostrar de repente una NullPointerException sin traza alguna. Si lo ves, esa excepción ya se ha lanzado miles de veces.
Errores comunes
Bloques catch vacíos. catch (Exception e) {} convierte un fallo en corrupción silenciosa de datos. Si de verdad se puede ignorar la excepción, explica por qué en un comentario y regístrala a nivel debug.
Capturar Throwable o Error. Acabarás capturando OutOfMemoryError y StackOverflowError y siguiendo con una JVM que puede estar en un estado inconsistente. Como mucho, captura Exception en las fronteras.
Perder la causa. throw new ServiceException("failed") dentro de un catch, sin e. El motivo real desaparece.
Hacer return desde finally. Sobrescribe tanto el valor de retorno como cualquier excepción en curso.
Excepciones como códigos de retorno. Lanzar UserNotFoundException desde una búsqueda de la que se espera que no encuentre nada la mitad de las veces. Devuelve un Optional<User> en su lugar.
Un throws Exception demasiado amplio. No le dice nada a quien llama y le obliga a capturas igual de amplias. Declara tipos concretos o usa excepciones unchecked.
Registrar en cada capa. Un solo fallo, seis stack traces en el log y alguien repasándolos a medianoche.
Validar demasiado tarde. Un argumento null guardado en un campo y desreferenciado tres llamadas más tarde produce una traza que apunta lejos del bug. Objects.requireNonNull(x, "x") al principio del método señala al culpable.
Puntos clave
- Las excepciones checked forman parte de la firma de un método y obligan a quien llama a decidir; las unchecked señalan bugs o fallos que quien llama no puede arreglar. El código moderno se inclina por las unchecked y traduce en las fronteras entre capas.
- try-with-resources cierra los recursos
AutoCloseableen orden inverso y conserva la excepción original, adjuntando los fallos declose()como suprimidos. - Nunca hagas return ni lances desde
finally: oculta la excepción que importaba. - Pasa siempre la excepción original como causa al traducir, y traduce una vez por capa, no en cada llamada.
- Captura tarde, en una frontera que pueda recuperarse, traducir o informar; registra ahí el
Throwablecompleto y en ningún otro sitio. - Las excepciones capturan un stack trace al construirse; para resultados esperados usa valores de retorno u
Optional.
Fuentes y lecturas recomendadas
Preguntas frecuentes
- ¿Debo usar excepciones checked o unchecked?
- Usa una excepción checked cuando quien llama pueda recuperarse de verdad y quieras que el compilador le obligue a decidir: un archivo que no existe y que el usuario puede volver a seleccionar, un timeout que merece un reintento. Usa una unchecked para los errores de programación y para los fallos que nada en el punto de llamada puede arreglar. La mayoría de bibliotecas y frameworks modernos se inclinan por las unchecked, porque las checked no encajan bien con las lambdas ni con el código genérico.
- ¿Qué pasa si tanto el bloque try como el bloque finally lanzan una excepción?
- La excepción de finally sustituye a la del try, que se pierde. Por eso un bloque finally no debe contener código que pueda lanzar excepciones. try-with-resources lo resuelve para los recursos: gana la excepción original y cualquier fallo de close() se le adjunta como excepción suprimida.
- ¿Está mal capturar Exception?
- En una frontera (un handler de peticiones, el método run de un thread, una tarea programada) capturar Exception para registrarla y convertirla en una respuesta es lo correcto, porque dejarla escapar mataría el thread o mostraría un stack trace al usuario. En lo profundo del código de negocio oculta bugs: captura los tipos concretos que sepas tratar y deja que el resto se propague.
- ¿Por qué no debo usar excepciones como control de flujo?
- Crear una excepción captura el stack trace, lo que cuesta mucho más que comprobar un booleano, y además hace el código más difícil de leer porque el camino normal queda escondido en un catch. Para las situaciones esperadas usa un valor de retorno, un Optional o una validación, y reserva las excepciones para lo que el método no pudo resolver.