Capa do artigo: Data-Oriented Programming em Java: quando os dados orientam o design

Data-Oriented Programming em Java: quando os dados orientam o design

No artigo Java multiparadigma: records, sealed classes e pattern matching vimos como o Java ganhou records, sealed classes, pattern matching e switch expressions. Aqui o foco desloca. O que fazer com essas peças na arquitetura e quando deixar os dados orientarem o design.

Data-Oriented Programming: o sistema completo

Brian Goetz resume o núcleo do paradigma em uma frase.

"A ideia central da programação orientada a dados em Java é: modele os dados como dados, e modele o comportamento separadamente."
— Brian Goetz, Data-Oriented Programming in Java

A divisão é clara. De um lado, a forma do que existe (estrutura, variantes). De outro, o que se faz com essa forma (funções e serviços que a consomem). O modelo de dados passa a ser a bússola. Primeiro a definição dos tipos, depois o código que os processa. Em muitos domínios essa separação reduz acoplamento e deixa o fluxo de dados explícito. Em sistemas com vários consumidores para o mesmo evento (notificação, auditoria, métricas, projeção de leitura), concentrar a estrutura nos tipos e o processamento em funções que recebem esses tipos evita que cada novo caso de uso exija mudar o domínio. Um exemplo curto mostra essa divisão em ação.

public sealed interface Resultado
    permits Sucesso, Falha {}

public record Sucesso(String valor) implements Resultado {}
public record Falha(String erro) implements Resultado {}

// O comportamento vive fora dos dados
public String interpretar(Resultado r) {
    return switch (r) {
        case Sucesso(var v) -> "OK: " + v;
        case Falha(var e) -> "Erro: " + e;
    };
}

Os dados definem a estrutura. O switch define o que se faz com ela. Adicionar um novo consumidor (log, métrica, notificação) significa escrever outro switch sobre o mesmo tipo, sem alterar Resultado, Sucesso ou Falha.

Vale precisar o que "dados" significa aqui. Em DOP, "dados" inclui o valor das variáveis e a própria forma do tipo. Um sealed interface com três records permitidos carrega a informação "existem exatamente estas três possibilidades". O switch que desconstroi esse tipo usa essa informação para garantir exaustividade. A forma do tipo é portanto parte do dado que orienta o design. O diagrama abaixo explicita a divisão de papéis. De um lado, a modelagem (records e sealed classes) define o que existe. De outro, o processamento (pattern matching e switch expressions) define como se consome. Os dois convergem para o que a teoria dos tipos chama de Algebraic Data Types (ADTs), como desenvolvido no artigo anterior. Records representam a combinação de componentes (product types) e sealed interfaces representam a escolha entre alternativas (sum types). Juntos, formam tipos cuja estrutura e variantes são conhecidas e que podem ser desconstruídos de forma segura e exaustiva.

graph TD
    A[Data-Oriented Programming] --> B[Records]
    A --> C[Sealed Classes]
    A --> D[Pattern Matching]
    A --> E[Switch Expressions]

    B --> F[Modelagem de Dados]
    C --> F
    D --> G[Processamento de Dados]
    E --> G

    F --> H[Algebraic Data Types]
    G --> H

    style A fill:#1a1a1a,stroke:#f5a623,color:#f5a623
    style H fill:#1a1a1a,stroke:#f5a623,color:#f5a623

A expressão "Data-Oriented Programming" circula há anos em outras comunidades. Em Clojure e em Lisp, dados imutáveis e funções que os transformam formam o núcleo do estilo. Yehonathan Sharvit sistematizou o tema no livro "Data-Oriented Programming" (Manning, 2022). Goetz deu ao termo um contorno preciso no Java e mostrou como records, sealed classes e pattern matching realizam o paradigma na linguagem. Quem vem de Clojure ou Scala reconhece a mesma ideia. Quem vem só de Java ganha um nome e construtos para aplicá-la de forma nativa.

O paradigma paga dividendos onde há múltiplos consumidores para o mesmo dado ou onde o dado cruza fronteiras. Em arquiteturas orientadas a eventos, um evento é publicado uma vez e lido por vários assinantes, cada um com uma interpretação (notificação, projeção, métricas, auditoria). Com eventos modelados como dados (records em sealed interface), cada assinante é um switch que decompõe o evento. O contrato do barramento é a estrutura do tipo. O mesmo vale para APIs REST ou GraphQL. Payload como dado, validação, transformação e persistência como comportamento. Contrato em record permite derivar esquema OpenAPI ou GraphQL da declaração. Handlers são funções que recebem DTO e devolvem DTO ou efeito. Os dados orientam o código e o contrato que o sistema expõe.

Considere um exemplo mais completo. Um sistema de processamento de eventos de domínio, onde diferentes tipos de eventos precisam ser roteados, validados e transformados.

public sealed interface EventoDominio
    permits PedidoCriado, PedidoPago, PedidoCancelado, PedidoEnviado {

    String pedidoId();
    Instant ocorridoEm();
}

public record PedidoCriado(
    String pedidoId, String clienteId,
    List<Item> itens, Instant ocorridoEm
) implements EventoDominio {}

public record PedidoPago(
    String pedidoId, String transacaoId,
    BigDecimal valor, Instant ocorridoEm
) implements EventoDominio {}

public record PedidoCancelado(
    String pedidoId, String motivo,
    Instant ocorridoEm
) implements EventoDominio {}

public record PedidoEnviado(
    String pedidoId, String rastreio,
    Instant previsaoEntrega, Instant ocorridoEm
) implements EventoDominio {}

public record Item(String produtoId, int quantidade, BigDecimal preco) {}
public class NotificacaoService {

    public Notificacao gerar(EventoDominio evento) {
        return switch (evento) {
            case PedidoCriado(var id, var cliente, var itens, var quando) ->
                new Notificacao(
                    cliente,
                    "Pedido %s confirmado com %d itens".formatted(id, itens.size()),
                    Prioridade.NORMAL
                );

            case PedidoPago(var id, var txId, var valor, var ocorrido) ->
                new Notificacao(
                    buscarCliente(id),
                    "Pagamento de R$ %s confirmado (tx: %s)".formatted(valor, txId),
                    Prioridade.ALTA
                );

            case PedidoCancelado(var id, var motivo, var ocorrido) ->
                new Notificacao(
                    buscarCliente(id),
                    "Pedido %s cancelado: %s".formatted(id, motivo),
                    Prioridade.ALTA
                );

            case PedidoEnviado(var id, var rastreio, var previsao, var ocorrido) ->
                new Notificacao(
                    buscarCliente(id),
                    "Pedido %s enviado! Rastreio: %s. Previsão: %s"
                        .formatted(id, rastreio, previsao),
                    Prioridade.NORMAL
                );
        };
    }
}

Esse código tem propriedades que seriam impossíveis em Java antes dessas features. A modelagem é algébrica. O compilador conhece todas as variantes. A desconstrução é segura e os componentes são extraídos diretamente dos patterns. O processamento é exaustivo. Adicionar um novo tipo de evento força a atualização de todos os switches. Cada evento carrega exatamente o que declara, sem estado escondido. Essa transparência traz um benefício colateral para quem escreve testes. Criar um evento para um cenário de teste resume-se a instanciar o record com os valores desejados, sem mocks nem construção elaborada de objetos.

Compare isso com a abordagem clássica OO, onde cada tipo de evento teria um método gerar() polimórfico. A abordagem OO distribui o comportamento entre as classes, o que é ideal quando o comportamento pertence ao tipo. A abordagem DOP centraliza o comportamento no ponto de uso, o que é ideal quando o mesmo dado é processado de formas diferentes por consumidores diferentes (notificação, auditoria, métricas, projeção de leitura, entre outros).

A armadilha da transparência

Toda ferramenta poderosa carrega o risco de uso indevido, e records seguem essa regra. A própria especificação alerta sobre o significado preciso de "imutável".

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

"Superficialmente imutável" é a chave. O record congela as referências, mas os objetos referenciados seguem mutáveis se forem mutáveis. Um componente que é uma List<Item> pode ser alterado externamente após a criação do record. A imutabilidade permanece apenas rasa. O exemplo abaixo ilustra o risco.

public record Pedido(String id, List<Item> itens) {}

var itens = new ArrayList<>(List.of(new Item("A", 1, BigDecimal.TEN)));
var pedido = new Pedido("001", itens);

// O record é "imutável", mas...
itens.add(new Item("B", 2, BigDecimal.ONE)); // a lista original foi mutada
pedido.itens().clear(); // o accessor retorna a referência mutável

A solução é defensiva. Copiar coleções no construtor canônico e retornar cópias não modificáveis resolve o problema.

public record Pedido(String id, List<Item> itens) {
    public Pedido {
        itens = List.copyOf(itens); // cópia imutável
    }
}

A armadilha mais sutil está na arquitetura. Quando times adotam records sem avaliar onde cada paradigma se aplica, começam a substituir entidades de domínio ricas por records transparentes. O resultado se aproxima do que Martin Fowler descreveu como "Anemic Domain Model".

"O sintoma básico de um Modelo de Domínio Anêmico é que, à primeira vista, ele parece a coisa real. Existem objetos, muitos com nomes que remetem aos substantivos do domínio, e esses objetos estão conectados com as relações e a estrutura ricas que modelos de domínio verdadeiros possuem. O problema aparece quando você observa o comportamento e percebe que quase não há comportamento nesses objetos, tornando-os pouco mais que sacos de getters e setters."
— Martin Fowler, AnemicDomainModel

Records podem acelerar essa erosão, porque tornam a criação de tipos anêmicos extremamente fácil. Na prática, times que adotaram records indiscriminadamente começaram a deslocar regras de negócio para serviços externos, criando um estilo procedural involuntário. Funções que recebem dados, transformam e devolvem novos dados, sem que nenhuma das estruturas carregue responsabilidade. Isso pode ser poderoso em sistemas orientados a eventos, onde a separação entre dados e processamento é uma escolha arquitetural consciente. Mas quando aplicado no coração do domínio, onde invariantes complexas precisam ser protegidas ao longo de toda a vida do objeto, a ausência de comportamento encapsulado se torna um problema silencioso que só aparece quando o sistema cresce.

Identidade, encapsulamento e os limites do record

Para entender onde records param de funcionar, é preciso entender o que significa identidade em um sistema. Eric Evans, no livro que deu forma ao Domain-Driven Design, colocou a distinção de forma direta.

"Muitos objetos não são fundamentalmente definidos por seus atributos, mas sim por um fio de continuidade e identidade."
— Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software

Uma ContaBancaria ilustra o ponto. Duas contas podem ter o mesmo titular, o mesmo saldo e a mesma data de abertura. Ainda assim, são contas diferentes. A identidade da conta é algo que persiste ao longo do tempo, independente dos valores que ela carrega em cada momento. O saldo muda, o titular pode mudar, mas a conta continua sendo a mesma conta.

Records definem igualdade pela estrutura. Dois records com os mesmos componentes são iguais. Essa semântica é perfeita para valores e dados em trânsito, mas produz o resultado errado para entidades.

// Record: igualdade por estrutura
public record ContaBancariaRecord(String titular, BigDecimal saldo) {}

var conta1 = new ContaBancariaRecord("Ana", new BigDecimal("1000"));
var conta2 = new ContaBancariaRecord("Ana", new BigDecimal("1000"));

conta1.equals(conta2); // true — mas são contas diferentes!

O equals retorna true porque os componentes são iguais. No mundo real, essas são duas contas distintas que por acaso têm os mesmos valores naquele instante. A identidade da entidade se perde.

Além da identidade, existe o encapsulamento. O JEP 395 é explícito sobre o que records abrem mão.

"Classes record abrem mão de uma liberdade que as classes normalmente possuem, a capacidade de desacoplar a API de uma classe da sua representação interna."
— JEP 395: Records

Essa transparência é uma virtude quando o tipo existe para carregar dados. Mas quando o tipo precisa proteger regras de negócio, a exposição se torna um risco. Compare as duas abordagens para ContaBancaria.

// Classe com encapsulamento: as regras vivem dentro do tipo
public class ContaBancaria {
    private final String id;
    private final String titular;
    private BigDecimal saldo;
    private final List<Transacao> historico;

    public ContaBancaria(String id, String titular, BigDecimal saldoInicial) {
        this.id = id;
        this.titular = titular;
        this.saldo = saldoInicial;
        this.historico = new ArrayList<>();
    }

    public void debitar(BigDecimal valor) {
        if (valor.compareTo(saldo) > 0) {
            throw new SaldoInsuficienteException(id, saldo, valor);
        }
        saldo = saldo.subtract(valor);
        historico.add(new Transacao(TipoTransacao.DEBITO, valor, Instant.now()));
    }

    public void creditar(BigDecimal valor) {
        saldo = saldo.add(valor);
        historico.add(new Transacao(TipoTransacao.CREDITO, valor, Instant.now()));
    }

    public BigDecimal consultarSaldo() {
        return saldo;
    }

    @Override
    public boolean equals(Object o) {
        return o instanceof ContaBancaria c && id.equals(c.id);
    }

    @Override
    public int hashCode() {
        return id.hashCode();
    }
}

O saldo só muda através de debitar() e creditar(). A invariante "saldo nunca negativo sem autorização" é protegida pelo próprio tipo. O equals compara pelo id, preservando a identidade da entidade. Qualquer consumidor que receba uma ContaBancaria opera sob essas garantias sem precisar conhecê-las.

Agora a mesma tentativa com record.

// Record: tudo exposto, nenhuma garantia
public record ContaBancariaRecord(
    String id, String titular,
    BigDecimal saldo, List<Transacao> historico
) {}

var conta = new ContaBancariaRecord(
    "001", "Ana", new BigDecimal("1000"), new ArrayList<>()
);

// Qualquer código pode "debitar" sem validação
var novaConta = new ContaBancariaRecord(
    conta.id(), conta.titular(),
    conta.saldo().subtract(new BigDecimal("5000")), // saldo negativo, sem barreira
    conta.historico()
);

O record expõe todos os componentes via acessores. Qualquer trecho de código pode reconstruir uma nova instância com saldo negativo, sem passar por nenhuma regra. A invariante que a classe protegia desaparece. Goetz aponta para essa zona de forma precisa.

"A OO está no seu melhor quando define e defende fronteiras, fronteiras de manutenção, fronteiras de versionamento, fronteiras de encapsulamento, fronteiras de compilação, fronteiras de compatibilidade, fronteiras de segurança, etc."
— Brian Goetz, Data-Oriented Programming in Java

Onde essas fronteiras precisam existir, o record trabalha contra o design. A transparência que o torna ideal para dados em trânsito é a mesma que remove as barreiras de proteção quando aplicada no domínio.

O diagrama abaixo mapeia essa separação. Entidades e aggregates vivem no núcleo, protegidos por encapsulamento. Records habitam as fronteiras, onde a transparência é desejável.

graph LR
    subgraph Fronteira Externa
        A[API REST] --> B[Request DTO record]
        Z[Response DTO record] --> A
    end

    subgraph Application Layer
        B --> C[Command record]
        C --> D[Application Service]
        D --> E[Domain Event record]
        D --> Z
    end

    subgraph Núcleo de Domínio
        D --> F[Entity Aggregate class]
        F --> G[Value Object record]
    end

    subgraph Fronteira de Saída
        E --> H[Message Broker]
        D --> I[Repository]
    end

    style F fill:#1a1a1a,stroke:#f5a623,color:#f5a623
    style G fill:#1a1a1a,stroke:#f5a623,color:#f5a623

O custo de evoluir sem copy

Records em Java são imutáveis. Para produzir um novo valor a partir de um existente, é preciso reconstruir o record inteiro, passando todos os componentes manualmente.

public record Endereco(
    String rua, String numero, String complemento,
    String bairro, String cidade, String estado, String cep
) {}

var original = new Endereco(
    "Rua das Flores", "42", "Apto 3",
    "Centro", "São Paulo", "SP", "01001-000"
);

// Para mudar apenas o complemento, todos os outros componentes são repetidos
var atualizado = new Endereco(
    original.rua(), original.numero(), "Apto 7",
    original.bairro(), original.cidade(), original.estado(), original.cep()
);

Em Kotlin, data classes oferecem o método copy() que resolve esse problema de forma concisa.

data class Endereco(
    val rua: String, val numero: String, val complemento: String,
    val bairro: String, val cidade: String, val estado: String, val cep: String
)

val atualizado = original.copy(complemento = "Apto 7")

A documentação oficial do Kotlin descreve o propósito de forma direta.

"Use a função copy() para copiar um objeto, permitindo alterar algumas de suas propriedades enquanto mantém o restante inalterado."
— Kotlin Documentation, Data Classes

Java optou por não incluir copy() nos records. A discussão na lista amber-spec-experts do OpenJDK tratou do tema, e a decisão foi consciente. Com poucos componentes, a reconstrução manual é aceitável. Com muitos, o custo escala e o código fica verboso. Esse é um ponto que o engenheiro precisa considerar ao modelar records com muitos componentes. Se o tipo vai ser frequentemente "evoluído" para novos valores com pequenas variações, a ausência de copy() pesa.

Value Objects: onde o record encaixa com perfeição

Se entidades dependem de identidade e encapsulamento, value objects vivem no polo oposto. A identidade de um value object é definida inteiramente pelos seus componentes. Dois objetos Dinheiro com o mesmo valor e a mesma moeda representam, por definição, o mesmo conceito.

public record Dinheiro(BigDecimal valor, String moeda) {
    public Dinheiro {
        if (valor == null || valor.compareTo(BigDecimal.ZERO) < 0)
            throw new IllegalArgumentException("Valor não pode ser nulo ou negativo");
        if (moeda == null || moeda.isBlank())
            throw new IllegalArgumentException("Moeda não pode ser vazia");
    }

    public Dinheiro somar(Dinheiro outro) {
        if (!this.moeda.equals(outro.moeda))
            throw new IllegalArgumentException(
                "Não é possível somar moedas diferentes: %s e %s"
                    .formatted(this.moeda, outro.moeda));
        return new Dinheiro(this.valor.add(outro.valor), this.moeda);
    }
}
public record Coordenada(double latitude, double longitude) {
    public Coordenada {
        if (latitude < -90 || latitude > 90)
            throw new IllegalArgumentException("Latitude fora do intervalo válido");
        if (longitude < -180 || longitude > 180)
            throw new IllegalArgumentException("Longitude fora do intervalo válido");
    }
}

Nesses tipos, a igualdade estrutural dos records faz exatamente o que se espera. new Dinheiro(new BigDecimal("100"), "BRL") é igual a outro Dinheiro com os mesmos valores, porque representam a mesma coisa. A validação no construtor canônico garante que nenhuma instância inválida exista. O record carrega o dado e a garantia de validade, sem precisar esconder nada.

Mudança de estado versus evolução de estado

Uma forma de fixar a decisão entre classe e record é distinguir mudança de estado de evolução de estado.

Na mudança de estado, o mesmo objeto é alterado ao longo do tempo. A referência permanece, mas o conteúdo interno muda. A conta bancária recebe débito e crédito por métodos que atualizam seu saldo e seu histórico no próprio objeto. O ciclo de vida inclui carregar, modificar e persistir a mesma instância.

Na evolução de estado, não se altera o objeto. Produz-se um novo valor a partir do anterior. Um evento de domínio não "muda". Ele ocorre e é representado por um record imutável. Uma resposta de API não "atualiza" um DTO. Cada resposta é um novo valor.

graph TD
    subgraph "Mudança de estado (classe)"
        E1[ContaBancaria id=001
saldo: 1000] -->|debitar 200| E2[ContaBancaria id=001
saldo: 800] E2 -->|creditar 500| E3[ContaBancaria id=001
saldo: 1300] end subgraph "Evolução de estado (record)" R1[PedidoCriado] --> R2[PedidoPago] R2 --> R3[PedidoEnviado] end style E1 fill:#1a1a1a,stroke:#f5a623,color:#f5a623 style E2 fill:#1a1a1a,stroke:#f5a623,color:#f5a623 style E3 fill:#1a1a1a,stroke:#f5a623,color:#f5a623

Na mudança de estado, a mesma identidade atravessa o tempo. Na evolução de estado, cada instância é um fato imutável que se sucede ao anterior. Se o tipo representa algo que "vive" no tempo e acumula alterações na mesma identidade, a classe com encapsulamento segue adequada. Se o tipo representa um fato, um comando, um valor ou um contrato de passagem entre camadas, o record encaixa.

Fronteiras arquiteturais

Records brilham nas fronteiras do sistema. DTOs de entrada e saída, comandos, queries, eventos de domínio e projeções de leitura são todos tipos que existem para carregar dados entre contextos. Nessas zonas, a transparência é uma virtude. Quando um record atravessa uma fila, uma API REST ou um barramento de eventos, seu significado não depende de código escondido. A estrutura define a identidade do dado. Essa mesma estrutura mapeia de forma direta para JSON, para contratos OpenAPI ou para o payload de uma mensagem. Bibliotecas de serialização conseguem inspecionar os componentes do record e gerar esquemas, validar entrada e produzir documentação sem código adicional.

A serialização de records reforça essa filosofia. O mecanismo histórico de serialização em Java (java.io.Serializable) dependia de um número de versão, o serialVersionUID, para garantir compatibilidade entre quem serializa e quem desserializa. Na prática, isso gerou fragilidade. Esquecer de declarar o UID fazia com que uma simples refatoração quebrasse compatibilidade. Declarar e depois alterar a classe de forma incompatível mantendo o mesmo UID produzia bugs silenciosos. Records eliminam esse problema. O formato serializado é derivado diretamente dos componentes do record, sem identificador de versão separado. A estrutura é o contrato. Quem lê a declaração do record sabe exatamente o que trafega.

O hibridismo consciente

Goetz coloca a coexistência dos paradigmas de forma precisa.

"As técnicas da OOP e da programação orientada a dados não estão em conflito. São ferramentas diferentes para granularidades e situações diferentes. Podemos combiná-las livremente conforme julgarmos adequado."
— Brian Goetz, Data-Oriented Programming in Java

A maturidade no uso de Java moderno aparece quando o engenheiro reconhece que orientação a objetos e orientação a dados se complementam. Entidades ricas concentram comportamento onde a identidade e as invariantes do domínio pedem proteção. Records aparecem nas fronteiras, onde dados transitam entre contextos e a clareza estrutural importa mais que encapsulação. Sealed classes entram quando o domínio exige um conjunto finito e conhecido de variantes. Pattern matching ganha espaço onde a interpretação dos dados varia conforme o contexto.

Quem trabalha com código legado em Java 8 ou 11 pode adotar records e sealed classes de forma gradual. A estratégia que mais reduz risco é começar pelas bordas do sistema. Novos DTOs de API, novos eventos de domínio, novos comandos ou queries passam a ser modelados como records. As entidades e aggregates existentes permanecem como classes. Com o tempo, value objects que hoje são classes imutáveis podem ser migrados para records, e hierarquias que representam escolhas fechadas (como resultados de operação ou tipos de evento) podem ser seladas. A compatibilidade com serialização antiga, reflexão e ferramentas que inspecionam tipos costuma ser preservada, pois records são classes no bytecode. O que muda é a semântica e a concisão no código fonte.

A virada multiparadigma de Java acompanha uma mudança no tipo de software que o mundo passou a exigir. Sistemas distribuídos, arquiteturas orientadas a eventos, processamento massivo de dados e APIs como contratos entre times e organizações demandam formas diferentes de expressar intenção. Quando a linguagem oferece apenas um paradigma, o engenheiro acaba distorcendo o problema para caber na solução. Com mais de um paradigma disponível, a solução pode se adaptar ao problema, e cada contexto escolhe o estilo que melhor o descreve.

A evolução de Java, de uma linguagem puramente orientada a objetos para um ecossistema multiparadigma completo, reflete uma resposta madura às pressões do software moderno. A linguagem incorporou novos modos de expressão sem abandonar o que já funcionava. O que define o resultado é o uso consciente de cada ferramenta, mais que a quantidade disponível. Onde a programação orientada a dados se aplica, isso significa deixar os dados orientarem o design. Primeiro a estrutura e as variantes, depois o código que as consome. Em toda evolução genuína, o ganho está em saber quando aplicar qual paradigma, e em deixar que o problema guie a escolha.

Conclusão

No artigo anterior, acompanhamos o Java incorporando lambdas, records, sealed classes, pattern matching e switch expressions. Cada feature respondia a uma limitação real da linguagem. Neste artigo, vimos o que acontece quando essas peças se organizam em um paradigma coerente e onde esse paradigma encontra seus limites. A separação entre dados e comportamento funciona quando o dado cruza fronteiras. O encapsulamento funciona quando o domínio precisa de proteção. Identidade, invariantes e ciclo de vida pedem classes com comportamento. Transporte, contratos e evolução imutável pedem records. O ponto de maturidade é saber onde cada um se aplica.

A linguagem oferece mais repertório do que a maioria dos projetos utiliza hoje. Records, sealed classes e pattern matching estão disponíveis desde o Java 21, mas a adoção depende menos da ferramenta e mais de como as equipes pensam sobre design. O ganho real não vem de substituir classes por records ou de adotar pattern matching em todo switch. Vem de reconhecer a natureza do tipo que se está modelando e de escolher o construto que expressa essa natureza com mais fidelidade.

"Quando estamos modelando entidades complexas, as técnicas de OO têm muito a nos oferecer. Mas quando estamos construindo serviços simples que processam dados simples e ad-hoc, as técnicas da programação orientada a dados podem nos oferecer um caminho mais direto."
— Brian Goetz, Data-Oriented Programming in Java

A melhor ferramenta não é a mais nova. É a que o engenheiro sabe quando usar. E saber quando usar exige entender o que cada paradigma protege, o que cada paradigma expõe, e o que o problema à frente está pedindo.

Bibliografia

Brian Goetz. Data-Oriented Programming in Java. InfoQ, 2022.

Brian Goetz. Data-Oriented Programming in Java (v1.1). Inside.java, 2024.

Eric Evans. Domain-Driven Design: Tackling Complexity in the Heart of Software. Addison-Wesley, 2003.

Martin Fowler. AnemicDomainModel. martinfowler.com, 2003.

Yehonathan Sharvit. Data-Oriented Programming. Manning, 2022.

OpenJDK. JEP 395: Records. 2021.

OpenJDK. JEP 409: Sealed Classes. 2021.

OpenJDK. JEP 440: Record Patterns. 2023.

OpenJDK. JEP 441: Pattern Matching for switch. 2023.

Kotlin Documentation. Data Classes. kotlinlang.org.