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() e y() (sin prefijo get).
  • equals(), hashCode() basados en todos los componentes.
  • toString() con 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: Los records son implícitamente final — no puedes extender un record, y sus campos también son final, 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() (sin get), mientras que Lombok genera getSku().
  • Los records no soportan herencia ni agregar campos adicionales mediante el patrón builder tan fácilmente como Lombok (@Builder sigue siendo más popular para objetos con muchos campos opcionales).

Nota: Si tu proyecto ya usa Lombok extensamente y necesitas @Builder para 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.

javarecordsjava-21spring-bootbest-practices