Java7 min de leitura

Sealed Classes + Switch: o compilador como auditor do seu domínio

Sealed Classes + Switch: o compilador como auditor do seu domínio


Introdução

Quantas vezes você adicionou uma classe nova no sistema e esqueceu de atualizar um switch perdido em algum serviço?

No Java antigo, o default engolia esse caso novo sem avisar ninguém. O código compilava, os testes passavam, e o bug só aparecia em produção.

Com o Java 21, isso muda. Duas funcionalidades trabalhando juntas fazem o compilador apontar exatamente onde você esqueceu de tratar o caso novo:

  • Sealed Classes (JEP 409), finalizada no Java 17
  • Pattern Matching for Switch (JEP 441), finalizada no Java 21

O problema: o default que esconde bugs

Imagine um sistema de pagamentos no estilo antigo, com uma interface comum e uma cadeia de instanceof para decidir o que fazer:

String descrever(Pagamento p) {
    if (p instanceof Pix pix) {
        return "Pix para " + pix.chave();
    } else if (p instanceof Cartao c) {
        return "Cartão " + c.bandeira();
    } else {
        return "Pagamento desconhecido"; // o "default"
    }
}

Amanhã alguém cria a classe Cashback implements Pagamento. O que acontece?

Nada. O código compila. O Cashback cai no else e o cliente vê "Pagamento desconhecido" na tela. Ninguém é avisado.

O compilador não reclama porque ele não sabe quantas implementações de Pagamento existem. Qualquer classe, em qualquer lugar, pode implementar essa interface.


Sealed: uma hierarquia fechada

Uma interface sealed resolve exatamente isso. Ela diz ao compilador: "só estas classes podem me implementar, e mais nenhuma".

public sealed interface Pagamento permits Pix, Cartao, Boleto {}

public record Pix(String chave, BigDecimal valor) implements Pagamento {}

public record Cartao(String bandeira, int parcelas, BigDecimal valor) implements Pagamento {}

public record Boleto(LocalDate vencimento, BigDecimal valor) implements Pagamento {}

Agora a lista é conhecida em tempo de compilação: Pix, Cartao e Boleto. Se alguém tentar criar outra classe que implementa Pagamento sem estar no permits, o código não compila.

Records são final por padrão, por isso combinam tão bem com sealed. Cada implementação já nasce fechada.


Switch exaustivo, sem default

Como o compilador conhece todas as opções, ele consegue conferir se o seu switch tratou cada uma delas:

String descrever(Pagamento p) {
    return switch (p) {
        case Pix pix  -> "Pix para " + pix.chave();
        case Cartao c -> "Cartão %s em %dx".formatted(c.bandeira(), c.parcelas());
        case Boleto b -> "Boleto vence em " + b.vencimento();
    };
}

Repare: não tem default. E não precisa ter. O switch é exaustivo, ou seja, cobre 100% dos casos possíveis.

Se você esquecer um caso, o código nem compila:

String descrever(Pagamento p) {
    return switch (p) {
        case Pix pix  -> "Pix para " + pix.chave();
        case Cartao c -> "Cartão " + c.bandeira();
        // esqueceu o Boleto
    };
}
error: the switch expression does not cover all possible input values

O domínio evoluiu: entrou o Cashback

Aqui está o ganho real. O time de produto pediu um novo meio de pagamento. Você adiciona no permits:

public sealed interface Pagamento permits Pix, Cartao, Boleto, Cashback {}

public record Cashback(BigDecimal saldoUsado, BigDecimal valor) implements Pagamento {}

Ao compilar, todo switch que processa Pagamento quebra o build. O compilador te entrega a lista exata de lugares que precisam tratar o Cashback: o serviço de descrição, o cálculo de taxa, a conciliação, o relatório.

Nenhum deles fica para trás. Nenhum deles vai para produção sem tratamento.

flowchart LR A[Novo tipo no permits] --> B{Compilação} B -->|switch sem o caso novo| C[Build quebra] C --> D[Dev trata o caso] D --> B B -->|todos os switch cobertos| E[Deploy seguro]

Compare com o modelo antigo: lá, o erro aparecia em runtime, na mão do cliente. Aqui, aparece em tempo de compilação, na mão do desenvolvedor.


Cuidado: o default desliga a proteção

Esse é o erro mais comum de quem começa a usar sealed. Por hábito, a pessoa coloca um default no final:

// ✗ Evite
return switch (p) {
    case Pix pix  -> "Pix para " + pix.chave();
    case Cartao c -> "Cartão " + c.bandeira();
    default       -> "Outro pagamento";
};

Esse código compila. Mas quando o Cashback entrar, ele cai no default em silêncio, e você volta ao problema do início.

A regra é simples: com hierarquia sealed, não use default. Trate cada tipo de forma explícita e deixe o compilador trabalhar por você.


Condições extras com when

Às vezes o tipo sozinho não basta. Um cartão parcelado em mais de 12 vezes pode ter uma regra diferente. Para isso existe o when:

BigDecimal taxa(Pagamento p) {
    return switch (p) {
        case Pix pix                         -> BigDecimal.ZERO;
        case Cartao c when c.parcelas() > 12 -> new BigDecimal("0.05");
        case Cartao c                        -> new BigDecimal("0.02");
        case Boleto b                        -> new BigDecimal("2.50");
    };
}

Atenção à ordem: o caso mais específico (when) vem antes do mais geral. E repare que o case Cartao c sem condição continua obrigatório. Sem ele, o compilador reclama, porque um cartão com até 12 parcelas não estaria coberto.


Desmontando o record direto no case

Se você só precisa de alguns campos, dá para usar Record Patterns e extrair os valores direto no case:

String resumo(Pagamento p) {
    return switch (p) {
        case Pix(var chave, var valor)          -> "Pix de " + valor + " para " + chave;
        case Cartao(var bandeira, var x, var v) -> bandeira + " em " + x + "x";
        case Boleto(var vencimento, var valor)  -> "Boleto de " + valor + " até " + vencimento;
    };
}

A exaustividade continua valendo. Menos código, e o compilador ainda confere tudo.


E o null?

Um switch com pattern matching lança NullPointerException se receber null, como sempre fez. Se null for um valor esperado no seu fluxo, trate de forma explícita:

return switch (p) {
    case null     -> "Sem pagamento";
    case Pix pix  -> "Pix para " + pix.chave();
    case Cartao c -> "Cartão " + c.bandeira();
    case Boleto b -> "Boleto vence em " + b.vencimento();
};

Onde isso se encaixa: Programação Orientada a Dados

Esse estilo tem nome: Programação Orientada a Dados (DOP, do inglês Data-Oriented Programming). A ideia é separar bem duas coisas:

  • Os dados: records imutáveis, simples, sem regra de negócio dentro.
  • As operações: funções que recebem esses dados e decidem o que fazer com um switch.

A interface sealed define o formato possível dos dados. O switch exaustivo garante que toda operação trata todos os formatos. Juntos, eles transformam uma exceção esquecida em runtime em um erro de compilação.

Java antigo Java 21 com sealed + switch
Novo tipo no sistema Cai no default em silêncio Build quebra
Onde o erro aparece Em produção Na sua máquina
Quem avisa O cliente O compilador
Precisa de default? Sim Não

Conclusão

Sealed Classes sozinhas fecham a hierarquia. Pattern Matching sozinho deixa o switch mais legível. Mas é juntando os dois que o ganho aparece: o compilador passa a conhecer todos os casos do seu domínio e cobra que você trate cada um deles.

Na próxima vez que for escrever um default em um switch sobre um tipo sealed, pare e pergunte: eu quero mesmo que o compilador pare de me avisar?


Este artigo faz parte da série Sealed e Hidden Classes do getcaramelo.dev.

Tem dúvidas ou quer aprofundar algum ponto? Me encontra no LinkedIn ou no GitHub.

Gostou do conteúdo? Me segue no @getcaramelo.dev

← Voltar aos Artigos