Capa do artigo: Java multiparadigma: records, sealed classes e pattern matching

Java multiparadigma: records, sealed classes e pattern matching

A linguagem que decidiu mudar de ideia

Em 1995, James Gosling e o time da Sun Microsystems lançaram Java com uma promessa clara de simplicidade orientada a objetos para um mundo conectado. A linguagem nascia como resposta ao C++, cuja complexidade multiparadigma era vista como fonte de erros e custos de manutenção. Java escolheu um caminho diferente. Tudo seria um objeto. Todo comportamento viveria dentro de uma classe. Toda abstração seria construída sobre herança, encapsulamento e polimorfismo. A aposta era que um paradigma único, aplicado com disciplina, produziria software mais seguro e previsível do que a liberdade irrestrita de misturar estilos.

Essa aposta funcionou por quase duas décadas. Java dominou o mercado corporativo, formou gerações de engenheiros e sustentou sistemas bancários, governamentais e de telecomunicações ao redor do mundo. Mas o mundo mudou. A computação distribuída, o processamento massivo de dados, a ascensão de arquiteturas orientadas a eventos e a proliferação de APIs como contratos entre sistemas criaram um ambiente onde a pureza orientada a objetos começou a cobrar um preço alto em verbosidade, rigidez e distância crescente entre o que o engenheiro queria expressar e o que a linguagem permitia.

Thomas Kuhn, em "A Estrutura das Revoluções Científicas" (1962), descreveu como paradigmas dominantes não desaparecem de uma vez. Eles acumulam anomalias, evidências pontuais de que o modelo vigente já não cobre toda a realidade, até a tensão levar à emergência de um novo paradigma que reorganiza o campo sob novas premissas. A trajetória do Java encaixa-se nessa dinâmica. As tensões descritas acima acumularam-se por quase duas décadas, até o Java 8 (2014) marcar a primeira ruptura deliberada. De lá em diante, cada ciclo de releases ampliou o que a sintaxe e a semântica oficiais permitem expressar, sem abandonar o que já funcionava.

Linguagens como Scala, Clojure e Kotlin já ofereciam imutabilidade, funções de primeira classe e tipos algébricos sobre a mesma plataforma. Com o Java 8, a própria linguagem começou a se aproximar dessas vizinhas, incorporando ao JLS construtos que antes só existiam fora dela. Lambdas e Streams vieram primeiro. Depois, switch expressions (Java 14), records (Java 16), sealed classes (Java 17) e pattern matching com record patterns (Java 21) completaram o caminho até o design orientado a dados. O resultado é uma linguagem que hoje permite escolher entre organizar o sistema em torno de identidade e comportamento encapsulado ou em torno da estrutura explícita dos dados, conforme o problema pedir.

Na prática, boa parte da indústria aplica OO mais como convenção sintática do que como modelo conceitual. A formação típica ensina a sintaxe (classes, herança, interfaces) e os padrões clássicos, mas dedica pouco tempo ao raciocínio que fundamenta o paradigma. Quando encapsular estado, como proteger invariantes ao longo do ciclo de vida de um objeto e em que situações a identidade importa mais que a estrutura dos dados são perguntas que raramente aparecem com a ênfase que merecem. O resultado é que muitos sistemas escritos em Java são, na essência, programação estruturada vestida de classes. Dados expostos em atributos públicos ou getters indiscriminados, lógica concentrada em serviços procedurais, invariantes que dependem de disciplina em vez de design. OO como rótulo, não como ferramenta de modelagem. Reconhecer essa lacuna importa porque a chegada de novos paradigmas na linguagem não a resolve automaticamente. Sem clareza sobre o que OO faz bem (proteger estado, garantir consistência, expressar identidade), fica difícil saber quando trocá-lo por orientação a dados. A escolha entre paradigmas depende da natureza do problema, das fronteiras arquiteturais do sistema e da maturidade do time, não mais das limitações que a linguagem impunha.

Java 8: a confissão funcional

Cada feature do Java 8 respondia a uma pressão real do ecossistema. Lambdas permitiam expressar comportamento efêmero sem a cerimônia de classes anônimas. Streams ofereciam um modelo declarativo de processamento que abria caminho para paralelismo transparente. Optional explicitava a ausência de valor como parte do contrato de tipos. Juntas, essas adições ampliavam o repertório da linguagem. Problemas que antes exigiam cerimônia e indireção ganhavam expressão direta, e o programador passava a escolher entre mais de um caminho para a mesma solução.

Antes do Java 8, processar uma coleção de dados exigia um ritual cerimonioso. Era preciso criar uma classe anônima, implementar uma interface, instanciar iteradores e acumular resultados em variáveis mutáveis. O código dedicava mais linhas ao mecanismo de iteração do que à lógica que o engenheiro realmente queria expressar, e essa desproporção se acumulava em cada método, em cada classe, ao longo de todo o projeto. Compare:

// Java 7: processamento imperativo
List<String> nomes = new ArrayList<>();
for (Pedido pedido : pedidos) {
    if (pedido.getValor() > 1000) {
        nomes.add(pedido.getCliente().getNome());
    }
}
Collections.sort(nomes);
// Java 8: processamento declarativo
List<String> nomes = pedidos.stream()
    .filter(p -> p.getValor() > 1000)
    .map(p -> p.getCliente().getNome())
    .sorted()
    .collect(Collectors.toList());

A diferença entre esses dois trechos vai além da aparência. No primeiro caso, o engenheiro assume a responsabilidade de gerenciar estado mutável, controlar a iteração e garantir a ordenação, tudo isso entrelaçado com a lógica de filtragem e extração que é o objetivo real do código. No segundo, ele descreve o que quer obter, e o runtime decide como executar, inclusive com a possibilidade de paralelismo transparente via parallelStream(). Essa transição do imperativo para o declarativo reflete uma mudança mais ampla na forma de pensar sobre processamento. Em vez de ditar cada passo, o engenheiro define o resultado desejado e delega a mecânica ao runtime.

Brian Goetz, arquiteto da linguagem Java e um dos principais responsáveis pelo Project Lambda, explicou a motivação em termos que revelam a profundidade da mudança:

"A maior mudança linguística no Java 8 é a adição de expressões lambda. [...] Expressões lambda nos dão uma forma leve de representar uma função como dado."
— Brian Goetz, State of the Lambda

A frase-chave é "representar uma função como dado". Essa é a essência do paradigma funcional. Funções são valores de primeira classe, podem ser passadas como argumentos, retornadas como resultados, armazenadas em variáveis. Ao adotar isso, Java passou a permitir que o engenheiro escolhesse entre expressar comportamento como método de um objeto ou como valor que flui pelo sistema. O modelo único de 1995 dava lugar a um ecossistema em que orientação a objetos e estilo funcional coexistem, cada um aplicável onde melhor se encaixa.

Essa mudança não foi acidental nem apressada. O Project Lambda levou anos de design cuidadoso, com debates internos sobre se lambdas deveriam ser objetos (a abordagem natural para Java) ou algo novo. A decisão final foi pragmática. Lambdas são instâncias de interfaces funcionais, o que preserva a compatibilidade com o ecossistema existente enquanto abre a porta para um estilo de programação fundamentalmente diferente.

A imutabilidade como princípio arquitetural

Java 8 também trouxe consigo, ainda que de forma indireta, a valorização da imutabilidade como princípio de design. A API de Streams opera sobre dados sem modificá-los. Optional encoraja a explicitação da ausência de valor em vez da mutação silenciosa via null. Functional interfaces como Function<T, R> e Predicate<T> expressam transformações puras, sem efeitos colaterais.

Essa valorização não surgiu no vácuo. Ela reflete uma lição que a engenharia de software aprendeu com a computação distribuída e concorrente. Estado mutável compartilhado é a raiz da maioria dos bugs em sistemas complexos. Quando dois threads acessam o mesmo objeto mutável, a ordem de execução determina o resultado, e essa ordem, em sistemas reais, é imprevisível. Race conditions, deadlocks e inconsistências de estado são consequências diretas da mutabilidade descontrolada.

Uma analogia imperfeita, mas útil, vem da física. Na mecânica quântica, o ato de medir uma partícula perturba seu estado. O observador não consegue capturar a realidade sem alterá-la. Em sistemas concorrentes com estado mutável, algo parecido acontece. Você não pode observar o estado de um objeto com segurança enquanto outra thread pode estar alterando-o. A leitura e a escrita interferem uma na outra de forma imprevisível. O valor que você lê pode já ter sido alterado entre a leitura e o uso. Dados imutáveis eliminam essa interferência. Uma vez criados, podem ser lidos por qualquer número de threads sem risco, porque não há mutação para invalidar a observação. É como se o dado, ao se tornar imutável, passasse a se comportar como uma constante física: qualquer observador o lê e obtém o mesmo valor, independentemente de quando ou de onde observa.

"Se você tiver um valor imutável, ele pode ser usado por vários threads sem nenhum problema. [...] Você simplesmente não pode ter uma race condition com um valor imutável."
— Rich Hickey, criador do Clojure, Are We There Yet? (JVM Language Summit, 2009)

Java 8 não tornou a linguagem imutável por padrão, mas plantou as sementes. Streams são pipelines de transformação imutável. Optional é um container imutável. As interfaces funcionais encorajam funções puras. O terreno estava sendo preparado para algo maior.

Records: a transparência como valor de design

Seis anos depois do Java 8, o JEP 395, entregue no Java 16 (março de 2021), deu o próximo passo. A motivação por trás dos records nasceu de uma observação recorrente sobre a relação entre o que o engenheiro queria modelar e o volume de código que Java exigia para isso. O JEP é direto ao nomear o problema:

"É uma reclamação comum que 'Java é muito verboso' ou tem 'cerimônia demais'. Alguns dos piores casos são classes que não são nada mais do que portadores imutáveis de dados para um punhado de valores."
— JEP 395: Records

Para entender o que records resolvem, considere o que um engenheiro Java precisava escrever antes deles para representar um conceito simples como um evento de domínio:

// Antes de records: ~60 linhas para carregar 4 campos
public final class PedidoCriado {
    private final String pedidoId;
    private final String clienteId;
    private final BigDecimal valor;
    private final Instant criadoEm;

    public PedidoCriado(String pedidoId, String clienteId,
                        BigDecimal valor, Instant criadoEm) {
        this.pedidoId = pedidoId;
        this.clienteId = clienteId;
        this.valor = valor;
        this.criadoEm = criadoEm;
    }

    public String pedidoId() { return pedidoId; }
    public String clienteId() { return clienteId; }
    public BigDecimal valor() { return valor; }
    public Instant criadoEm() { return criadoEm; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof PedidoCriado)) return false;
        PedidoCriado that = (PedidoCriado) o;
        return Objects.equals(pedidoId, that.pedidoId)
            && Objects.equals(clienteId, that.clienteId)
            && Objects.equals(valor, that.valor)
            && Objects.equals(criadoEm, that.criadoEm);
    }

    @Override
    public int hashCode() {
        return Objects.hash(pedidoId, clienteId, valor, criadoEm);
    }

    @Override
    public String toString() {
        return "PedidoCriado[pedidoId=" + pedidoId
            + ", clienteId=" + clienteId
            + ", valor=" + valor
            + ", criadoEm=" + criadoEm + "]";
    }
}
// Com records: a mesma semântica em uma linha
public record PedidoCriado(
    String pedidoId,
    String clienteId,
    BigDecimal valor,
    Instant criadoEm
) {}

A redução de boilerplate é a consequência mais visível, mas o ganho real está em outro lugar. No primeiro caso, o engenheiro implementou manualmente equals, hashCode e toString. Cada uma dessas implementações é uma decisão que pode conter bugs, divergir das outras (um campo incluído em equals mas esquecido em hashCode), ou ser alterada por um desenvolvedor bem-intencionado que não entendeu o contrato original. O tipo se torna opaco: olhar para a declaração dos campos não basta para saber como o objeto se compara, se identifica ou se representa como texto, porque essas respostas estão espalhadas em métodos escritos à mão que podem divergir da estrutura declarada. Seu significado depende de código que precisa ser lido, verificado e mantido, e qualquer mudança num campo exige atualizações coordenadas em vários métodos.

Com o record, o compilador gera tudo a partir da declaração, e essa geração é determinística. A estrutura do tipo define sua identidade, sua igualdade e sua representação textual. O espaço para divergência entre o que o tipo declara e o que ele faz desaparece. Essa transparência é o núcleo filosófico dos records, e o JEP é explícito sobre isso:

"Uma classe record é um portador transparente e superficialmente imutável para um conjunto fixo de valores, chamados componentes do record."
— JEP 395: Records

A palavra "transparente" carrega peso técnico e filosófico. Em um record, a API é derivada mecanicamente da descrição do estado. Não há comportamento escondido, estado secreto ou lógica de negócio embutida. Quem lê a declaração do tipo sabe exatamente o que ele carrega e como acessá-lo. Essa transparência inverte o princípio clássico de encapsulamento, onde o objetivo é esconder estado atrás de comportamento. Nos records, o estado é o contrato. A estrutura visível define identidade, igualdade e representação, sem camadas intermediárias.

"Com records, a API de uma classe é derivada mecanicamente da descrição do estado. Isso abre mão de um grau de encapsulamento: os campos da classe são expostos através de sua API."
— Brian Goetz, Data Classes and Sealed Types for Java

Abrir mão de encapsulamento pode parecer heresia para quem cresceu no paradigma orientado a objetos. Mas Goetz argumenta que essa abertura é intencional. Existem tipos cuja razão de existir é carregar dados, e forçá-los a fingir que têm comportamento encapsulado é uma mentira que o código conta para si mesmo.

Records também permitem validação no construtor canônico compacto, o que significa que invariantes de criação são preserváveis:

public record Coordenada(double latitude, double longitude) {
    public Coordenada {
        if (latitude < -90 || latitude > 90)
            throw new IllegalArgumentException(
                "Latitude deve estar entre -90 e 90: " + latitude);
        if (longitude < -180 || longitude > 180)
            throw new IllegalArgumentException(
                "Longitude deve estar entre -180 e 180: " + longitude);
    }
}

Após a construção, a Coordenada é uma fotografia imutável. Seus valores não podem mudar. Qualquer thread pode lê-la com segurança. Qualquer serialização pode reconstruir o record a partir dos componentes, e a forma serializada é determinística: deriva da lista canônica de componentes, sem identificador de versão à parte. O contraste com o modelo antigo de POJOs e serialVersionUID fica para o artigo sobre DOP, onde o dado atravessa fronteiras e o contrato de serialização ganha peso.

Antes dos records, boa parte do ecossistema Java recorria ao Lombok para reduzir boilerplate em classes que carregavam dados. Anotações como @Data, @Value, @Getter e @Builder geravam em tempo de compilação construtores, acessores, equals, hashCode e toString, aproximando o código de um modelo "dados como dados". O Lombok virou quase um padrão em muitos projetos. Ao mesmo tempo, divide opiniões. Quem aprova destaca a produtividade e a legibilidade. Quem evita aponta a dependência em manipulação de bytecode, a fragilidade em upgrades de JDK ou de IDE, a opacidade de código que depende de geração invisível e a dificuldade de troubleshooting durante debug, já que o código executado não corresponde ao que o desenvolvedor escreveu.

Records entram como resposta nativa da linguagem para o mesmo problema que o Lombok atacava nos portadores imutáveis de dados. Para DTOs, eventos, comandos e value objects, um record substitui a classe anotada com @Value ou boa parte do que @Data fazia, sem biblioteca extra e com semântica definida pelo JLS. Isso não invalida o Lombok onde ele ainda agrega (por exemplo, entidades mutáveis com setters ou builders complexos), mas reduz de forma clara a necessidade da lib em código novo orientado a dados. A evolução da linguagem absorve, aos poucos, o espaço que ferramentas externas ocupavam.

Records também carregam restrições deliberadas que convém conhecer. Um record não pode estender uma classe (apenas implementar interfaces). Não pode declarar campos de instância além dos componentes do cabeçalho. Não suporta herança entre records. Essas limitações existem justamente para preservar a transparência. Se um record pudesse esconder campos extras ou herdar estado de uma superclasse, a promessa de que "a declaração é o contrato" se quebraria. A rigidez é o preço da garantia.

A semântica de valor dos records traz ainda uma consequência que muitas vezes passa despercebida. A representação em memória torna-se previsível. O compilador e a JVM conhecem o layout exato dos componentes. Essa previsibilidade abre espaço para otimizações que tipos opacos dificultam, desde alocação mais eficiente até a possibilidade de inlining em cenários que o runtime considere vantajoso. Com sealed classes e switch sobre tipos selados, o compilador pode, em certos casos, resolver em tempo de compilação qual ramo executar, reduzindo o custo de despacho que a herança polimórfica impõe. Nada disso exige que o programador pense em performance no dia a dia; a forma dos dados, transparente e estável, permite que a máquina tome decisões que antes não tinha como tomar.

Sealed Classes: o fechamento do universo de tipos

Records sozinhos resolvem metade do problema. Eles modelam o que a teoria dos tipos chama de product types, tipos definidos pela combinação (produto cartesiano) de seus componentes. Um PedidoCriado(String, String, BigDecimal, Instant) é o produto de quatro tipos. Mas sistemas reais também precisam de sum types, tipos que representam uma escolha entre alternativas mutuamente exclusivas. Um resultado pode ser sucesso ou falha. Um evento pode ser criação, atualização ou cancelamento. Uma forma geométrica pode ser círculo, retângulo ou triângulo.

Na teoria dos tipos, a combinação de product types e sum types forma o que se chama de Algebraic Data Types (ADTs), uma construção fundamental em linguagens como Haskell, ML, Scala e Rust. Em Scala, case classes e sealed traits realizam esse papel há anos. Em Kotlin, data classes combinadas com sealed classes oferecem o mesmo padrão. Java, até o Java 17, não tinha mecanismo nativo para sum types. A solução era usar hierarquias de classes com herança, mas nada impedia que um desenvolvedor criasse uma nova subclasse em qualquer lugar do sistema, quebrando a exaustividade do modelo. Com sealed interfaces e records, o Java passa a falar a mesma língua que essas linguagens para esse tipo de modelagem.

O JEP 409 (Java 17, setembro de 2021) resolveu isso com sealed classes:

"Uma classe ou interface sealed pode ser estendida ou implementada apenas por aquelas classes e interfaces que têm permissão para fazê-lo."
— JEP 409: Sealed Classes

Em lógica formal, isso é conhecido como closed-world assumption. O conjunto de possibilidades é finito e conhecido em tempo de compilação. Compare com a open-world assumption da herança tradicional, onde qualquer classe pode estender qualquer outra (a menos que seja final). Sealed classes trazem a closed-world assumption para Java:

public sealed interface ResultadoPagamento
    permits PagamentoAprovado, PagamentoRecusado, PagamentoPendente {}

public record PagamentoAprovado(
    String transacaoId,
    Instant processadoEm
) implements ResultadoPagamento {}

public record PagamentoRecusado(
    String motivo,
    String codigo
) implements ResultadoPagamento {}

public record PagamentoPendente(
    Instant expiraEm
) implements ResultadoPagamento {}

O compilador agora sabe que ResultadoPagamento só pode ser uma dessas três variantes. Nenhuma outra classe pode implementar a interface. Isso tem uma consequência decisiva. O compilador pode verificar exaustividade. Se um switch sobre ResultadoPagamento não tratar todas as variantes, o compilador emite um erro. O modelo de domínio deixa de ser uma convenção que depende da disciplina do time e passa a ser uma restrição verificada pela máquina.

O que sealed classes fazem não é eliminar a extensibilidade, mas escolher onde ela termina. Dentro do tipo selado, o universo é finito. Fora dele, o sistema segue aberto. O autor do tipo decide quais variantes existem, e o compilador garante que nenhuma outra apareça.

O mesmo padrão sealed mais records serve para modelar operações que podem falhar de forma tipada. Em vez de lançar exceções ou retornar null, o método devolve um tipo que representa explicitamente sucesso ou falha. Por exemplo, pode-se definir uma interface sealed Resultado<T, E> permitida apenas por Sucesso(T valor) e Falha(E erro). Quem chama o método fica obrigado, em tempo de compilação, a tratar os dois casos num switch. O erro vira dado que atravessa o sistema com a mesma dignidade que o valor de sucesso; a decisão de como logar, exibir ou recuperar fica no ponto de uso, e o tipo garante que nenhum dos ramos seja esquecido. Linguagens como Rust e Haskell popularizaram esse estilo; em Java, sealed classes e records trazem a mesma disciplina para quem prefere que falhas sejam parte do contrato de tipos.

A combinação de sealed interfaces com records é o ponto em que as peças se encaixam. Cada variante é um record (product type), e a interface sealed é o sum type que as une. O resultado é um ADT completo em Java.

classDiagram
    ResultadoPagamento <|.. PagamentoAprovado
    ResultadoPagamento <|.. PagamentoRecusado
    ResultadoPagamento <|.. PagamentoPendente

Pattern Matching: a linguagem que decompõe

Records definem dados transparentes. Sealed classes definem universos fechados de tipos. Faltava ainda uma peça, a de decompor esses dados de forma segura e expressiva. A resposta veio com pattern matching.

O JEP 394 (Java 16) introduziu pattern matching para instanceof, eliminando o cast manual que acompanhava toda verificação de tipo:

// Antes: verificação + cast manual
if (resultado instanceof PagamentoAprovado) {
    PagamentoAprovado aprovado = (PagamentoAprovado) resultado;
    processar(aprovado.transacaoId());
}

// Com pattern matching: verificação + binding em uma expressão
if (resultado instanceof PagamentoAprovado aprovado) {
    processar(aprovado.transacaoId());
}

Mas a verdadeira revolução veio com o JEP 441 (Java 21), que trouxe pattern matching para switch, e o JEP 440, que trouxe record patterns. Juntos, eles permitem a desconstrução de hierarquias de tipos:

public String descrever(ResultadoPagamento resultado) {
    return switch (resultado) {
        case PagamentoAprovado(var txId, var quando) ->
            "Aprovado: transação %s em %s".formatted(txId, quando);

        case PagamentoRecusado(var motivo, var codigo) ->
            "Recusado [%s]: %s".formatted(codigo, motivo);

        case PagamentoPendente(var expira) ->
            "Pendente até %s".formatted(expira);
    };
}

Observe o que está acontecendo. O switch é uma expressão (não um statement), o que significa que produz um valor. Os patterns desconstroem os records em seus componentes, extraindo os valores diretamente. E como a interface é sealed, o compilador verifica exaustividade. Se uma nova variante for adicionada a ResultadoPagamento, todos os switches que a utilizam deixarão de compilar até serem atualizados. O sistema de tipos se torna um guardião ativo da consistência do código.

O Java 21 também trouxe guarded patterns, que permitem refinar um case com uma condição booleana via cláusula when. A guarda adiciona expressividade sem comprometer a exaustividade. O compilador continua verificando que todas as variantes estão cobertas, independentemente das condições de guarda.

String descricao = switch (resultado) {
    case PagamentoAprovado(var txId, var quando) when txId.startsWith("INT") ->
        "Pagamento internacional aprovado: " + txId;

    case PagamentoAprovado(var txId, var quando) ->
        "Pagamento aprovado: " + txId;

    case PagamentoRecusado(var motivo, var quando) ->
        "Recusado: " + motivo;

    case PagamentoPendente(var prazo) ->
        "Aguardando até " + prazo;
};

O contraste com polimorfismo via métodos virtuais ajuda a situar cada abordagem. No polimorfismo, o comportamento vive nas subclasses e a decisão de qual método executar é tomada em tempo de execução. No pattern matching, o comportamento fica no ponto de uso e a estrutura dos dados guia a decisão. A escolha entre um e outro depende do problema. Quando o comportamento pertence intrinsicamente ao tipo (por exemplo, uma operação que só aquele tipo sabe realizar), polimorfismo encaixa melhor. Quando o mesmo dado precisa ser interpretado de formas diferentes conforme o contexto (transformações, validações, serialização, logging, geração de respostas), centralizar a lógica em um switch com pattern matching evita espalhar conhecimento do domínio por várias classes e facilita adicionar novos consumidores sem alterar os tipos de dados.

Essa mesma simplicidade torna obsoletos, na prática, vários padrões que existiam para contornar limitações da linguagem. O Visitor era a solução clássica para percorrer uma hierarquia de tipos e executar uma ação diferente por variante. Exigia uma interface de visita, um método accept em cada classe da hierarquia e implementações que delegavam para o overload correto do visitante. O objetivo era obter algo próximo a um switch exaustivo sobre o tipo, mas em Java antigo não havia como expressar isso de forma direta. Com sealed classes e pattern matching, o mesmo efeito surge em poucas linhas. Um único switch sobre a interface selada substitui a interface Visitor, as implementações de accept e os métodos do visitante. Compare o Visitor clássico para calcular a área de formas geométricas com a mesma lógica usando sealed e pattern matching:

// Visitor clássico: interface de visita + accept em cada classe
interface Forma {
    <R> R accept(Visitor<R> v);
}
interface Visitor<R> {
    R visit(Circulo c);
    R visit(Retangulo r);
}
class Circulo implements Forma {
    final double raio;
    Circulo(double raio) { this.raio = raio; }
    @Override
    public <R> R accept(Visitor<R> v) { return v.visit(this); }
}
class Retangulo implements Forma {
    final double largura, altura;
    Retangulo(double l, double a) { this.largura = l; this.altura = a; }
    @Override
    public <R> R accept(Visitor<R> v) { return v.visit(this); }
}
// Cada novo comportamento exige uma nova classe visitante
class AreaVisitor implements Visitor<Double> {
    public Double visit(Circulo c) { return Math.PI * c.raio * c.raio; }
    public Double visit(Retangulo r) { return r.largura * r.altura; }
}
// Uso: forma.accept(new AreaVisitor());
// Sealed + pattern matching: mesmo resultado, sem Visitor
sealed interface Forma permits Circulo, Retangulo {}
record Circulo(double raio) implements Forma {}
record Retangulo(double largura, double altura) implements Forma {}

double area(Forma f) {
    return switch (f) {
        case Circulo(double r) -> Math.PI * r * r;
        case Retangulo(double l, double a) -> l * a;
    };
}

Padrões análogos, como o "type-safe heterogeneous container" para despacho por tipo, perdem parte da razão de ser quando o compilador verifica exaustividade do switch. A linguagem também reduziu a necessidade de outros artifícios de verbosidade. O Strategy, quando se resumia a um único método, foi em grande parte absorvido por lambdas e referências de método. Antes, uma interface e várias classes para variar o comportamento; hoje, o comportamento passa direto como função:

// Strategy clássico: interface + uma classe por variante
interface EstrategiaOrdenacao {
    int comparar(Pedido a, Pedido b);
}
class OrdenarPorValor implements EstrategiaOrdenacao {
    public int comparar(Pedido a, Pedido b) { return a.valor().compareTo(b.valor()); }
}
class OrdenarPorData implements EstrategiaOrdenacao {
    public int comparar(Pedido a, Pedido b) { return a.data().compareTo(b.data()); }
}
// Uso: lista.sort((a, b) -> estrategia.comparar(a, b));
// Com lambda, o strategy vira argumento
pedidos.sort(Comparator.comparing(Pedido::valor));
pedidos.sort(Comparator.comparing(Pedido::data));

Builders elaborados para objetos com muitos campos opcionais seguem úteis em casos específicos, mas para agregados simples de dados o record com construtor canônico basta. O próprio Optional, já no Java 8, tornou desnecessário o padrão "null object" em vários contextos. O que antes exigia padrões de projeto e bibliotecas para compensar a linguagem passa a ser expresso de forma direta. A complexidade não desaparece, mas a evolução do Java desloca boa parte dela para onde a linguagem resolve nativamente.


Essa jornada, do Java 8 ao 21, mostra a linguagem incorporando imutabilidade, transparência de dados e exaustividade de forma integrada. O próprio switch, que no Java clássico era uma instrução propensa a bugs por fall-through implícito, tornou-se uma expressão que produz valor e em que o compilador garante que todo caso foi tratado. Somado a records, sealed classes e pattern matching, o repertório se completa. Não são recursos soltos, mas peças que se encaixam. No artigo Data-Oriented Programming em Java: quando os dados orientam o design, exploramos como essas peças se unem no paradigma data-oriented e como decidir onde os dados devem orientar o design do seu sistema.