Java Records: quando usar e quando evitar

1. Visão geral

Antes de Java 14, se você quisesse uma classe contendo apenas dados imutáveis, tinha que escrever manualmente o constructor, getters, equals(), hashCode(), toString() — tudo boilerplate repetitivo. Records resolvem exatamente esse problema: você declara uma linha, e Java gera todo o resto.

Este artigo passa pela sintaxe básica de records, como customizar validação, comparação com classes comuns e Lombok, depois mostra claramente quando usar records e quando não são apropriados. Ao final, você saberá como aplicar records no lugar certo em uma aplicação Spring Boot real.

2. O que é um record

Um record é um tipo especial de classe, usado para modelar dados imutáveis. Declarar um record é equivalente a definir os seus “componentes”:

public record Point(int x, int y) {
}

Apenas com essa linha, o compilador gera automaticamente:

  • Constructor que recebe x, y.
  • Métodos acessadores x() e y() (sem o prefixo get).
  • equals(), hashCode() baseados em todos os componentes.
  • toString() no formato Point[x=1, y=2].
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);

System.out.println(p1);
System.out.println(p1.equals(p2));
System.out.println(p1.x());
Point[x=1, y=2]
true
1

Nota: Records são automaticamente final — não podem ser estendidas, e seus campos também são final, não podem ser reatribuídos após a inicialização.

3. Componentes de um record

3.1. Constructor e validação

Records possuem um constructor canônico (canonical constructor) gerado automaticamente, mas você pode sobrescrevê-lo para adicionar validação. Isso é chamado de compact constructor — não precisa redeclarar os parâmetros:

public record Order(String id, BigDecimal amount) {
    public Order {
        if (amount == null || amount.signum() < 0) {
            throw new IllegalArgumentException("amount deve ser >= 0");
        }
    }
}

A linha public Order { ... } não tem lista de parâmetros — ela compartilha os parâmetros do record. A validação executa antes dos campos serem atribuídos, então você bloqueia dados inválidos já na criação do objeto.

new Order("O1", new BigDecimal("-10"));
Exception in thread "main" java.lang.IllegalArgumentException: amount deve ser >= 0

3.2. Adicionar métodos customizados

Records ainda podem conter métodos, campos estáticos e métodos estáticos como classes comuns:

public record Money(String currency, BigDecimal value) {

    public Money add(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalStateException("Moeda diferente");
        }
        return new Money(currency, value.add(other.value));
    }

    public static Money zero(String currency) {
        return new Money(currency, BigDecimal.ZERO);
    }
}

Como value é final, o método add() não modifica o objeto atual — ele retorna um novo Money. Esse é exatamente o espírito de imutabilidade que records objetivam.

3.3. Implementar interfaces

Records podem implementar interfaces (mas não podem estender outras classes):

public interface Shape {
    double area();
}

public record Rectangle(double width, double height) implements Shape {
    @Override
    public double area() {
        return width * height;
    }
}

4. Records no Spring Boot: DTO e request/response

O caso de uso mais comum de records é DTO — dados trocados por uma API REST.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
public record CustomerRequest(
        @NotBlank String name,
        @Email String email
) {
}
@RestController
@RequestMapping("/customers")
public class CustomerController {

    @PostMapping
    public ResponseEntity<CustomerResponse> create(@Valid @RequestBody CustomerRequest request) {
        Customer saved = new Customer(1L, request.name(), request.email());
        return ResponseEntity.ok(new CustomerResponse(saved.id(), saved.name()));
    }
}

public record CustomerResponse(Long id, String name) {
}

Jackson (pré-configurado pelo Spring Boot) desserializa JSON para records através do constructor canônico, sem precisar de setters. Anotações do Bean Validation (@NotBlank, @Email) colocadas diretamente nos parâmetros do record funcionam normalmente porque Spring reconhece essas anotações no constructor.

POST /customers
{"name": "", "email": "a@b.com"}

HTTP 400 Bad Request

Nota: Se um record for usado como JPA entity, você terá problemas — JPA precisa de um constructor sem argumentos e fields mutáveis para lazy-loading funcionar. Records não suportam isso, então não use records como @Entity.

5. Records versus classes comuns

Critério Classe comum Record
Boilerplate Escrever getters, equals, hashCode, toString Gerado automaticamente
Mutável Sim Não (campos sempre final)
Herança Pode estender outras classes Não pode estender (implicitamente final)
Indicado para Entities, classes com estado mutável DTO, value object, dados imutáveis

Comparação concreta através de um exemplo com o mesmo value object FullName:

// Forma antiga
public final class FullNameOld {
    private final String first;
    private final String last;

    public FullNameOld(String first, String last) {
        this.first = first;
        this.last = last;
    }

    public String getFirst() { return first; }
    public String getLast() { return last; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof FullNameOld other)) return false;
        return Objects.equals(first, other.first) && Objects.equals(last, other.last);
    }

    @Override
    public int hashCode() {
        return Objects.hash(first, last);
    }

    @Override
    public String toString() {
        return "FullNameOld[first=" + first + ", last=" + last + "]";
    }
}
// Com record
public record FullName(String first, String last) {
}

Essas duas classes têm o mesmo comportamento, mas record é muito mais conciso e sem risco de erros em equals/hashCode (erros clássicos ao codificar manualmente).

6. Records versus Lombok @Value

Muitos times usavam Lombok @Value para reduzir boilerplate antes de records existirem. Essas duas abordagens resolvem problemas similares, mas são fundamentalmente diferentes:

// Lombok
@Value
public class ProductDto {
    String sku;
    BigDecimal price;
}
// Record
public record ProductDto(String sku, BigDecimal price) {
}

Diferenças principais:

  • Records são uma funcionalidade nativa do Java, não requerem dependências extras nem processadores de anotações.
  • Records geram acessadores no formato sku() (sem get), enquanto Lombok gera getSku().
  • Records não suportam herança nem adicionam campos facilmente através de builder pattern como Lombok (@Builder ainda é mais popular para objetos com muitos campos opcionais).

Nota: Se seu projeto já usa Lombok extensivamente e precisa de @Builder para DTOs com muitos campos, você pode manter Lombok para esse caso. Records são mais adequados quando o DTO é simples, tem poucos campos e não precisa de builder.

7. Quando usar records

Use records quando seus dados:

  • São imutáveis por natureza: coordenadas, ranges de valores monetários, ranges de tempo, resultados de cálculos.
  • DTOs trocados entre camadas: request/response da API, dados retornados de queries, mensagens enviadas por filas.
  • Value objects no domain model: Money, EmailAddress, Coordinates — coisas comparadas por valor, não por identidade.
  • Resultados intermediários na lógica: agrupa múltiplos valores retornados de um método sem precisar criar uma classe separada complexa.
public record ValidationResult(boolean valid, List<String> errors) {
    public static ValidationResult ok() {
        return new ValidationResult(true, List.of());
    }
}

8. Quando não usar records

8.1. JPA entity

Como mencionado na seção 4, JPA precisa que entities sejam mutáveis, tenham um constructor sem argumentos e suportem proxies para lazy loading. Records não satisfazem esses requisitos.

// NÃO faça isso
@Entity
public record Customer(@Id Long id, String name) {
}

Hibernate encontrará erros ou comportamentos inesperados ao tentar criar um proxy para um record. Use classes comuns (ou Lombok @Entity + @Getter/@Setter) para entities.

8.2. Objetos que precisam alterar state continuamente

Se seu objeto tem seu estado alterado muitas vezes durante seu ciclo de vida (por exemplo, ShoppingCart adicionando/removendo itens continuamente), forçá-lo a ser um record o obrigaria a criar um novo objeto a cada mudança — ineficiente e não natural.

// Não é sensato: criar um novo cart a cada vez que adiciona um item
public record ShoppingCart(List<Item> items) {
    public ShoppingCart addItem(Item item) {
        List<Item> newItems = new ArrayList<>(items);
        newItems.add(item);
        return new ShoppingCart(newItems);
    }
}

Neste caso, uma classe mutável comum com List<Item> e um método addItem() que modifica diretamente seria mais natural e eficiente.

8.3. Necessidade de herança

Records são implicitamente final e não podem estender outras classes (apenas implementar interfaces). Se seu domain model precisa de uma hierarquia de classes (Employee → Manager, Developer), records não são apropriados — use classes comuns ou uma interface/classe sealed combinada com records para cada ramo folha.

public sealed interface Shape permits Circle, Square {}

public record Circle(double radius) implements Shape {}
public record Square(double side) implements Shape {}

Essa é a forma apropriada de usar records em hierarquias: records para os ramos folhas imutáveis, sealed interface gerenciando o conjunto de tipos filhos.

8.4. Necessidade de serialização complexa

Records podem implementar Serializable, mas se você precisar de serialização customizada complexa (versionamento, backward compatibility por anos), uma classe comum com controle mais granular é ainda mais segura.

9. Testando records

Como equals()/hashCode() são gerados automaticamente baseados nos componentes, testar é muito simples com AssertJ:

class MoneyTest {

    @Test
    void shouldBeEqualWhenSameValues() {
        Money a = new Money("VND", new BigDecimal("1000"));
        Money b = new Money("VND", new BigDecimal("1000"));

        assertThat(a).isEqualTo(b);
        assertThat(a.hashCode()).isEqualTo(b.hashCode());
    }

    @Test
    void shouldThrowWhenAddingDifferentCurrency() {
        Money vnd = new Money("VND", BigDecimal.TEN);
        Money usd = new Money("USD", BigDecimal.ONE);

        assertThatThrownBy(() -> vnd.add(usd))
                .isInstanceOf(IllegalStateException.class)
                .hasMessage("Moeda diferente");
    }
}

assertThat(a).isEqualTo(b) valida o comportamento correto de equals() gerado automaticamente — dois records com valores de componentes iguais devem ser iguais, mesmo sendo instâncias diferentes em memória.

10. Conclusão

Records são a melhor ferramenta que Java fornece para dados imutáveis: menos código, menos bugs, intenção clara. Use-os para DTOs, value objects, resultados intermediários. Não os use para JPA entities, objetos com state que muda continuamente, ou quando você precisa de hierarquias de classes complexas através de herança. Escolha a ferramenta certa para o trabalho certo — records não substituem completamente classes comuns, eles fornecem uma opção mais concisa para um conjunto específico de problemas.

javarecordsjava-21spring-bootboas-práticas