Excepciones en Java: checked, unchecked y cómo diseñarlas
1. Descripción general
Una excepción es un mecanismo en Java para indicar que algo no sucedió como se esperaba. Muchos proyectos las usan de forma inconsistente. En algunos lugares hay throws Exception esparcido por todas partes. En otros, los errores se silencian en bloques catch vacíos.
En este artículo, entenderás la diferencia entre excepciones checked y unchecked. También sabrás cuándo usar cada tipo y cómo diseñar un sistema de excepciones propio para tu aplicación. Al final del artículo, hay una sección sobre cómo probar el comportamiento de lanzamiento de excepciones con JUnit 5 y AssertJ.
2. La jerarquía de Throwable
Todo lo que puede lanzarse con throw hereda de java.lang.Throwable. La jerarquía simplificada es la siguiente:
Throwable
├── Error (unchecked) – errores graves de la JVM
│ ├── OutOfMemoryError
│ └── StackOverflowError
└── Exception (checked)
├── IOException
├── SQLException
└── RuntimeException (unchecked)
├── IllegalArgumentException
├── IllegalStateException
└── NullPointerException
Las reglas de clasificación son muy simples:
- Excepciones checked son
Exceptiony sus subclases, excepto la rama deRuntimeException. El compilador te obliga a manejar este tipo. - Excepciones unchecked son
RuntimeException,Errory sus subclases. El compilador no te obliga a manejarlas.
Casi nunca deberías capturar Error. Cuando la JVM se queda sin memoria, la aplicación rara vez puede recuperarse por sí sola.
3. Excepciones checked
Una excepción checked es parte del “contrato” de un método. Cuando un método declara throws, quien lo llama debe elegir una de dos formas. La primera es capturar el error con try/catch. La segunda es declarar throws nuevamente en el nivel superior.
Considera una puerta de pago que puede rechazar una transacción. Esta situación es totalmente posible, y quien llama debe decidir qué hacer a continuación:
public class PaymentDeclinedException extends Exception {
private final String reason;
public PaymentDeclinedException(String reason) {
super("Pago rechazado: " + reason);
this.reason = reason;
}
public String getReason() {
return reason;
}
}
public class PaymentGateway {
public void charge(String cardNumber, long amount) throws PaymentDeclinedException {
if (amount > 10_000_000) {
throw new PaymentDeclinedException("excedió el límite");
}
System.out.println("Se dedujo " + amount + " de la tarjeta " + cardNumber);
}
}
PaymentDeclinedException hereda de Exception, por lo que es una excepción checked. Si llamas a charge(...) sin manejar el error, el compilador reportará:
error: unreported exception PaymentDeclinedException; must be caught or declared to be thrown
El llamador está obligado a pensar en el caso de fallo:
public class CheckoutService {
private final PaymentGateway gateway = new PaymentGateway();
public String checkout(String card, long amount) {
try {
gateway.charge(card, amount);
return "SUCCESS";
} catch (PaymentDeclinedException e) {
System.out.println("Razón: " + e.getReason());
return "DECLINED";
}
}
public static void main(String[] args) {
var service = new CheckoutService();
System.out.println(service.checkout("4111-xxxx", 50_000));
System.out.println(service.checkout("4111-xxxx", 20_000_000));
}
}
Salida:
Se dedujo 50000 de la tarjeta 4111-xxxx
SUCCESS
Razón: excedió el límite
DECLINED
Aquí el error se convierte en un estado empresarial (DECLINED). Este es el tipo de situación donde las excepciones checked funcionan mejor.
4. Excepciones unchecked
Las excepciones unchecked generalmente señalan errores de programación o violación de precondiciones. Por ejemplo: pasar null, pasar un número negativo o llamar a un método cuando el objeto está en un estado incorrecto.
public class Order {
private final long id;
private int quantity;
private boolean shipped;
public Order(long id, int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException("quantity debe ser > 0, se recibió: " + quantity);
}
this.id = id;
this.quantity = quantity;
}
public void changeQuantity(int newQuantity) {
if (shipped) {
throw new IllegalStateException("Pedido " + id + " ya fue entregado, no se puede cambiar la cantidad");
}
this.quantity = newQuantity;
}
public void ship() {
this.shipped = true;
}
}
El constructor lanza IllegalArgumentException cuando la entrada no es válida. El método changeQuantity lanza IllegalStateException cuando el pedido ya fue entregado. Nadie debería “capturar y manejar” estos errores en cada lugar que llama. La forma correcta es arreglar el código que los llama incorrectamente.
Debes reutilizar las excepciones predefinidas del JDK antes de crear nuevas clases:
| Escenario | Excepción a usar |
|---|---|
| Parámetro inválido | IllegalArgumentException |
| Objeto en estado incorrecto | IllegalStateException |
Parámetro obligatorio es null |
NullPointerException (mediante Objects.requireNonNull) |
| Operación no soportada | UnsupportedOperationException |
| Índice fuera de rango | IndexOutOfBoundsException |
5. ¿Elegir checked o unchecked?
Hay una regla práctica: pregúntate si el llamador puede hacer algo útil con este error.
- El llamador puede recuperarse de manera razonable, por ejemplo, reintentando, eligiendo una alternativa o notificando al usuario. En ese caso, una excepción checked es una opción aceptable.
- El error es por un bug o por fallo de la infraestructura que el llamador no puede hacer nada al respecto. En ese caso, usa una excepción unchecked.
En la práctica, los frameworks modernos como Spring e Hibernate casi exclusivamente usan excepciones unchecked. Por ejemplo, Spring envuelve SQLException como DataAccessException. La razón es que las excepciones checked tienen varias desventajas:
- Se propagan hacia arriba a través de todas las capas: un
throwsen la capa repository requierethrowsen la capa service y controller. - No son compatibles con lambda y Stream API (ver sección 8).
- Exponen detalles de implementación. La interfaz
OrderRepositoryno debería declararthrows SQLException.
Nota: Con aplicaciones Spring Boot, por defecto usa excepciones unchecked para errores empresariales. Manéjalas de forma centralizada usando
@RestControllerAdvice. Solo usa excepciones checked cuando realmente quieras forzar al llamador a manejarla de inmediato.
6. Diseña excepciones propias para tu aplicación
6.1. Crear una clase base
Debes crear una excepción base para cada dominio. Esto te permite capturar todos los errores del dominio con un único bloque catch cuando sea necesario:
public class OrderException extends RuntimeException {
public OrderException(String message) {
super(message);
}
public OrderException(String message, Throwable cause) {
super(message, cause);
}
}
Esta clase siempre debe tener un constructor que acepte cause. Así, el error original no se pierde cuando lo envuelves.
6.2. Subclases con contexto
Una buena excepción debe responder la pregunta “¿qué pasó y con qué datos?”. No solo escribas "Not found":
public class OrderNotFoundException extends OrderException {
private final long orderId;
public OrderNotFoundException(long orderId) {
super("No se encontró el pedido id=" + orderId);
this.orderId = orderId;
}
public long getOrderId() {
return orderId;
}
}
El campo orderId se preserva para que las capas superiores lo usen. Por ejemplo, un advice de controller puede leerlo para devolver JSON de error al cliente sin tener que analizar la cadena de mensaje.
6.3. Envuelve errores de capas inferiores (traducción de excepciones)
Cuando la capa service llama a infraestructura, debes traducir errores técnicos a errores del dominio y preservar la causa:
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class OrderExportService {
public void export(long orderId, Path target) {
try {
Files.writeString(target, "order=" + orderId);
} catch (IOException e) {
throw new OrderException("Exportar pedido " + orderId + " a " + target + " falló", e);
}
}
}
El segundo parámetro e es la causa. Al imprimir el stack trace, verás ambas capas:
OrderException: Exportar pedido 42 a /readonly/order.txt falló
at OrderExportService.export(OrderExportService.java:12)
Caused by: java.nio.file.AccessDeniedException: /readonly/order.txt
...
Si escribes throw new OrderException(e.getMessage()) sin pasar e, la parte Caused by desaparecerá. El debugging se vuelve muy difícil entonces.
7. Anti-patrones comunes
Silenciar excepciones. Este es el error más peligroso, porque la aplicación continúa como si nada hubiera sucedido:
try {
gateway.charge(card, amount);
} catch (PaymentDeclinedException e) {
// TODO
}
Si realmente quieres ignorar un error, documenta claramente el motivo en un comentario. Como mínimo, debes registrar ese error.
Log y lanza de nuevo. Escribir log.error(...) y luego throw e; hace que el mismo error aparezca múltiples veces en el log. Solo debes elegir una: manejar (y registrar) en el lugar, o lanzar a la capa superior para que lo maneje.
Capturar Exception o Throwable demasiado amplio. Al hacerlo, captura accidentalmente también NullPointerException y otros bugs. Captura solo el tipo que realmente puedes manejar. La única excepción razonable es en el perímetro exterior de la aplicación, como un manejador global.
Usar excepciones para control de flujo. Crear excepciones es costoso porque requiere recopilar el stack trace. Este código también es difícil de leer:
// No deberías
try {
return Integer.parseInt(input);
} catch (NumberFormatException e) {
return 0;
}
Para datos que esperas que a menudo sean inválidos, verifica primero. También puedes devolver Optional. Las excepciones deben reservarse para situaciones excepcionales.
Declarar throws Exception. Esto borra toda la información sobre qué tipos de errores pueden ocurrir. Además obliga a todos los llamadores a capturar un tipo muy amplio.
8. Excepciones checked en lambdas
La interfaz Function no declara throws, por lo que el siguiente código no compilará:
List<String> contents = paths.stream()
.map(Files::readString) // error: unreported exception IOException
.toList();
La forma común de manejar esto es envolver el error como UncheckedIOException. Esta clase está disponible en el JDK precisamente para este propósito:
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
public class OrderFileReader {
public List<String> readAll(List<Path> paths) {
return paths.stream()
.map(OrderFileReader::readQuietly)
.toList();
}
private static String readQuietly(Path path) {
try {
return Files.readString(path);
} catch (IOException e) {
throw new UncheckedIOException("No se pudo leer " + path, e);
}
}
}
El método readQuietly aísla el bloque try/catch fuera del pipeline, manteniendo el stream limpio. El llamador de readAll puede capturar UncheckedIOException y obtener el IOException original mediante getCause().
9. try-with-resources y excepciones suprimidas
Cuando trabajes con recursos que necesitan cerrarse, usa try-with-resources. Hay un punto que pocos notan: si tanto el cuerpo del try como close() lanzan errores, el error en close() no sobrescribe el error principal. Se adjunta al error principal como excepción suprimida:
public class SuppressedDemo {
static class ReportConnection implements AutoCloseable {
void query() {
throw new IllegalStateException("Query falló");
}
@Override
public void close() {
throw new IllegalStateException("Close falló");
}
}
public static void main(String[] args) {
try (var connection = new ReportConnection()) {
connection.query();
} catch (IllegalStateException e) {
System.out.println("Principal: " + e.getMessage());
for (Throwable s : e.getSuppressed()) {
System.out.println("Suprimida: " + s.getMessage());
}
}
}
}
Salida:
Principal: Query falló
Suprimida: Close falló
Si escribes tu propio try/finally y llamas a close() en el finally, el error “Close falló” sobrescribirá a “Query falló”. Entonces pierdes la causa real.
10. Prueba el comportamiento de lanzamiento de excepciones
Si usas Spring Boot, spring-boot-starter-test ya incluye JUnit 5 y AssertJ. Para un proyecto Java puro, añade las siguientes dependencias:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
Debes verificar el tipo de excepción, el mensaje, los datos de contexto y la causa:
import org.junit.jupiter.api.Test;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.*;
class OrderExceptionTest {
@Test
void whenQuantityIsZero_thenThrowIllegalArgument() {
assertThatThrownBy(() -> new Order(1L, 0))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("quantity debe ser > 0");
}
@Test
void whenOrderShipped_thenCannotChangeQuantity() {
var order = new Order(7L, 2);
order.ship();
assertThatIllegalStateException()
.isThrownBy(() -> order.changeQuantity(5))
.withMessage("Pedido 7 ya fue entregado, no se puede cambiar la cantidad");
}
@Test
void notFoundException_carriesOrderId() {
assertThatThrownBy(() -> { throw new OrderNotFoundException(42L); })
.isInstanceOf(OrderException.class)
.hasFieldOrPropertyWithValue("orderId", 42L);
}
@Test
void whenPaymentOverLimit_thenThrowCheckedException() {
var gateway = new PaymentGateway();
assertThatExceptionOfType(PaymentDeclinedException.class)
.isThrownBy(() -> gateway.charge("4111-xxxx", 20_000_000))
.satisfies(e -> assertThat(e.getReason()).isEqualTo("excedió el límite"));
}
@Test
void whenExportFails_thenCauseIsPreserved() {
var service = new OrderExportService();
Path invalid = Path.of("/thu-muc-khong-ton-tai/order.txt");
assertThatThrownBy(() -> service.export(42L, invalid))
.isInstanceOf(OrderException.class)
.hasMessageContaining("Exportar pedido 42")
.hasCauseInstanceOf(java.io.IOException.class);
}
}
Algunos puntos a tener en cuenta:
assertThatThrownByacepta una lambda que puede lanzar cualquierThrowable, incluidas excepciones checked. Por lo tanto, no necesitastry/catchen la prueba.hasFieldOrPropertyWithValuelee el gettergetOrderId(). Esto te asegura que el contexto se pasó correctamente.hasCauseInstanceOfafirma que la causa no se pierde al envolver el error. Es fácil olvidarse de la causa si falta esta prueba. En la prueba, el directorio padre no existe, por lo queFiles.writeStringlanzaNoSuchFileException, una subclase deIOException.
11. Conclusión
En este artículo, has visto que las excepciones checked son para situaciones donde el llamador puede recuperarse. Las excepciones unchecked son para errores de programación, violaciones de precondiciones o fallos de infraestructura que el llamador no puede manejar. Para aplicaciones modernas, especialmente Spring Boot, las excepciones unchecked son la opción predeterminada razonable.
Al diseñar excepciones propias, sigue estos principios:
- Crea una clase base para cada dominio.
- Incluye datos de contexto en las subclases.
- Siempre preserva
causeal traducir errores entre capas.
Finalmente, debes evitar anti-patrones como silenciar errores, registrar y lanzar de nuevo, o capturar Exception demasiado ampliamente. Al mismo tiempo, escribe pruebas para asegurar el comportamiento de lanzamiento de errores de tu código.