Java7 min de leitura

Java 17/21: 3 motivos pra usar Sealed Classes no seu projeto

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:

  1. O design fica explícito
  2. O compilador vigia a hierarquia por você
  3. O switch deixa de precisar de default

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 default num switch sobre 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, um Handler), deixe a interface aberta.
  • Hierarquias que mudam toda semana. Cada tipo novo exige mexer no permits e em todos os switch. 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), um enum resolve 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

← Voltar aos Artigos