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()ey()(sem o prefixoget). equals(),hashCode()baseados em todos os componentes.toString()no formatoPoint[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ãofinal, 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()(semget), enquanto Lombok geragetSku(). - Records não suportam herança nem adicionam campos facilmente através de builder pattern como Lombok (
@Builderainda é mais popular para objetos com muitos campos opcionais).
Nota: Se seu projeto já usa Lombok extensivamente e precisa de
@Builderpara 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.