Java Records: cuándo usarlos y cuándo evitarlos
1. Descripción general
Antes de Java 14, si querías tener una clase que solo contuviera datos inmutables, tenías que escribir manualmente el constructor, getters, equals(), hashCode(), toString() — todo boilerplate repetitivo. Record soluciona exactamente este problema: declaras una línea y Java genera todo automáticamente.
Este artículo recorre la sintaxis básica de los records, cómo personalizar la validación, los compara con clases normales y Lombok, e indica claramente cuándo usar records y cuándo no son apropiados. Después de leerlo, sabrás cómo aplicar records correctamente en una aplicación Spring Boot real.
2. ¿Qué es un record?
Un record es un tipo especial de clase, utilizado para modelar datos inmutables. Declarar un record equivale a definir los “componentes” del mismo:
public record Point(int x, int y) {
}
Con solo esta línea, el compilador genera automáticamente:
- Constructor que acepta
x,y. - Métodos de acceso
x()ey()(sin prefijoget). equals(),hashCode()basados en todos los componentes.toString()con 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: Los records son implícitamente
final— no puedes extender un record, y sus campos también sonfinal, no pueden reasignarse después de la inicialización.
3. Componentes de un record
3.1. Constructor y validación
Los records tienen un constructor principal (canonical constructor) generado automáticamente, pero puedes reescribirlo para agregar validación. Esto se llama constructor compacto — no necesitas declarar nuevamente los parámetros:
public record Order(String id, BigDecimal amount) {
public Order {
if (amount == null || amount.signum() < 0) {
throw new IllegalArgumentException("amount debe ser >= 0");
}
}
}
La línea public Order { ... } no tiene lista de parámetros — reutiliza los parámetros del record. La validación se ejecuta antes de asignar los campos, por lo que rechazas datos inválidos en el momento de crear el objeto.
new Order("O1", new BigDecimal("-10"));
Exception in thread "main" java.lang.IllegalArgumentException: amount debe ser >= 0
3.2. Añadir métodos personalizados
Los records aún pueden contener métodos, campos estáticos y métodos estáticos como cualquier clase:
public record Money(String currency, BigDecimal value) {
public Money add(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalStateException("Divisas diferentes");
}
return new Money(currency, value.add(other.value));
}
public static Money zero(String currency) {
return new Money(currency, BigDecimal.ZERO);
}
}
Como value es final, el método add() no modifica el objeto actual — devuelve un nuevo Money. Este es el espíritu de inmutabilidad que los records promueven.
3.3. Implementar interfaces
Los records pueden implementar interfaces (pero no pueden extender otras clases):
public interface Shape {
double area();
}
public record Rectangle(double width, double height) implements Shape {
@Override
public double area() {
return width * height;
}
}
4. Records en Spring Boot: DTO y request/response
El caso más común de uso de records es como DTOs — datos transferidos dentro y fuera a través de las APIs 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 (preconfigurado en Spring Boot) deserializa JSON a records mediante el constructor canónico, sin necesidad de setters. Las anotaciones de Bean Validation (@NotBlank, @Email) colocadas directamente en los parámetros del record funcionan normalmente porque Spring reconoce automáticamente estas anotaciones en el constructor.
POST /customers
{"name": "", "email": "a@b.com"}
HTTP 400 Bad Request
Nota: Si usas un record como entidad JPA, encontrarás problemas — JPA necesita un constructor sin argumentos y campos mutables para que el lazy-loading funcione. Los records no lo soportan, así que no uses records como
@Entity.
5. Records vs clases normales
| Aspecto | Clase normal | Record |
|---|---|---|
| Boilerplate | Escribes getters, equals, hashCode, toString |
Se generan automáticamente |
| Mutabilidad | Posible | No (campos siempre final) |
| Herencia | Puede extender otras clases | No puede extender (implícitamente final) |
| Mejor para | Entidades, clases con estado cambiante | DTOs, value objects, datos inmutables |
Comparación específica con el mismo value object FullName:
// Forma antigua
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 + "]";
}
}
// Con record
public record FullName(String first, String last) {
}
Ambas clases se comportan de forma equivalente, pero el record es mucho más conciso y no tienes riesgo de escribir incorrectamente equals/hashCode (error clásico cuando lo codeas manualmente).
6. Records vs Lombok @Value
Muchos equipos usaban Lombok @Value para reducir boilerplate antes de que existieran los records. Ambas formas resuelven problemas similares, pero son fundamentalmente diferentes:
// Lombok
@Value
public class ProductDto {
String sku;
BigDecimal price;
}
// Record
public record ProductDto(String sku, BigDecimal price) {
}
Las diferencias principales:
- Los records son una característica nativa de Java, sin dependencias externas ni procesador de anotaciones.
- Los records generan métodos de acceso como
sku()(singet), mientras que Lombok generagetSku(). - Los records no soportan herencia ni agregar campos adicionales mediante el patrón builder tan fácilmente como Lombok (
@Buildersigue siendo más popular para objetos con muchos campos opcionales).
Nota: Si tu proyecto ya usa Lombok extensamente y necesitas
@Builderpara DTOs con muchos campos, puedes mantener Lombok para esos casos. Los records encajan mejor cuando el DTO es simple, con pocos campos, y sin necesidad de builder.
7. Cuándo usar records
Usa records cuando tus datos:
- Son inmutables por naturaleza: coordenadas, rangos de dinero, intervalos de tiempo, resultados calculados.
- Son DTOs transferidos entre capas: request/response de una API, datos retornados de una consulta, mensajes enviados por una cola.
- Son value objects en el modelo de dominio:
Money,EmailAddress,Coordinates— cosas que se comparan por valor, no por identidad. - Son resultados temporales en la lógica de procesamiento: agrupar varios valores retornados por un método sin necesidad de crear una clase separada compleja.
public record ValidationResult(boolean valid, List<String> errors) {
public static ValidationResult ok() {
return new ValidationResult(true, List.of());
}
}
8. Cuándo NO usar records
8.1. Entidades JPA
Como se mencionó en la sección 4, JPA requiere entidades mutables, con constructor sin argumentos, y que soporten proxy para lazy-loading. Los records no cumplen esto.
// NO hagas esto
@Entity
public record Customer(@Id Long id, String name) {
}
Hibernate encontrará errores o comportamientos inesperados al intentar crear proxies para un record. Usa una clase normal (o Lombok @Entity + @Getter/@Setter) para las entidades.
8.2. Objetos que necesitan cambiar de estado continuamente
Si tu objeto cambia de estado muchas veces durante su ciclo de vida (por ejemplo, ShoppingCart agregando/eliminando artículos constantemente), forzarlo a ser un record te obliga a crear un nuevo objeto cada vez que cambia — es ineficiente e incómodo.
// No es práctico: crear un carrito nuevo cada vez que se agrega un artículo
public record ShoppingCart(List<Item> items) {
public ShoppingCart addItem(Item item) {
List<Item> newItems = new ArrayList<>(items);
newItems.add(item);
return new ShoppingCart(newItems);
}
}
Para este caso, una clase normal mutable con List<Item> y un método addItem() que modifica directamente sería mucho más natural y eficiente.
8.3. Necesitas herencia
Los records son implícitamente final y no pueden extender otras clases (solo implementar interfaces). Si tu modelo de dominio necesita jerarquía de clases (Employee → Manager, Developer), los records no son apropiados — usa clases normales o una combinación de interfaz/clase sealed con records para cada rama terminal.
public sealed interface Shape permits Circle, Square {}
public record Circle(double radius) implements Shape {}
public record Square(double side) implements Shape {}
Esta es una forma apropiada de usar records en una jerarquía: records como ramas terminales inmutables, sealed interface gestionando el conjunto de tipos.
8.4. Necesitas serialización compatible y compleja
Los records pueden implementar Serializable, pero si necesitas serialización personalizada compleja (versionado, compatibilidad hacia atrás durante años), una clase normal con control más detallado es más segura.
9. Testing de records
Dado que equals()/hashCode() se generan automáticamente basados en componentes, el testing es muy simple con 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("Divisas diferentes");
}
}
assertThat(a).isEqualTo(b) verifica el comportamiento correcto del equals() generado automáticamente — dos records con los mismos valores de componentes deben ser iguales, aunque sean instancias diferentes en memoria.
10. Conclusión
Los records son la mejor herramienta que Java proporciona para datos inmutables: menos código, menos errores, intención clara. Úsalos para DTOs, value objects, resultados de retorno temporales. No los uses para entidades JPA, objetos con cambios de estado continuos, o cuando necesites una jerarquía de clases compleja mediante herencia. Elige la herramienta correcta para el trabajo correcto — los records no reemplazan completamente las clases normales, sino que ofrecen una opción más concisa para un grupo específico de problemas.