Exceção em Java: checked, unchecked e como projetar

1. Visão geral

Exceção é um mecanismo do Java para informar que algo não ocorreu conforme esperado. Muitos projetos usam exceções de forma inconsistente. Há lugares onde throws Exception está espalhado por toda parte. Há lugares onde erros são silenciados em blocos catch vazios.

Neste artigo, você entenderá a diferença entre exceções checked e unchecked. Você também aprenderá quando usar cada tipo e como projetar um sistema de exceções customizado para sua aplicação. No final do artigo, há uma seção sobre como testar o comportamento de lançamento de exceções com JUnit 5 e AssertJ.

2. A hierarquia de Throwable

Tudo que pode ser lançado com throw herda de java.lang.Throwable. A hierarquia simplificada é a seguinte:

Throwable
├── Error                      (unchecked) – erros graves da JVM
│   ├── OutOfMemoryError
│   └── StackOverflowError
└── Exception                  (checked)
    ├── IOException
    ├── SQLException
    └── RuntimeException       (unchecked)
        ├── IllegalArgumentException
        ├── IllegalStateException
        └── NullPointerException

A regra de classificação é muito simples:

  • Exceção checked é Exception e suas subclasses, exceto a branch RuntimeException. O compilador força você a tratar esse tipo de exceção.
  • Exceção unchecked é RuntimeException, Error e suas subclasses. O compilador não força você a tratá-las.

Você quase nunca deve capturar Error. Quando a JVM fica sem memória, a aplicação raramente consegue se recuperar.

3. Exceção checked

Uma exceção checked é parte do “contrato” de um método. Quando um método declara throws, quem o chama deve escolher uma de duas formas. A primeira é capturar o erro com try/catch. A segunda é declarar throws e passar a exceção adiante.

Considere um gateway de pagamento que pode recusar uma transação. Essa situação é totalmente possível de acontecer, e quem chama o método deve decidir como proceder:

public class PaymentDeclinedException extends Exception {
    private final String reason;

    public PaymentDeclinedException(String reason) {
        super("Pagamento recusado: " + 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("limite excedido");
        }
        System.out.println("Descontado " + amount + " do cartão " + cardNumber);
    }
}

PaymentDeclinedException herda de Exception, portanto é uma exceção checked. Se você chamar charge(...) sem tratar a exceção, o compilador reportará:

error: unreported exception PaymentDeclinedException; must be caught or declared to be thrown

Quem chama é forçado a pensar no cenário de falha:

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("Motivo: " + 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));
    }
}

Saída:

Descontado 50000 do cartão 4111-xxxx
SUCCESS
Motivo: limite excedido
DECLINED

Aqui o erro é convertido em um estado de negócio (DECLINED). Esse é o tipo de situação onde exceções checked funcionam melhor.

4. Exceção unchecked

Exceções unchecked geralmente indicam erros de programação ou violação de pré-condições. Exemplos incluem passar null, passar um número negativo ou chamar um método quando o objeto está em estado inválido.

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 deve ser > 0, recebido: " + quantity);
        }
        this.id = id;
        this.quantity = quantity;
    }

    public void changeQuantity(int newQuantity) {
        if (shipped) {
            throw new IllegalStateException("Pedido " + id + " já foi entregue, não é possível alterar a quantidade");
        }
        this.quantity = newQuantity;
    }

    public void ship() {
        this.shipped = true;
    }
}

O construtor lança IllegalArgumentException quando a entrada é inválida. O método changeQuantity lança IllegalStateException quando o pedido já foi entregue. Ninguém deve “capturar e tratar” esses erros em cada local de chamada. O jeito correto é corrigir o código que faz a chamada.

Você deve reutilizar as exceções built-in do JDK antes de criar suas próprias:

Cenário Exceção a usar
Parâmetro inválido IllegalArgumentException
Objeto em estado inválido IllegalStateException
Parâmetro obrigatório é null NullPointerException (via Objects.requireNonNull)
Operação não suportada UnsupportedOperationException
Índice fora dos limites IndexOutOfBoundsException

5. Escolher checked ou unchecked?

Existe um princípio prático: pergunte-se se quem chama o método pode fazer algo útil com esse erro.

  • Quem chama pode se recuperar de forma razoável, por exemplo tentando novamente, escolhendo uma alternativa ou informando o usuário. Nesse caso, uma exceção checked é uma escolha aceitável.
  • Erros causados por bugs ou infraestrutura quebrada que quem chama não pode fazer nada a respeito. Nesse caso, use uma exceção unchecked.

Na prática, frameworks modernos como Spring e Hibernate usam principalmente exceções unchecked. Por exemplo, Spring encapsula SQLException como DataAccessException. O motivo é que exceções checked têm algumas desvantagens:

  1. Elas se propagam para cima em todas as camadas: um throws na camada repository força throws na service e no controller.
  2. Elas não são compatíveis com lambdas e Stream API (veja a seção 8).
  3. Elas expõem detalhes de implementação. Uma interface OrderRepository não deve declarar throws SQLException.

Nota: Com aplicações Spring Boot, use exceções unchecked por padrão para erros de negócio. Depois trate-os centralizadamente com @RestControllerAdvice. Use exceções checked apenas quando você realmente quer forçar quem chama a tratar o erro imediatamente.

6. Projetando exceções customizadas para sua aplicação

6.1. Criar uma classe base

Você deve criar uma exceção base para cada domain. Isso permite capturar todos os erros do domain com um único bloco catch quando necessário:

public class OrderException extends RuntimeException {
    public OrderException(String message) {
        super(message);
    }

    public OrderException(String message, Throwable cause) {
        super(message, cause);
    }
}

Essa classe sempre deve ter um construtor que aceite cause. Assim, o erro original não é perdido quando você o encapsula.

6.2. Subclasses com contexto

Uma exceção bem projetada deve responder a pergunta “o que aconteceu, com quais dados?”. Não apenas escreva "Not found":

public class OrderNotFoundException extends OrderException {
    private final long orderId;

    public OrderNotFoundException(long orderId) {
        super("Pedido não encontrado, id=" + orderId);
        this.orderId = orderId;
    }

    public long getOrderId() {
        return orderId;
    }
}

O campo orderId é armazenado para que as camadas acima possam usá-lo. Por exemplo, um controller advice pode ler esse valor para retornar JSON de erro ao cliente sem precisar analisar a string de mensagem.

6.3. Encapsular erros de camada inferior (exception translation)

Quando a camada service chama a infraestrutura, você deve traduzir erros técnicos para erros do domain e manter o cause:

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("Falha ao exportar pedido " + orderId + " para " + target, e);
        }
    }
}

O segundo parâmetro e é o cause. Quando imprimir a stack trace, você verá ambas as camadas:

OrderException: Falha ao exportar pedido 42 para /readonly/order.txt
    at OrderExportService.export(OrderExportService.java:12)
Caused by: java.nio.file.AccessDeniedException: /readonly/order.txt
    ...

Se você escrever throw new OrderException(e.getMessage()) sem passar e, a seção Caused by desaparecerá. Debug se torna muito difícil nesse caso.

7. Anti-patterns comuns

Silenciar exceções. Esse é o erro mais perigoso, pois a aplicação continua como se nada tivesse acontecido:

try {
    gateway.charge(card, amount);
} catch (PaymentDeclinedException e) {
    // TODO
}

Se você realmente quer ignorar um erro, deixe claro o motivo em um comentário. No mínimo, você deve registrar (log) esse erro.

Fazer log e relançar. Escrever log.error(...) seguido de throw e; faz o mesmo erro aparecer múltiplas vezes no log. Você deve escolher apenas um: trate (e faça log) no local, ou relance para a camada acima tratar.

Capturar Exception ou Throwable muito amplo. Fazer isso acaba capturando NullPointerException e outros bugs acidentalmente. Capture apenas o tipo que você pode tratar. A única exceção razoável é na borda mais externa da aplicação, por exemplo um global handler.

Usar exceção para controlar fluxo. Criar exceções é caro porque deve-se capturar a stack trace. Código assim também é difícil de ler:

// Evite
try {
    return Integer.parseInt(input);
} catch (NumberFormatException e) {
    return 0;
}

Para dados que você espera que sejam frequentemente inválidos, faça a validação antes. Você também pode retornar Optional. Exceções devem ser reservadas para situações excepcionais.

Declarar throws Exception. Essa abordagem remove todas as informações sobre que tipo de erro pode acontecer. Também força todos os chamadores a capturar um tipo muito amplo.

8. Exceções checked em lambdas

A interface Function não declara throws, então o código a seguir não compilará:

List<String> contents = paths.stream()
        .map(Files::readString) // erro: exceção não reportada IOException
        .toList();

A forma comum de tratar isso é encapsular o erro como UncheckedIOException. Essa classe já vem no JDK para exatamente esse 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("Falha ao ler " + path, e);
        }
    }
}

O método readQuietly isola o bloco try/catch do pipeline, mantendo a stream limpa. Quem chama readAll pode capturar UncheckedIOException e obter o IOException original via getCause().

9. try-with-resources e exceções suprimidas

Quando trabalhar com recursos que precisam ser fechados, use try-with-resources. Há um ponto que poucas pessoas notam: se tanto o corpo do try quanto close() lançarem exceção, o erro de close() não sobrescreve o erro principal. Ele é anexado ao erro principal como uma exceção suprimida:

public class SuppressedDemo {

    static class ReportConnection implements AutoCloseable {
        void query() {
            throw new IllegalStateException("Erro na query");
        }

        @Override
        public void close() {
            throw new IllegalStateException("Erro no close");
        }
    }

    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());
            }
        }
    }
}

Saída:

Principal: Erro na query
Suprimida: Erro no close

Se você escrever manualmente try/finally e chamar close() no finally, o erro “Erro no close” sobrescreveria “Erro na query”. Nesse caso você perde a causa real.

10. Testando o comportamento de lançamento de exceção

Se usar Spring Boot, spring-boot-starter-test já vem com JUnit 5 e AssertJ. Para um projeto Java puro, adicione as seguintes dependências:

<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>

Você deve testar o tipo de exceção, mensagem, dados de contexto e cause:

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 deve ser > 0");
    }

    @Test
    void whenOrderShipped_thenCannotChangeQuantity() {
        var order = new Order(7L, 2);
        order.ship();

        assertThatIllegalStateException()
                .isThrownBy(() -> order.changeQuantity(5))
                .withMessage("Pedido 7 já foi entregue, não é possível alterar a quantidade");
    }

    @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("limite excedido"));
    }

    @Test
    void whenExportFails_thenCauseIsPreserved() {
        var service = new OrderExportService();
        Path invalid = Path.of("/diretorio-inexistente/order.txt");

        assertThatThrownBy(() -> service.export(42L, invalid))
                .isInstanceOf(OrderException.class)
                .hasMessageContaining("Exportar pedido 42")
                .hasCauseInstanceOf(java.io.IOException.class);
    }
}

Alguns pontos a notar:

  • assertThatThrownBy aceita uma lambda que pode lançar qualquer Throwable, inclusive exceções checked. Portanto, você não precisa de try/catch no teste.
  • hasFieldOrPropertyWithValue lê o getter getOrderId(). Isso garante que o contexto seja passado corretamente.
  • hasCauseInstanceOf afirma que o cause não é perdido ao encapsular o erro. Erros que esquecem o cause passam facilmente despercebidos sem esse teste. No teste, o diretório pai não existe, então Files.writeString lança NoSuchFileException, uma subclasse de IOException.

11. Conclusão

Neste artigo, você viu que exceções checked são para situações onde quem chama pode se recuperar. Exceções unchecked são para erros de programação, violação de pré-condições ou erros de infraestrutura que quem chama não pode resolver. Com aplicações modernas, especialmente Spring Boot, exceções unchecked são a escolha padrão mais sensata.

Ao projetar exceções customizadas, siga estes princípios:

  • Crie uma classe base para cada domain.
  • Coloque dados de contexto nas subclasses.
  • Sempre mantenha cause ao traduzir erros entre camadas.

Por fim, você deve evitar anti-patterns como silenciar erros, fazer log e relançar, ou capturar Exception muito amplamente. Além disso, escreva testes para garantir o comportamento de lançamento de exceção do seu código.

javaexceptionclean-codejunit5