Java 17/21: 3 motivos pra usar Sealed Classes no seu projeto
Introdução
Quarta de manha e alguém do time cria uma classe nova que implementa a sua interface de pagamento sem ninguém avisar. O switch do serviço de cobrança cai em um default e o bug aparece la em prod.
Foi pra acabar com esse tipo de surpresa que a JEP 409 trouxe as Sealed Classes no Java 17. À primeira vista parece só mais uma palavra-chave. Na prática, muda bastante coisa na forma de modelar o domínio.
Neste artigo vamos ver três motivos pra começar a usar:
- O design fica explícito
- O compilador vigia a hierarquia por você
- O
switchdeixa de precisar dedefault
Antes do sealed: o jeito que dava
Até o Java 16, você tinha basicamente duas opções pra controlar quem estende um tipo.
A primeira era o final, que fecha tudo. Ninguém estende, nem você.
A segunda era a gambiarra do construtor package-private:
public abstract class Pagamento {
// só quem está no mesmo pacote consegue estender
Pagamento() {}
}
Funciona, mais ou menos. Mas a regra fica escondida. Quem lê a classe não sabe quais são os filhos. Pra descobrir, precisa abrir o pacote e procurar. E com interface nem isso dá pra fazer, porque interface não tem construtor.
Outra saída comum era o enum. Só que enum não serve quando cada tipo carrega dados diferentes: um Pix tem chave, um cartão tem bandeira e parcelas, um boleto tem vencimento.
Faltava um meio-termo: "essa hierarquia é aberta, mas só pra esses tipos aqui".
Motivo 1: Design explícito
Com sealed e permits, você declara quem pode estender o seu tipo. Direto na assinatura:
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 {}
Bate o olho e você já sabe: pagamento, aqui, é Pix, Cartão ou Boleto. Nada além disso. O código vira documentação do domínio.
Cada filho de um tipo sealed precisa dizer como continua a hierarquia. São três opções:
| Modificador | O que significa |
|---|---|
final |
Ninguém estende esse filho |
sealed |
Esse filho também é fechado, com o seu próprio permits |
non-sealed |
Esse filho volta a ser aberto pra qualquer um |
Records já são final por padrão, por isso combinam tão bem com sealed. Mas dá pra misturar:
public sealed interface Pagamento permits Pix, Cartao, Boleto {}
public record Pix(String chave, BigDecimal valor) implements Pagamento {}
// Cartão tem subtipos, mas também controlados
public sealed interface Cartao extends Pagamento permits Credito, Debito {}
public record Credito(String bandeira, int parcelas, BigDecimal valor) implements Cartao {}
public record Debito(String bandeira, BigDecimal valor) implements Cartao {}
// Boleto fica aberto de propósito: cada banco tem a sua variação
public non-sealed class Boleto implements Pagamento { /* ... */ }
Repare que o non-sealed é uma decisão consciente. Se alguém abrir a hierarquia, isso fica escrito no código e aparece no code review.
Se todos os filhos estiverem no mesmo arquivo, dá pra omitir o
permits. O compilador descobre sozinho quem são.
Motivo 2: O compilador vigia por você
Com a hierarquia declarada, o compilador passa a cobrar a regra. Se alguém tentar criar um tipo novo sem estar no permits, o build quebra na hora:
public record Cashback(BigDecimal saldo, BigDecimal valor) implements Pagamento {}
error: class is not allowed to extend sealed class: Pagamento
(as it is not listed in its 'permits' clause)
Ninguém cria subclasse escondido. Pra entrar na hierarquia, tem que mexer na interface Pagamento, e essa mudança fica visível pra todo mundo no PR.
O compilador também confere os filhos. Se você esquecer de marcar um deles como final, sealed ou non-sealed:
public class Pix implements Pagamento {} // faltou o modificador
error: sealed, non-sealed or final modifiers expected
Tem mais uma regra que vale conhecer: os filhos permitidos precisam estar no mesmo módulo do tipo sealed. Se o projeto não usa módulos, precisam estar no mesmo pacote. Isso evita que uma biblioteca externa entre na sua hierarquia pela porta dos fundos.
E a informação não some depois de compilar. Ela fica disponível em runtime, via reflection:
Pagamento.class.isSealed(); // true
Pagamento.class.getPermittedSubclasses(); // [Pix, Cartao, Boleto]
Motivo 3: Switch sem default
Aqui é onde o ganho aparece de verdade. No Java 21, o switch com pattern matching (JEP 441) conhece todos os tipos de uma hierarquia sealed. Então ele consegue conferir se você tratou cada um:
BigDecimal taxa(Pagamento p) {
return switch (p) {
case Pix pix -> BigDecimal.ZERO;
case Cartao c -> new BigDecimal("0.02");
case Boleto b -> new BigDecimal("2.50");
};
}
Sem default. E não precisa de um, porque o switch já cobre todos os casos possíveis.
Agora volta pra sexta à tarde. O time decide adicionar o Cashback, do jeito certo:
public sealed interface Pagamento permits Pix, Cartao, Boleto, Cashback {}
Na próxima compilação, todo switch que processa Pagamento para de compilar:
error: the switch expression does not cover all possible input values
O compilador mostra exatamente onde falta tratar a regra: taxa, conciliação, relatório, notificação. Nenhum deles fica pra trás.
Compare com o modelo antigo:
// ✗ Com default, o Cashback passa em silêncio
return switch (p) {
case Pix pix -> BigDecimal.ZERO;
case Cartao c -> new BigDecimal("0.02");
default -> new BigDecimal("2.50"); // cashback cobrando taxa de boleto
};
Esse código compila. O Cashback cai no default e ninguém percebe até o cliente reclamar.
⚠️ Colocar
defaultnumswitchsobre tipo sealed desliga a proteção. Por hábito, muita gente coloca. Resista.
E se um JAR antigo, compilado antes do Cashback existir, receber um Cashback em runtime? O Java 21 lança MatchException em vez de seguir em frente com um valor errado. Falha alto, falha cedo.
Se quiser ir mais fundo nessa parte, com when, record patterns e tratamento de null, tem um artigo inteiro sobre isso: Sealed Classes + Switch: o compilador como auditor do seu domínio.
Quando não usar
Sealed não é pra tudo. Algumas situações em que ele atrapalha mais do que ajuda:
- APIs pensadas pra extensão. Se a ideia é que outros times ou bibliotecas criem as próprias implementações (um
Plugin, umHandler), deixe a interface aberta. - Hierarquias que mudam toda semana. Cada tipo novo exige mexer no
permitse em todos osswitch. Isso é ótimo quando a mudança é rara e importante, mas cansa se for constante. - Tipos sem dados diferentes entre si. Se as opções são só rótulos (
ATIVO,INATIVO,BLOQUEADO), umenumresolve e é mais simples.
A pergunta que ajuda a decidir: eu conheço hoje todos os tipos possíveis desse domínio? Se a resposta for sim, sealed é um bom candidato.
Resumo
| Sem sealed | Com sealed | |
|---|---|---|
| Quem pode estender | Qualquer um | Só quem está no permits |
| Onde a regra fica | Escondida no pacote | Na assinatura do tipo |
| Subclasse não autorizada | Compila normal | Build quebra |
Tipo novo no switch |
Cai no default |
Build quebra e mostra onde tratar |
| Quem avisa do bug | O cliente | O compilador |
Conclusão
Sealed Classes juntam o melhor de dois mundos: a flexibilidade da Orientação a Objetos, com tipos que carregam dados e comportamento próprios, e a segurança de um domínio bem modelado, em que o compilador conhece todas as opções.
O design fica explícito, o compilador vigia a hierarquia e o switch deixa de depender do default. E muito menos susto na sexta à tarde.
Este artigo faz parte da série Records, Records Patterns, 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
Caramelo