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 Exception y sus subclases, excepto la rama de RuntimeException. El compilador te obliga a manejar este tipo.
  • Excepciones unchecked son RuntimeException, Error y 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:

  1. Se propagan hacia arriba a través de todas las capas: un throws en la capa repository requiere throws en la capa service y controller.
  2. No son compatibles con lambda y Stream API (ver sección 8).
  3. Exponen detalles de implementación. La interfaz OrderRepository no debería declarar throws 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:

  • assertThatThrownBy acepta una lambda que puede lanzar cualquier Throwable, incluidas excepciones checked. Por lo tanto, no necesitas try/catch en la prueba.
  • hasFieldOrPropertyWithValue lee el getter getOrderId(). Esto te asegura que el contexto se pasó correctamente.
  • hasCauseInstanceOf afirma 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 que Files.writeString lanza NoSuchFileException, una subclase de IOException.

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 cause al 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.

javaexceptionclean-codejunit5