Todos os artigos

// Knowledge.log — 技術記事

Sealed interfaces em vez de boolean-flag soup: modelando estado em vez de configurá-lo

Flags booleanas aceitam combinações impossíveis. Troque por sealed interface com records e switch exaustivo: o javac barra estados inválidos no build.

Um Payment com três booleans — pending, cancelled, refunded — parece inofensivo até alguém instanciar new PaymentFlags(true, true, true). Isso compila, roda, e devolve um rótulo. O tipo nunca disse que essa combinação era proibida; ele só tinha três bits e nenhuma regra amarrando os três.

final class PaymentFlags {
    boolean pending, cancelled, refunded;
    PaymentFlags(boolean pending, boolean cancelled, boolean refunded) {
        this.pending = pending; this.cancelled = cancelled; this.refunded = refunded;
    }
    String label() {
        if (pending) return "pending";
        if (cancelled) return "cancelled";
        if (refunded) return "refunded";
        return "unknown";
    }
    public static void main(String[] args) {
        System.out.println(new PaymentFlags(true, true, true).label());
    }
}
javac --release 25 -d out PaymentFlags.java
java -cp out PaymentFlags

Saída: pending. O primeiro if resolve o caso mais raro e esconde os outros dois. Funciona até funcionar.

Três booleans independentes representam 2^3 = 8 combinações possíveis. O domínio de pagamento tem quatro estados de verdade — pendente, pago, cancelado, reembolsado — não oito. As quatro combinações sobrando (pending+cancelled+refunded entre elas) não deveriam nem compilar. O resultado deste artigo é fazer exatamente isso: transformar "estado impossível" em erro de javac, não em bug relatado por um cliente.

Pré-requisitos

Os exemplos rodam em Eclipse Temurin 25.0.4+7 LTS, compilados com javac --release 25, sem --enable-preview. Nenhuma das features usadas é nova no JDK 25: sealed classes são finais desde o JDK 17 (JEP 409), e pattern matching para switch junto com record patterns são finais desde o JDK 21 (JEP 441, JEP 440). O JDK 25 entra aqui só por ser a LTS atual do host, não porque o artigo dependa de algo introduzido nela.

Modelando os estados válidos

Em vez de três booleans soltos, uma sealed interface que lista exatamente quem pode implementá-la, e um record por estado:

sealed interface PaymentState permits Pending, Paid, Cancelled, Refunded {}
record Pending() implements PaymentState {}
record Paid(long amountCents) implements PaymentState {
    Paid {
        if (amountCents < 0) throw new IllegalArgumentException("negative");
    }
}
record Cancelled(String reason) implements PaymentState {}
record Refunded(long amountCents) implements PaymentState {}

permits fecha a lista de implementadores; cada um precisa ser final, sealed ou non-sealed, e records já são final por definição. O JEP 409 chama essa combinação de tipo algébrico: sealed é a soma (um dos quatro), record é o produto (os campos de cada um). Paid carrega amountCents, Cancelled carrega reason, e não existe — porque o tipo não permite — um valor que seja Pending e Cancelled ao mesmo tempo.

Verificado neste host: PaymentState.class.isSealed() retorna true, e PaymentState.class.getPermittedSubclasses().length retorna 4 (API de Class). Rodando javap -verbose PaymentState aparece um atributo PermittedSubclasses listando as quatro classes — não existe uma flag ACC_SEALED separada; o selamento é esse atributo.

Tentar estender por fora falha na compilação:

record Extra() implements PaymentState {}
Unauthorized.java:1: error: class is not allowed to extend sealed class: PaymentState (as it is not listed in its 'permits' clause)

Migração incremental sem quebrar quem lê os booleans antigos

Ninguém reescreve todo caller de isPending() num sprint só. O caminho que compila hoje é guardar um único PaymentState e derivar os booleans antigos dele — nunca os dois lado a lado:

final class Payment {
    private final PaymentState state;
    Payment(PaymentState state) { this.state = state; }
    PaymentState state() { return state; }
    boolean isPending() { return state instanceof Pending; }
    boolean isCancelled() { return state instanceof Cancelled; }
    boolean isRefunded() { return state instanceof Refunded; }
    boolean isPaid() { return state instanceof Paid; }
    String legacyLabel() {
        if (isPending()) return "pending";
        if (isCancelled()) return "cancelled";
        if (isRefunded()) return "refunded";
        if (isPaid()) return "paid";
        return "unknown";
    }
}

isPending() e isCancelled() nunca retornam true ao mesmo tempo, porque os dois consultam o mesmo campo state. Não há como desincronizar o que não existe em duplicata. Guardar os três booleans e o campo PaymentState reintroduz o problema original — os dois podem discordar. O código novo passa a fazer switch em state(); o código legado que ainda chama isPending() continua compilando sem saber que mudou nada por baixo.

Quando o boolean simples ainda é a escolha certa

Sealed modela um conjunto fechado de tipos; enum modela um conjunto fechado de instâncias; boolean modela um único fato de sim/não. Um flag como enabled, verbose ou uma soft-delete solitária não têm exclusividade mútua com nada — as combinações são todas legítimas. Selar um tipo para representar um bit isolado é cerimônia sem ganho de exaustividade sobre um if (enabled) comum.

O sinal de que vale migrar é o oposto: dois ou mais booleans que descrevem estágios do mesmo ciclo de vida, onde pelo menos uma combinação é proibida pelo domínio, não pelo tipo. Se todas as 2^n combinações forem válidas, fica boolean (ou um record de booleans, se quiser agrupar). E não declare um implementador non-sealed só para "deixar aberto depois" — isso reabre o branch e o switch volta a tratar aquele ramo como catch-all, escondendo subclasses desconhecidas exatamente onde você queria exaustividade.

Verificação: o switch exaustivo e o erro que você quer ver

Com PaymentState selado, um switch que cobre os quatro permits não precisa de default:

final class Labels {
    static String label(PaymentState s) {
        return switch (s) {
            case Pending() -> "pending";
            case Paid(long amount) -> "paid:" + amount;
            case Cancelled(String reason) -> "cancelled:" + reason;
            case Refunded(long amount) -> "refunded:" + amount;
        };
    }
}

Removendo o case Refunded de propósito:

SealedMissing.java:3: error: the switch expression does not cover all possible input values
        return switch (s) {
               ^

Esse é o ganho: esquecer um estado vira erro de build, não comportamento silencioso em produção. E aqui mora a armadilha mais fácil de cometer sem perceber — adicionar default -> "other" junto com o case Refunded ausente faz o mesmo código compilar de novo. O default parece gentileza; na prática é o compilador prometendo cobrir um buraco que você nunca tapou. Se o objetivo é exaustividade, não escreva default sobre um seletor selado.

Duas verificações a mais, já cobertas pelo mesmo modelo:

  • label(null) lança NullPointerException — um switch de pattern matching sem case null não aceita null implicitamente.
  • new Paid(-1) lança IllegalArgumentException, vinda do construtor compacto do record — isso valida o valor de um estado específico, não a exaustividade entre estados; são preocupações diferentes e cada uma mora no lugar certo.

Rodando o harness completo:

javac --release 25 -d out PaymentState.java Payment.java Labels.java
java -cp out Labels
true
4
paid:100
1
NEG:negative
legacy:refunded
both:false
NPE

Cada linha confirma um fato: isSealed() é true, quatro subtipos permitidos, o switch decompõe Paid(100) em paid:100, o acessor amountCents() devolve 1, o construtor compacto rejeita -1, o adaptador legado (Payment) devolve legacy:refunded, isPending() && isCancelled() é false, e label(null) lança a exceção em vez de devolver um rótulo qualquer.

Observação em produção

A correção executável já está acima: o par PaymentState + Payment como única fonte de verdade, com o switch exaustivo barrando estado novo não tratado. Para observabilidade, troque logs e métricas que hoje registram três booleans separados por um único campo tirado do tipo:

log.info("payment_state={}", payment.state().getClass().getSimpleName());

Isso garante que o dashboard sempre recebe um valor de um conjunto fechado e conhecido (Pending, Paid, Cancelled ou Refunded), nunca uma combinação de bits que ninguém previu ao montar o alerta.

Recomendação

A DevDojo adota sealed interface + records assim que um tipo de domínio junta dois ou mais booleans descrevendo o mesmo ciclo de vida — pagamento, pedido, sessão, o que for — e existe pelo menos uma combinação que o negócio proíbe. A migração incremental (guardar PaymentState, derivar os booleans antigos) evita reescrever callers de uma vez.

Recua quando os flags são genuinamente independentes — todas as 2^n combinações fazem sentido — ou quando o conjunto de estados muda com frequência e cada novo permits obriga a tocar todo switch exaustivo do código-base; nesse caso a rigidez que ajuda hoje vira atrito amanhã, e um enum ou um record de flags é mais barato para manter.

Próximo passo: pegue o tipo do seu projeto com mais de dois booleans relacionados, escreva no papel os estados que o negócio realmente permite, e comece pelo sealed interface — o adaptador de leitura para o código legado pode esperar até o primeiro switch quebrar em build.

javajdk-25

// Continue.training — 次のステップ

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos