Un logotipo con el texto blog.marcnuri.com
English
Inicio»Desarrollo Backend»Records en Java: guía completa con record patterns, JPA y Jackson

Entradas Recientes

  • ¡Fabric8 Kubernetes Client 8.0 está disponible!
  • ¡Fabric8 Kubernetes Client 7.9 está disponible!
  • ¡Eclipse JKube 1.20 está disponible!
  • Project, Agent, Person: El Eje que Falta en Herramientas de Programación con IA
  • ¡Fabric8 Kubernetes Client 7.8 está disponible!

Categorías

  • Antiguo
  • Cloud Native
  • Desarrollo Backend
  • Desarrollo Frontend
  • Herramientas
  • Ingeniería de calidad
  • Inteligencia Artificial
  • JavaScript
  • Operaciones
  • Personal
  • Proyectos personales
  • Reflexiones sobre Ingeniería

Archivos

  • septiembre 2026
  • julio 2026
  • junio 2026
  • mayo 2026
  • abril 2026
  • marzo 2026
  • febrero 2026
  • enero 2026
  • diciembre 2025
  • octubre 2025
  • septiembre 2025
  • julio 2025
  • mayo 2025
  • abril 2025
  • marzo 2025
  • febrero 2025
  • enero 2025
  • diciembre 2024
  • noviembre 2024
  • agosto 2024
  • junio 2024
  • mayo 2024
  • abril 2024
  • marzo 2024
  • febrero 2024
  • enero 2024
  • diciembre 2023
  • noviembre 2023
  • octubre 2023
  • septiembre 2023
  • agosto 2023
  • julio 2023
  • junio 2023
  • mayo 2023
  • abril 2023
  • marzo 2023
  • febrero 2023
  • enero 2023
  • diciembre 2022
  • noviembre 2022
  • octubre 2022
  • septiembre 2022
  • agosto 2022
  • julio 2022
  • junio 2022
  • mayo 2022
  • marzo 2022
  • febrero 2022
  • enero 2022
  • diciembre 2021
  • noviembre 2021
  • octubre 2021
  • septiembre 2021
  • agosto 2021
  • julio 2021
  • abril 2021
  • enero 2021
  • diciembre 2020
  • octubre 2020
  • septiembre 2020
  • agosto 2020
  • junio 2020
  • mayo 2020
  • marzo 2020
  • febrero 2020
  • enero 2020
  • noviembre 2019
  • septiembre 2019
  • julio 2019
  • diciembre 2018
  • agosto 2018
  • julio 2018
  • junio 2018
  • mayo 2018
  • marzo 2018
  • febrero 2018
  • noviembre 2017
  • octubre 2017
  • agosto 2017
  • julio 2017
  • enero 2017
  • julio 2016
  • mayo 2016
  • enero 2016
  • diciembre 2015
  • noviembre 2015
  • diciembre 2014
  • noviembre 2014
  • octubre 2014
  • marzo 2014
  • agosto 2012
  • febrero 2011
  • enero 2010
  • junio 2008
  • mayo 2008
  • abril 2008
  • enero 2008
  • junio 2007
  • mayo 2007
  • abril 2007
  • marzo 2007

Records en Java: guía completa con record patterns, JPA y Jackson

2021-04-10 en Desarrollo Backend etiquetado Java / Java 17 / Java 21 por Marc Nuri | Última actualización: 2026-11-07
English version

¿Qué es un record en Java?

⁠

Un record en Java es una clase que declara sus datos en la cabecera y recibe del compilador su constructor, sus accessors (métodos de acceso), equals, hashCode y toString.

Point.java
public record Point(int x, int y) {}
Uso de un record
var point = new Point(1, 2);
System.out.println(point);
System.out.println(point.x());
Salida
Point[x=1, y=2]
1

Los records son una característica del lenguaje, definitiva desde Java 16. La JEP que los hizo definitivos los llama "transparent carriers for immutable data", es decir, portadores transparentes de datos inmutables. Transparente significa que el estado de un record es su API: sus accessors exponen todo lo que contiene, y con ellos puedes reconstruir un record igual. Debemos matizar su inmutabilidad: son superficialmente inmutables, porque sus campos son final, pero los objetos a los que apuntan pueden cambiar.

Ese matiz está detrás del bug más habitual con los records, y es solo uno de varios de los que la sintaxis no te avisa. Por eso, en esta guía dedico más tiempo a las trampas que a la sintaxis general de los records. En ella te explico el lenguaje hasta el pattern matching, y también qué ocurre después, cuando un record se encuentra con las herramientas que usas a diario: Jackson, Hibernate, Spring Data, Lombok y Kotlin.

Nota

Todas las salidas de consola de esta guía proceden de una ejecución real en Temurin JDK 25.0.5, la última LTS a fecha de noviembre de 2026. Las versiones de las librerías son Jackson 2.22.4 y 3.2.4, Hibernate ORM 7.4.15, Hibernate Validator 9.1.6, Spring Boot y Spring Data JPA 4.1.2, Lombok 1.18.48 y Kotlin 2.4.21. Las pruebas de Spring Data usan las versiones que gestiona Spring Boot, entre ellas Hibernate ORM 7.4.13. Para cada característica indico la versión de Java que necesita.

¿Por qué usar records?

⁠

Antes de los records, transportar dos enteros como es debido requería una clase como esta.

Point.java, sin records
public final class Point {
  private final int x;
  private final int y;

  public Point(int x, int y) {
    this.x = x;
    this.y = y;
  }

  public int x() {
    return x;
  }

  public int y() {
    return y;
  }

  @Override
  public boolean equals(Object other) {
    if (!(other instanceof Point)) {
      return false;
    }
    Point that = (Point) other;
    return x == that.x && y == that.y;
  }

  @Override
  public int hashCode() {
    return Objects.hash(x, y);
  }

  @Override
  public String toString() {
    return "Point[x=" + x + ", y=" + y + "]";
  }
}

El problema de esa clase no es solo lo que hay que escribir. Cada una de sus líneas es un sitio donde olvidarte de un campo cuando añades uno nuevo, y quien la lee tiene que recorrerla entera para confirmar que no hace nada especial.

He comprobado sus consecuencias en un proyecto real. Eclipse JKube tenía cientos de métodos de builder escritos a mano, y nadie cuida el boilerplate. Algunos builders se quedaron atrás respecto a sus clases, y otros tenían erratas que asignaban un argumento al campo equivocado. Una clase llegó a tener un campo escrito exludes, con su correspondiente getExludes(). Por ese motivo, migramos esas clases a Lombok en 2020, a pesar de que yo siempre había sido detractor de esa librería.

Un record elimina gran parte de ese código en el propio lenguaje: sus accessors, equals, hashCode y toString no pueden quedarse atrás respecto a sus campos. La intención cabe en una línea: este tipo son sus datos, y nada más.

Tener menos boilerplate es la ventaja que todo el mundo menciona primero, y es la menor de las dos. La mayor es que el compilador conoce la forma de un record, así que los record patterns pueden volver a descomponerlo. Ninguna clase generada por Lombok o por tu IDE puede hacer eso. Los comparo en Records vs clases vs Lombok vs data classes de Kotlin.

Cronología de los records en Java

⁠

Los records tardaron tres versiones en ser definitivos, y las características construidas sobre ellos tardaron varias más.

JavaLanzamientoJEPQué cambió
14Marzo de 2020JEP 359Records, primera preview
15Septiembre de 2020JEP 384Segunda preview: reglas de acceso del constructor canónico, prohibido asignar campos en un constructor compacto
16Marzo de 2021JEP 395Records definitivos; las clases internas pueden declarar records miembro
17Septiembre de 2021JEP 409Clases sealed definitivas
19Septiembre de 2022JEP 405Record patterns, primera preview
20Marzo de 2023JEP 432Record patterns, segunda preview
21Septiembre de 2023JEP 440, JEP 441Record patterns y pattern matching para switch definitivos
22Marzo de 2024JEP 456Variables y patrones sin nombre (_) definitivos
25Septiembre de 2025JEP 513Cuerpos de constructor flexibles definitivos

Java 17 es la primera LTS con records y clases sealed, y Java 21 la primera con record patterns.

En cuanto a lo que viene, a fecha de noviembre de 2026 conviene conocer dos JEPs. JEP 401, Value Objects está integrada en JDK 28, previsto para marzo de 2027, como característica en preview, y considera que los records son "often good candidates to be value classes" (a menudo, buenos candidatos a value classes). JEP 468, Derived Record Creation es la propuesta de las expresiones with, y sigue siendo una Candidate sin ninguna versión asignada. Vuelvo a ella en Builders y withers.

Cómo crear un record

⁠

La declaración de un record tiene un nombre, una lista de componentes entre paréntesis y un cuerpo, que a menudo está vacío. Cada componente se convierte en un campo privado final y en un accessor público, de modo que esa única línea genera una clase completa. Para verlo, compila el record y pregúntale a javap qué hay dentro.

bash
javac Point.java
javap -p Point
Salida
Compiled from "Point.java"
public final class Point extends java.lang.Record {
  private final int x;
  private final int y;
  public Point(int, int);
  public final java.lang.String toString();
  public final int hashCode();
  public final boolean equals(java.lang.Object);
  public int x();
  public int y();
}

Cuatro cosas de esa salida explican casi todo el comportamiento de los records:

  • La clase es final, así que nada puede extenderla.
  • Extiende java.lang.Record, así que no puede extender nada más.
  • Sus campos son private final, así que no se pueden reasignar después de la construcción.
  • El constructor recibe todos los componentes, en el orden en que se declaran: se llama constructor canónico.
Anatomía de un record en Java: la declaración en una línea del record Point, con flechas hacia lo que javac genera a partir de ella: final class Point, extends java.lang.Record, los campos private final x e y, el constructor Point(int, int), los accessors x() e y(), y equals, hashCode y toString
Anatomía de un record en Java: la declaración en una línea del record Point, con flechas hacia lo que javac genera a partir de ella: final class Point, extends java.lang.Record, los campos private final x e y, el constructor Point(int, int), los accessors x() e y(), y equals, hashCode y toString

Los accessors se llaman como el componente: x(), no getX(). Es algo deliberado, y es lo primero que se rompe cuando un record sustituye a un JavaBean, porque cualquier herramienta que busque el prefijo get no lo encontrará. Hablo más acerca de esto en Cómo migrar una clase a un record.

Constructor compacto, validación y copias defensivas

⁠

El compilador te escribe un constructor, pero a menudo querrás cambiar lo que hace. Aquí es donde un record deja de ser una sola línea, y donde se escriben o se evitan la mayoría de sus bugs.

El constructor canónico

⁠

Puedes escribir el constructor canónico a mano, con los mismos nombres y tipos de parámetros que los componentes. Tiene que asignar todos los campos, y debe ser al menos tan accesible como el propio record.

Point.java
public record Point(int x, int y) {
  public Point(int x, int y) {
    this.x = x;
    this.y = y;
  }
}

Ese constructor no hace nada que no haga el generado, así que rara vez lo escribirás. El compilador rechaza una versión menos accesible, por ejemplo si quitas public de ese constructor:

Salida
Point.java:2: error: invalid canonical constructor in record Point
  Point(int x, int y) {
  ^
  (attempting to assign stronger access privileges; was public)
1 error

El constructor compacto

⁠

El constructor compacto es la forma que usarás en la práctica. No tiene lista de parámetros: los parámetros son implícitos, y los campos se asignan solos cuando termina su cuerpo. Sirve para comprobar o normalizar los argumentos, y para normalizarlos se reasigna el parámetro, no el campo.

Name.java
public record Name(String value) {
  public Name {
    value = value.strip();
  }
}

new Name(" Ada Lovelace ").value() devuelve Ada Lovelace. La asignación al campo ocurre después del cuerpo, así que escribir this.value dentro de él es un error de compilación:

Salida
Name.java:3: error: cannot assign a value to final variable value
    this.value = value.strip();
        ^
1 error

Cómo validar un record

⁠

El constructor compacto también es el sitio natural para validar, porque si el constructor lanza una excepción, la instancia inválida nunca llega a existir.

Range.java
public record Range(int start, int end) {
  public Range {
    if (start > end) {
      throw new IllegalArgumentException("start > end: " + start + " > " + end);
    }
  }
}

new Range(5, 1) lanza IllegalArgumentException: start > end: 5 > 1.

Bean Validation funciona de otra manera, y la diferencia suele sorprender. Las anotaciones se ponen en los componentes, pero no hacen nada hasta que se ejecuta un Validator, así que construir el record siempre funciona.

User.java
public record User(@NotBlank String name, @Email String email) {}
Validación de un User
var user = new User(" ", "not-an-email");
System.out.println(user);
try (var factory = Validation.buildDefaultValidatorFactory()) {
  factory.getValidator().validate(user).stream()
    .map(v -> v.getPropertyPath() + ": " + v.getMessage())
    .sorted()
    .forEach(System.out::println);
}
Salida
User[name= , email=not-an-email]
email: must be a well-formed email address
name: must not be blank

El usuario inválido existe, y solo se informa de él porque algo lo ha pedido. En una aplicación con Spring MVC, ese algo es @Valid en un parámetro del controlador.

Advertencia

Una constraint personalizada en un componente de un record necesita ElementType.FIELD en su @Target. Sin él, Validator.validate(), que es lo que @Valid dispara, ignora la constraint sin decir nada. He ejecutado la misma constraint con tres targets en Hibernate Validator 9.1.6: FIELD informó de la violación, mientras que METHOD y PARAMETER por sí solos no informaron de ninguna y tampoco dieron error.

Usa el constructor compacto para lo que debe cumplirse en toda instancia, y Bean Validation para la entrada cuyos errores quieres comunicar, con mensajes, campo por campo.

Copias defensivas

⁠

Esta es la sección más importante de esta guía. Los campos de un record son final, pero final solo protege la referencia. Si un componente es mutable, el record lo comparte con todo el que tenga una referencia a él.

Team.java
public record Team(String name, List<String> members) {}
Un record modificado desde fuera
var members = new ArrayList<>(List.of("Alice", "Bob"));
var team = new Team("core", members);
members.add("Mallory");
team.members().add("Eve");
System.out.println(team);
Salida
Team[name=core, members=[Alice, Bob, Mallory, Eve]]

Los dos cambios llegaron al record: uno a través de la lista que conservaba quien lo creó, y el otro a través del accessor. La solución es hacer una copia en el constructor compacto.

Team.java
public record Team(String name, List<String> members) {
  public Team {
    members = List.copyOf(members);
  }
}

Con esta versión, members.add("Mallory") ya no llega al record, y team.members().add("Eve") lanza UnsupportedOperationException. El equipo sigue siendo Team[name=core, members=[Alice, Bob]].

List.copyOf devuelve una lista no modificable, así que una sola copia cubre ambas direcciones, y es la opción correcta por defecto. Eso sí, copia la lista, no sus elementos: si estos son mutables, el record solo es tan inmutable como ellos.

El inconveniente es null, que List.copyOf rechaza con un NullPointerException, tanto para un elemento null como para una lista null. Una lista que pueda contener un null necesita Collections.unmodifiableList(new ArrayList<>(members)), y una lista opcional necesita un valor por defecto:

Team.java
public record Team(String name, List<String> members) {
  public Team {
    members = members == null ? List.of() : List.copyOf(members);
  }
}

Precaución

Collections.unmodifiableList(members) por sí solo no es una copia. Es una vista de solo lectura de la lista original, así que el accessor no puede cambiarla, pero quien la pasó sí: un Team construido de esta forma imprimió members=[Alice, Bob, Mallory] después de members.add("Mallory").

Los arrays son aún peores, porque no existe una versión no modificable, así que una copia en el constructor no basta: el accessor también tiene que copiar. Además, un record compara los arrays por referencia, así que dos records con arrays iguales no son iguales.

Scores.java
public record Scores(int[] values) {}
Componentes de tipo array
var scores = new Scores(new int[] {1, 2, 3});
scores.values()[0] = 99;
System.out.println(Arrays.toString(scores.values()));
System.out.println(new Scores(new int[] {1, 2}).equals(new Scores(new int[] {1, 2})));
Salida
[99, 2, 3]
false

Arreglarlo bien exige todo lo que el record debía ahorrarte.

Scores.java
public record Scores(int[] values) {
  public Scores {
    values = values.clone();
  }

  @Override
  public int[] values() {
    return values.clone();
  }

  @Override
  public boolean equals(Object other) {
    return other instanceof Scores that && Arrays.equals(values, that.values);
  }

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

  @Override
  public String toString() {
    return "Scores[values=" + Arrays.toString(values) + "]";
  }
}

Con tantos métodos sobrescritos, prefiero usar un List<Integer> con List.copyOf, o envolver el array en una pequeña clase con su propio comportamiento. Lo mismo vale para cualquier clase mutable, como java.util.Date: elige un tipo inmutable como Instant para el componente en lugar de copiarlo por todas partes.

Métodos factory estáticos y otros constructores

⁠

Un record puede declarar métodos estáticos, así que un factory con nombre es una forma limpia de construirlo a partir de otras entradas.

Money.java
public record Money(BigDecimal amount, Currency currency) {
  public static Money of(String amount, String currency) {
    return new Money(new BigDecimal(amount), Currency.getInstance(currency));
  }

  public static Money zero(String currency) {
    return of("0", currency);
  }
}
Uso de los factories
System.out.println(Money.of("19.99", "EUR"));
System.out.println(Money.zero("USD"));
Salida
Money[amount=19.99, currency=EUR]
Money[amount=0, currency=USD]

Un factory tiene un nombre que explica para qué sirve, puede parsear o convertir los datos antes de llamar al constructor, y puede devolver una instancia compartida. Lo prefiero a un constructor adicional siempre que los argumentos necesiten algún trabajo previo. La alternativa es un constructor no canónico, que tiene una regla estricta: debe delegar en otro constructor del record.

Range.java
public record Range(int start, int end) {
  public Range(int end) {
    this(0, end);
  }
}

new Range(10) imprime Range[start=0, end=10].

Cuerpos de constructor flexibles

⁠

Imagina que quieres construir un Interval a partir de un texto como "10..20". Lo lógico sería separar el texto primero y llamar después al constructor canónico, pero hasta Java 25 no se podía: this(...) tenía que ser la primera instrucción del constructor. Todo el trabajo debía ocurrir dentro de los argumentos de esa llamada, lo que normalmente implicaba un helper estático, que aquí se llama dos veces:

Interval.java, antes de Java 25
public record Interval(int start, int end) {
  public Interval(String text) {
    this(parse(text)[0], parse(text)[1]);
  }

  private static int[] parse(String text) {
    String[] bounds = text.split("\\.\\.");
    if (bounds.length != 2) {
      throw new IllegalArgumentException("expected start..end: " + text);
    }
    return new int[] {Integer.parseInt(bounds[0]), Integer.parseInt(bounds[1])};
  }
}

JEP 513, definitiva en Java 25, elimina esa restricción, de modo que el código puede leerse en el orden en que lo explicarías: comprobar la entrada y después llamar a this(...).

Interval.java, en Java 25
public record Interval(int start, int end) {
  public Interval(String text) {
    String[] bounds = text.split("\\.\\.");
    if (bounds.length != 2) {
      throw new IllegalArgumentException("expected start..end: " + text);
    }
    this(Integer.parseInt(bounds[0]), Integer.parseInt(bounds[1]));
  }
}

new Interval("10..20") imprime Interval[start=10, end=20], y new Interval("20") lanza IllegalArgumentException: expected start..end: 20.

Hay límites: el código anterior a this(...) puede preparar los argumentos, pero no puede tocar el record que se está construyendo, así que nada de accessors, métodos de instancia ni asignar campos. Solo un constructor no canónico puede llamar a this(...), así que esto no aporta nada a los constructores compactos y canónicos.

Para este ejemplo yo seguiría escribiendo un método factory estático, ya que Interval.parse("10..20") deja claro que se está parseando un texto. Los constructores flexibles ayudan cuando no puedes prescindir del constructor.

Componentes, métodos y limitaciones

⁠

El cuerpo de un record admite más de lo que podrías esperar. Se permiten métodos de instancia, métodos estáticos, campos estáticos y tipos anidados, y un record puede implementar interfaces.

Point.java
public record Point(int x, int y) {
  public static final Point ORIGIN = new Point(0, 0);

  public double distanceTo(Point other) {
    return Math.hypot(x - other.x, y - other.y);
  }
}

new Point(3, 4).distanceTo(Point.ORIGIN) devuelve 5.0.

Lo que no puede tener es más estado. Cualquier campo de instancia se rechaza, con una pista de lo que debes hacer en su lugar, y los inicializadores de instancia también se rechazan:

Salida
Point.java:2: error: field declaration must be static
  private int z;
              ^
  (consider replacing field with record component)
1 error

Hay algunas reglas más. Un accessor explícito debe ser public, y no puede declarar una cláusula throws. Un record no puede ser abstract, sealed ni non-sealed. Hay ocho nombres prohibidos para un componente, porque chocarían con los métodos de Object: clone, finalize, getClass, hashCode, notify, notifyAll, toString y wait.

¿Son inmutables los records en Java?

⁠

Superficialmente, sí. No puedes reasignar los campos de un record, pero nada impide que un componente mutable cambie, como muestra Copias defensivas. Un record solo es inmutable en profundidad cuando lo son todos sus componentes: primitivos, String, Instant, otros records que también lo sean, o colecciones creadas con List.copyOf con elementos inmutables.

¿Puede un record extender una clase?

⁠

No. La superclase de un record siempre es java.lang.Record, así que la gramática no contempla la cláusula extends, y el compilador se detiene en esa palabra:

Salida
Point.java:3: error: '{' expected
public record Point(int x, int y) extends Base {}
                                 ^
1 error

Tampoco se puede extender un record, porque es final: cannot inherit from final Point. Un record puede implementar tantas interfaces como quieras, y esa es la forma de compartir comportamiento entre records: con métodos default.

¿Puede un record tener un constructor sin argumentos?

⁠

Sí, siempre que delegue en otro constructor, de modo que todos los campos reciban un valor.

Point.java
public record Point(int x, int y) {
  public Point() {
    this(0, 0);
  }
}

new Point() imprime Point[x=0, y=0]. Un constructor sin argumentos que no delega se rechaza: constructor is not canonical, so it must invoke another constructor of class Point.

Eso no ayuda a un framework que necesite un constructor sin argumentos para crear un objeto vacío y asignar sus campos después. Los campos son final, así que no hay nada que pueda asignar más tarde, y por eso un record no puede ser una entidad JPA.

equals, hashCode y toString

⁠

El equals generado compara todos los componentes: como Objects.equals para las referencias, y con el método compare de la clase wrapper para los primitivos. Así, new Point(1, 2).equals(new Point(1, 2)) es true, pero los componentes de coma flotante tienen un comportamiento propio.

Measure.java
public record Measure(double value) {}
Comparación de doubles
System.out.println(new Measure(Double.NaN).equals(new Measure(Double.NaN)));
System.out.println(new Measure(0.0).equals(new Measure(-0.0)));
System.out.println(Double.NaN == Double.NaN);
System.out.println(0.0 == -0.0);
Salida
true
false
false
true

Los dos records responden lo contrario que ==, y es la decisión correcta para equals: un NaN que no fuera igual a sí mismo se perdería para siempre en un HashSet.

La igualdad también compara los componentes BigDecimal con su propio equals, que tiene en cuenta la escala: un Money con 1.50 y otro con 1.5 no son iguales.

Puedes sobrescribir cualquiera de los tres métodos cuando lo necesites, pero la documentación de la API limita lo que puedes dar por hecho sobre los generados. El algoritmo de hashCode no está especificado y puede cambiar entre ejecuciones, así que nunca persistas su valor. El formato de toString también puede cambiar, y la documentación advierte de que no debe parsearse para recuperar valores.

Records avanzados

⁠

Algunas características merecen una mirada más detenida, y varias son más útiles de lo que sugiere su lugar al final de un tutorial.

Los genéricos funcionan igual que en una clase.

Pair.java
public record Pair<A, B>(A first, B second) {}

new Pair<>("answer", 42) imprime Pair[first=answer, second=42].

Las anotaciones en los componentes se propagan a todos los sitios que permite su @Target: el campo, el accessor, el parámetro del constructor y el propio componente del record. Por eso una misma anotación puede servir a varias librerías, y por eso existe la advertencia anterior sobre FIELD. Esto es lo que encuentra la reflexión en un componente con cuatro anotaciones, una por cada target:

Salida
component: [@OnComponent()]
field:     [@OnField()]
accessor:  [@OnMethod()]
parameter: [@OnParameter()]

Hay dos excepciones que debes recordar: una anotación no se propaga a un accessor escrito a mano, ni a un constructor canónico escrito en su forma larga. Con un método name() explícito, la misma ejecución imprimió accessor: [], así que tendrías que repetirla ahí. Un constructor compacto sí conserva las anotaciones de sus parámetros.

Comparable es una interfaz normal, así que un record puede implementarla, y un Comparator estático mantiene el método corto.

Version.java
public record Version(int major, int minor) implements Comparable<Version> {
  private static final Comparator<Version> ORDER =
    Comparator.comparingInt(Version::major).thenComparingInt(Version::minor);

  @Override
  public int compareTo(Version other) {
    return ORDER.compare(this, other);
  }
}
Ordenación de versiones
var versions = new ArrayList<>(List.of(new Version(2, 0), new Version(1, 9), new Version(1, 10)));
Collections.sort(versions);
System.out.println(versions);
Salida
[Version[major=1, minor=9], Version[major=1, minor=10], Version[major=2, minor=0]]

Los records locales y anidados son los más infravalorados de todos. Puedes declarar uno dentro de un método para dar nombre a un resultado intermedio en un stream, en lugar de ir pasando Map.Entry u Object[].

Un record local
int threshold = 6;
record Scored(String name, int score) {}
System.out.println(Stream.of("record", "pattern", "sealed")
  .map(name -> new Scored(name, name.length()))
  .filter(s -> s.score() > threshold)
  .map(Scored::name)
  .toList());
Salida
[pattern]

Los records anidados y locales son implícitamente static, así que un record no puede capturar lo que lo rodea. Usar threshold desde un método de Scored falla con non-static variable threshold cannot be referenced from a static context. La lambda de arriba sí puede usarlo porque no forma parte del record.

La reflexión tiene soporte específico: Point.class.isRecord() devuelve true, y getRecordComponents() lista los componentes en el orden en que se declaran. Para Point, imprimir cada nombre y tipo da [x:int, y:int]. Librerías como Jackson se apoyan en esto para encontrar el constructor canónico sin ninguna anotación.

Serialización de records

⁠

La serialización de Java trata a los records de forma distinta a cualquier otra clase, y más segura. Un record no es serializable por defecto: como cualquier clase, tiene que implementar Serializable. Un record serializable se reconstruye a través de su constructor canónico, así que la validación también se ejecuta al deserializar, y un flujo de bytes no puede producir un record que el constructor rechazaría. Los métodos writeObject y readObject personalizados se ignoran, mientras que writeReplace y readResolve sí se respetan.

Además, no se exige que coincida el serialVersionUID, así que una discrepancia que rompería una clase no afecta a un record. Para verlo todo de una vez, serialicé una clase normal y dos records Temperature, uno de ellos a -300 grados, con serialVersionUID = 1L en ambas clases. Después lo cambié a 2L en las dos, le puse a Temperature el código de abajo y volví a leerlo todo.

Temperature.java
public record Temperature(int celsius) implements Serializable {
  private static final long serialVersionUID = 2L;

  public Temperature {
    if (celsius < -273) {
      throw new IllegalArgumentException("below absolute zero: " + celsius);
    }
  }

  private void readObject(ObjectInputStream in) {
    System.out.println("never printed");
  }
}
Salida
Temperature[celsius=21]
java.io.InvalidObjectException: below absolute zero: -300
java.io.InvalidClassException: Plain; local class incompatible: stream classdesc serialVersionUID = 1, local class serialVersionUID = 2

El primer record sobrevivió a la discrepancia, el segundo no pasó la nueva validación, y la clase normal falló por la discrepancia. readObject nunca se ejecutó. Para las clases que todavía lo necesitan, te explico cómo generar un serialVersionUID en IntelliJ en otro artículo.

Records e interfaces sealed

⁠

Los records no pueden extender nada, pero sí implementar interfaces, y desde Java 17 esa interfaz puede ser sealed. Una interfaz sealed enumera los únicos tipos que pueden implementarla, y un record es el candidato ideal porque es final por definición.

Shape.java
public sealed interface Shape permits Circle, Rectangle {}

record Point(int x, int y) {}

record Circle(Point center, int radius) implements Shape {}

record Rectangle(Point topLeft, Point bottomRight) implements Shape {}

Esta pareja es la forma de modelar en Java "una de estas cosas, cada una con sus propios datos", lo que otros lenguajes llaman sum type o tagged union. Un Shape es un Circle o un Rectangle, y el compilador sabe que no puede ser otra cosa. Por sí solo es poco más que documentación, y se aprovecha de verdad con la característica de la siguiente sección.

Record patterns

⁠

Un record pattern descompone un record: comprueba el tipo y enlaza sus componentes a variables en un solo paso. Los record patterns son definitivos desde Java 21 (JEP 440).

Record patterns con instanceof

⁠

Amplían los patrones de tipo que explico en Pattern Matching para instanceof, que te conviene conocer antes.

Descomposición con instanceof
Object value = new Point(3, 4);
if (value instanceof Point(int x, int y)) {
  System.out.println(x + y);
}
Salida
7

Usar var para un componente también funciona, y es lo que normalmente escribirás. Algunos tutoriales obsoletos también muestran record patterns en un bucle for mejorado: era una característica en preview de Java 20 que se eliminó en Java 21, y no compila en Java 25.

Record patterns en switch

⁠

El verdadero uso de un record pattern está en un switch, donde cada caso descompone un record distinto (JEP 441).

Cálculo de un área
static double area(Shape shape) {
  return switch (shape) {
    case Circle(var center, var radius) -> Math.PI * radius * radius;
    case Rectangle(Point(var x1, var y1), Point(var x2, var y2)) ->
      Math.abs(x2 - x1) * Math.abs(y2 - y1);
  };
}
La llamada
System.out.printf("%.2f%n", area(new Circle(new Point(0, 0), 2)));
System.out.println(area(new Rectangle(new Point(0, 0), new Point(3, 4))));
Salida
12.57
12.0

Record patterns anidados

⁠

El caso Rectangle de arriba ya muestra que los patrones se anidan: Point(var x1, var y1) descompone el componente que Rectangle(...) acaba de extraer. Una protección when añade una condición sobre los valores enlazados, y los casos se prueban de arriba abajo.

Patrones con protecciones
static String describe(Shape shape) {
  return switch (shape) {
    case Circle(Point(var x, var y), var radius) when x == 0 && y == 0 -> "circle at the origin";
    case Circle c -> "circle of radius " + c.radius();
    case Rectangle(var topLeft, var bottomRight) when topLeft.equals(bottomRight) -> "empty rectangle";
    case Rectangle r -> "rectangle";
  };
}
La llamada
System.out.println(describe(new Circle(new Point(0, 0), 2)));
System.out.println(describe(new Circle(new Point(1, 0), 2)));
System.out.println(describe(new Rectangle(new Point(1, 1), new Point(1, 1))));
Salida
circle at the origin
circle of radius 2
empty rectangle

Switch exhaustivo

⁠

Ninguno de los switch anteriores tiene default, y de eso se trata. Shape es sealed, así que el compilador sabe que los casos cubren todos los valores, y lo comprueba por ti. Si añades un nuevo record a la interfaz sealed, todo switch que no lo contemple deja de compilar, con el error the switch expression does not cover all possible input values. Esa comprobación ocurre al recompilar el switch: el código compilado antes lanza MatchException cuando se encuentra con el nuevo record, algo importante si distribuyes una librería. Con un default perderías esa comprobación, así que lo evito en los tipos sealed.

Precaución

null no coincide con ningún record pattern. instanceof devuelve false, y un switch sobre null lanza NullPointerException, salvo que tenga un case null. Un null dentro de un record es fácil de pasar por alto: area(new Rectangle(null, new Point(3, 4))) lanza MatchException, ya que Point(var x1, var y1) no coincide con null y ningún otro caso se aplica, aunque el switch compile como exhaustivo. Falla así cualquier patrón que tenga que comprobar el componente: un record pattern anidado, o un patrón de tipo más estrecho que el tipo del componente. var, o un patrón de tipo que cubra todo el componente, sí coincide con null, de modo que new Circle(null, 2) pasa por area con center a null.

Patrones sin nombre

⁠

Cuando un caso solo necesita parte de un record, _ omite el resto. Esto es de Java 22 (JEP 456), no de Java 21, donde todavía estaba en preview.

Omitir componentes con _
static String side(Shape shape) {
  return switch (shape) {
    case Circle(Point(var x, _), _) when x >= 0 -> "right";
    case Circle _ -> "left";
    case Rectangle _ -> "n/a";
  };
}

En Java 25 funciona como esperas, y en Java 21 falla:

Salida
Main.java:4: error: unnamed variables are a preview feature and are disabled by default.
      case Circle(Point(var x, _), _) when x >= 0 -> "right";
                                ^
  (use --enable-preview to enable unnamed variables)
1 error

_ le indica a quien lee qué componentes importan, y case Circle _ dice que el caso no usa el valor en absoluto.

Records con Jackson

⁠

Jackson encuentra por sí mismo el constructor canónico de un record y los nombres de sus componentes, así que no necesitas @JsonCreator ni un constructor sin argumentos. Los constructores compactos y @JsonProperty en un componente funcionan como esperas.

Employee.java
public record Employee(@JsonProperty("full_name") String fullName, List<String> skills) {
  public Employee {
    skills = List.copyOf(skills);
  }
}

Jackson 2

⁠

Jackson soporta records desde la 2.12, pero usa la 2.18.8, o la 2.21.4 y posteriores. La versión 2.15 cambió la forma de introspeccionar los records, con regresiones, y la 2.18 reescribió la introspección de propiedades, lo que corrigió el tratamiento de las anotaciones en los componentes.

Ida y vuelta con Jackson 2
var mapper = new ObjectMapper();
var employee = new Employee("Ada Lovelace", List.of("math", "engines"));
var json = mapper.writeValueAsString(employee);
System.out.println(json);
var read = mapper.readValue(json, Employee.class);
System.out.println(read);
System.out.println(read.equals(employee));
Salida
{"full_name":"Ada Lovelace","skills":["math","engines"]}
Employee[fullName=Ada Lovelace, skills=[math, engines]]
true

Jackson 3

⁠

El mismo record, sin cambios, da el mismo resultado en Jackson 3, que necesita Java 17 y ha movido sus clases a tools.jackson. @JsonProperty, @JsonIgnore y @JsonCreator siguen en com.fasterxml.jackson.annotation, así que un record que solo use esas no cambia. En el código que lo rodea cambian los imports, y JsonMapper.builder() es la forma habitual de crear el mapper:

Crear el mapper en Jackson 3
var mapper = JsonMapper.builder().build();

Eso sí, ten cuidado con @JsonSerialize, @JsonDeserialize y @JsonNaming: se han movido a tools.jackson.databind.annotation, y si Jackson 2 sigue en el classpath, Jackson 3 ignora las antiguas sin dar ningún error.

Spring Boot 4 usa Jackson 3 por defecto: su mapper autoconfigurado es un tools.jackson.databind.json.JsonMapper. Jackson 2 en sí no está deprecado, pero el soporte de Spring para él sí: Spring Boot 4 ha deprecado su módulo spring-boot-jackson2, y Spring Framework 7 ha marcado sus conversores de Jackson 2 para eliminarlos. Nosotros hicimos la misma migración en Fabric8 Kubernetes Client con la versión 8.0.

Jackson 3 cambia algunos valores por defecto, y para los records destaca uno: SORT_PROPERTIES_ALPHABETICALLY es true, lo que reordena una clase normal, pero un record mantiene el orden de declaración.

Orden de las propiedades en Jackson 3
public record Ordered(String zeta, String alpha) {}

public class Plain {
  public String zeta = "z";
  public String alpha = "a";
}
Salida
{"zeta":"z","alpha":"a"}
{"alpha":"a","zeta":"z"}

Trampas de los records con Jackson

⁠

La que tienes más probabilidades de encontrarte es un método helper que parece un getter. Jackson no limita las propiedades de un record a sus componentes, así que un método getFullName() se convierte en una propiedad adicional.

Person.java
public record Person(String firstName, String lastName) {
  public String getFullName() {
    return firstName + " " + lastName;
  }
}
Salida
{"firstName":"Ada","lastName":"Lovelace","fullName":"Ada Lovelace"}

Jackson 2 y Jackson 3 escriben lo mismo, pero las consecuencias son distintas. Jackson 2 no puede leer su propia salida, porque por defecto falla con las propiedades desconocidas:

Salida
com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized field "fullName" (class Person), not marked as ignorable (2 known properties: "lastName", "firstName")

Jackson 3 ignora por defecto las propiedades desconocidas, así que la ida y vuelta funciona y el campo extra se queda ahí sin que nadie lo note. La solución en ambos es poner @JsonIgnore en el método, o llamarlo fullName(), sin el get. Jackson 3.1 y posteriores también tienen un ajuste global:

Solo los componentes son propiedades
var mapper = JsonMapper.builder()
  .enable(MapperFeature.INFER_RECORD_GETTERS_FROM_COMPONENTS_ONLY)
  .build();

El constructor compacto también se ejecuta en cada lectura, así que el Employee de arriba no puede leer un documento sin skills: List.copyOf recibe un null, y la lectura falla con un ValueInstantiationException en ambas versiones. Si un campo es opcional, usa el valor por defecto de Copias defensivas.

La última trampa es de seguridad, así que mantén Jackson al día con los últimos parches. CVE-2026-59888 afecta específicamente a los records: con un PropertyNamingStrategy, se ignora @JsonIgnore en un componente, de modo que un cliente puede asignar un valor que querías dejar fuera de su alcance. Está corregido en 2.18.8, 2.21.4 y 3.1.4, y las líneas 2.19, 2.20 y 3.0 nunca recibieron la corrección, así que un número de versión superior a 2.18.8 no significa que estés a salvo.

Records con JPA, Hibernate y Spring Data

⁠

Los records no encajan en todos los papeles de una capa de persistencia, pero sí en la mayoría de aquellos en los que se suele dar por hecho que no.

¿Puede un record ser una entidad JPA?

⁠

No. La especificación de Jakarta Persistence lo descarta expresamente: "An enum, record, or interface may not be designated as an entity." (un enum, un record o una interfaz no pueden designarse como entidad). Además exige un constructor sin argumentos, una clase que no sea final y campos que no sean final. Un record puede añadir el constructor, pero nunca lo otro. Una entidad tiene identidad y un ciclo de vida, y cambia de estado con el tiempo, justo lo contrario que un valor.

Sin embargo, Hibernate no te detiene al arrancar, y por eso conviene avisar. Anoté un record con @Entity, y Hibernate 7.4.15 arrancó y persistió una fila con un id asignado sin ningún error. Solo falló al volver a leer esa fila:

Salida
org.hibernate.InstantiationException: No default constructor for entity 'Customer'

Un test que solo guardara pasaría. Con @GeneratedValue lo descubrirías antes, porque el persist falla cuando Hibernate intenta asignar el campo id final.

Records como embeddables

⁠

Un record es un @Embeddable excelente, porque un embeddable es un valor sin identidad propia. Jakarta Persistence 3.2 lo convirtió en estándar ("an embeddable class may be any Java record type", es decir, una clase embeddable puede ser cualquier record de Java), y Hibernate ya lo soportaba como extensión, de la 6.2 a la 6.6. En Hibernate 7 no hace falta un EmbeddableInstantiator ni ninguna otra configuración.

Address.java
@Embeddable
public record Address(String street, String city, @ManyToOne(fetch = FetchType.LAZY) Country country) {}
Customer.java
@Entity
public class Customer {
  @Id @GeneratedValue private Long id;
  private String name;
  @Embedded private Address address;

  protected Customer() {}

  public Customer(String name, Address address) {
    this.name = name;
    this.address = address;
  }

  public Address getAddress() {
    return address;
  }
}

Muchos artículos antiguos dicen que un record embeddable no puede contener una asociación, pero ese ejemplo la contiene, y sigue siendo lazy. Después de cargar un Customer, Hibernate.isInitialized(customer.getAddress().country()) devolvió false, y al leer después el nombre del país se cargó. Un record embeddable también funciona como parámetro de una consulta, como en where c.address = :address.

Records como claves compuestas

⁠

Una clave compuesta también es un valor, y necesita los equals y hashCode correctos que un record te da gratis. Tanto @IdClass como @EmbeddedId funcionan, y desde Jakarta Persistence 3.2 un record usado como clase de clave no necesita un constructor sin argumentos.

@IdClass
public record OrderLineId(Long orderId, int position) {}

@Entity
@IdClass(OrderLineId.class)
public class OrderLine {
  @Id private Long orderId;
  @Id private int position;
  private String product;

  // Constructors and getters omitted
}
@EmbeddedId
@Embeddable
public record ShipmentId(String warehouse, Long number) {}

@Entity
public class Shipment {
  @EmbeddedId private ShipmentId id;
  private String status;

  // Constructors and getters omitted
}

em.find(OrderLine.class, new OrderLineId(1L, 1)) y em.find(Shipment.class, new ShipmentId("ES", 7L)) cargan su entidad.

Advertencia

No pongas relaciones dentro de un record usado como @EmbeddedId. La especificación prohíbe las relaciones en un embeddable usado como id embebido o como clave de un map, así que eso no es portable, aunque Hibernate lo acepte.

Proyecciones con records

⁠

Una proyección es el mejor papel de un record en una capa de persistencia: una vista de solo lectura de una consulta, sin cargar entidades. La forma portable es una constructor expression (expresión de constructor), con el nombre completamente cualificado de la clase.

CustomerSummary.java
package com.marcnuri;

public record CustomerSummary(String name, String city) {}
Una constructor expression
em.createQuery(
  "select new com.marcnuri.CustomerSummary(c.name, c.address.city) from Customer c",
  CustomerSummary.class
).getResultList();
Salida
[CustomerSummary[name=Ada, city=Bilbao]]

Hibernate no necesita el new: si la clase del resultado tiene un constructor que encaje, select c.name, c.address.city from Customer c da el mismo resultado. Es una característica de Hibernate: Jakarta Persistence 4.0 hace opcional el new, pero a fecha de noviembre de 2026 sigue en milestones, así que solo lo usaría en código que no vaya a cambiar de proveedor.

Spring Data JPA va más allá. Un método de repositorio puede devolver directamente un record, y selecciona solo las columnas del record.

CustomerContact.java
public record CustomerContact(String name, String email) {}
CustomerRepository.java
public interface CustomerRepository extends JpaRepository<Customer, Long> {
  List<CustomerContact> findByEmailEndingWith(String suffix);

  <T> List<T> findByName(String name, Class<T> type);

  @Query("select c from Customer c")
  List<CustomerContact> findContacts();

  @Query(value = "select name, email from customer", nativeQuery = true)
  List<CustomerContact> findContactsNative();
}

El tercer método es el interesante. Desde Spring Data JPA 3.5, un @Query que devuelve un tipo DTO se reescribe como una constructor expression, de modo que select c lee dos columnas, no toda la entidad. Cada uno de esos cuatro métodos devolvió records CustomerContact, con estas consultas:

El SQL que ejecutó Hibernate para cada método del repositorio
Log de SQL
select c1_0.name,c1_0.email from customer c1_0 where c1_0.email like ? escape '\'
select c1_0.name,c1_0.email from customer c1_0 where c1_0.name=?
select c1_0.name,c1_0.email from customer c1_0
select name, email from customer

Tres detalles que debes tener en cuenta:

  • Fuera de las consultas nativas, un record public puede tener constructores adicionales: Spring Data usa el canónico, y @PersistenceCreator selecciona otro. Un record package-private con un segundo constructor falla con ConverterNotFoundException, después de leer toda la entidad.
  • La documentación desaconseja los alias en una consulta que se va a reescribir, así que escribe select c.name, c.email, no select c.name as name. Hibernate aceptó los alias en mi prueba, pero solo porque el proveedor es permisivo.
  • Una consulta que enumera sus columnas las asigna por posición, no por nombre: select c.email, c.name me devolvió, sin ningún aviso, CustomerContact[name=ada@marcnuri.com, email=Ada]. Las consultas nativas ni siquiera se reescriben, así que el orden y los tipos de sus columnas también deben coincidir con los del constructor, y un record public con dos constructores de la misma aridad falla con ellas.

Builders y withers

⁠

Un record no tiene setters, así que "el mismo record con un cambio" significa llamar al constructor con todos los componentes. Cuando un record tiene más de unos pocos componentes, eso resulta tedioso y es fácil equivocarse, sobre todo si dos componentes contiguos son del mismo tipo.

La solución sin dependencias es un método withX escrito a mano, lo que suele llamarse un wither.

Ticket.java
public record Ticket(long id, String title, Status status, String assignee) {
  public Ticket withStatus(Status status) {
    return new Ticket(id, title, status, assignee);
  }

  public Ticket withAssignee(String assignee) {
    return new Ticket(id, title, status, assignee);
  }
}
Uso de un wither
var ticket = new Ticket(42, "Fix the build", Status.OPEN, "marc");
System.out.println(ticket.withStatus(Status.CLOSED));
Salida
Ticket[id=42, title=Fix the build, status=CLOSED, assignee=marc]

Eso escala mal: cada nuevo componente obliga a tocar todos los withers. Lombok los genera por ti con @Builder y @With, y ambos funcionan en records.

Ticket.java
@Builder(toBuilder = true)
@With
public record Ticket(long id, @NonNull String title, @Singular List<String> labels) {
  public Ticket {
    labels = List.copyOf(labels);
  }
}
Uso de Lombok
var ticket = Ticket.builder().id(42).title("Fix the build").label("ci").label("urgent").build();
System.out.println(ticket);
System.out.println(ticket.withTitle("Fix the release build"));
System.out.println(ticket.toBuilder().id(43).build());
Salida
Ticket[id=42, title=Fix the build, labels=[ci, urgent]]
Ticket[id=42, title=Fix the release build, labels=[ci, urgent]]
Ticket[id=43, title=Fix the build, labels=[ci, urgent]]

@NonNull también añade una comprobación de null al constructor: new Ticket(1, null, List.of()) lanza NullPointerException: title is marked non-null but is null. El constructor compacto sigue siendo necesario, porque el builder de Lombok protege su propia lista, pero una llamada directa al constructor no pasa por él.

Importante

Puede que hayas visto ticket with { status = CLOSED; } en artículos sobre records. Esa sintaxis es JEP 468, y no es una característica de Java: a fecha de noviembre de 2026 es una Candidate, actualizada por última vez en abril de 2024, sin ninguna versión asignada. Un hilo de febrero de 2026 en la lista amber-spec-experts la describe como "on hold" (en pausa). No cuentes con ella.

Si un record necesita un builder porque tiene una docena de componentes, merece la pena preguntarse si algunos de ellos deberían agruparse en un record más pequeño.

Records vs clases vs Lombok vs data classes de Kotlin

⁠

Estas cuatro opciones se solapan mucho, y la elección suele depender de lo que ya tengas.

CaracterísticaRecord de JavaClase normalLombok @Valuedata class de Kotlin
MutabilidadCampos final, inmutabilidad superficialLo que escribasCampos final, inmutabilidad superficialval o var, a tu elección
Java mínimo16CualquieraTambién JDKs antiguosTambién JDKs antiguos; @JvmRecord necesita 16
HerenciaNo puede extender ni ser extendidoCompletaPuede extender una clase; final salvo con @NonFinalPuede extender una clase, no puede ser extendida
IgualdadTodos los componentes, generadaLo que escribasCampos no estáticos ni transient, generadaPropiedades del constructor primario, generada
BoilerplateNingunoTodoNinguno, pero necesita un annotation processorNinguno, pero necesita Kotlin
Accessorsname()Lo que escribasgetName()getName() desde Java
Copia con un cambioA mano, o @With de LombokA mano@WithIntegrada: copy(), todos los argumentos desde Java
DescomposiciónRecord patternsNoNoval (a, b) = user, solo en Kotlin
Encaje con frameworksMuy bueno; sin entidades JPA ni herramientas solo para JavaBeansTodoBueno para lectura; sin setters ni constructor sin argumentosBueno, con su propio módulo de Jackson
InteroperabilidadJava puroJava puroJava puro, con un annotation processor al compilarNecesita la librería estándar de Kotlin

Mi regla general: para los nuevos portadores de datos en Java, usa un record. Recurre a una clase normal cuando necesites mutabilidad, herencia o estado calculado de forma lazy, y deja Lombok para las clases que un record no puede sustituir.

Lombok y los records se combinan bien, aunque la mayoría de las anotaciones de clase de Lombok no tienen sentido en un record, y Lombok las rechaza con un error de compilación:

Salida
Rejected.java:6: error: @Value is only supported on a class.
Rejected.java:3: error: @Getter is only supported on a class, an enum, or a field.
Rejected.java:4: error: @Setter is only supported on a class or a field.
Rejected.java:5: error: @Data is only supported on a class.
Rejected.java:7: error: @ToString is only supported on a class or enum.
Rejected.java:8: error: @EqualsAndHashCode is only supported on a class.
Rejected.java:9: error: AllArgsConstructor is only supported on a class or an enum.

@NoArgsConstructor, @RequiredArgsConstructor, @SuperBuilder, @FieldDefaults y @UtilityClass fallan de la misma forma. Las que compilan en un record son las que aportan algo: @Builder, @With, @WithBy, @NonNull, @FieldNameConstants y la familia @Log.

Una data class de Kotlin no es un record de Java: desde Java, isRecord() devuelve false, y sus accessors son getName(). Para convertirla en uno, añade @JvmRecord, que requiere un target de JVM 16 o posterior y propiedades val.

Models.kt
@JvmRecord
data class UserRecord(val name: String, val age: Int)

data class UserData(val name: String, val age: Int)
La llamada desde Java
var record = new UserRecord("Ada", 36);
var data = new UserData("Ada", 36);
System.out.println(record.name() + " " + data.getName());
System.out.println(record + " " + record.getClass().isRecord());
System.out.println(data + " " + data.getClass().isRecord());
Salida
Ada Ada
UserRecord(name=Ada, age=36) true
UserData(name=Ada, age=36) false

@JvmRecord conserva el formato de toString propio de Kotlin y copy(). También cambia los nombres de los accessors, así que añadirlo a una clase existente no es compatible a nivel binario.

Buenas prácticas y cuándo no usar records

⁠

Si te quedas con una sola lista de esta guía, que sea esta:

  • Copia los componentes mutables en el constructor compacto, con List.copyOf por defecto.
  • Evita los arrays como componentes.
  • Valida en el constructor compacto lo que deba cumplirse en toda instancia.
  • Prefiere un método factory estático con nombre a un constructor que tenga que hacer trabajo real.
  • Mantén los records pequeños: divide el que haya crecido más allá de unos pocos componentes.
  • Implementa las interfaces sealed con records, y omite default para que el compilador compruebe tus switch.
  • Usa records para lo que cruza una frontera: DTOs, payloads de APIs, resultados de consultas, eventos, claves compuestas y valores en un map.

Cuándo no usar records

⁠

Los records no son un sustituto general de las clases. Yo no usaría uno para:

  • Entidades y cualquier cosa con identidad. Dos clientes con el mismo nombre no son el mismo cliente, mientras que un record dice que sí. Por eso, además, un record no puede ser una entidad JPA.
  • Estado mutable. Un contador, una caché o un builder tienen un estado que cambia por diseño.
  • Jerarquías. Si necesitas estado compartido de una superclase, un record no puede dártelo. Compartir comportamiento sí es posible con métodos default.
  • Representaciones encapsuladas. Los accessors de un record exponen sus componentes, así que una clase que oculta cómo almacena sus datos no puede ser un record: la representación es la API.
  • Herramientas solo para JavaBeans. Nada que necesite un constructor sin argumentos y setters, o prefijos get, funcionará. El soporte de records en JAXB, por ejemplo, sigue siendo una petición abierta a fecha de noviembre de 2026.

Errores comunes

⁠

Estos son los bugs de esta guía, y dónde se trata cada uno:

  • Compartir una colección o un array mutable, o usar unmodifiableList como si fuera una copia: Copias defensivas.
  • Esperar que un constructor ejecute Bean Validation, o usar una constraint personalizada sin target FIELD: Cómo validar un record.
  • Un helper getX() que se convierte en una propiedad JSON: Trampas de los records con Jackson.
  • Un record con @Entity que se persiste sin errores y luego falla al cargar: ¿Puede un record ser una entidad JPA?.
  • Un componente null en un switch con patrones: Switch exhaustivo.
  • Parsear toString o persistir hashCode: equals, hashCode y toString.

Cómo migrar una clase a un record

⁠

Vamos a migrar un DTO típico de Lombok.

CustomerDto.java, antes
@Value
public class CustomerDto {
  String name;
  String email;
  List<String> tags;
}

1. Busca candidatas. Las buenas son final, no tienen setters y no extienden nada. Las clases @Value son el punto de partida obvio, y buscar por el paquete evita coincidir con la anotación homónima de Spring:

bash
grep -rlE 'lombok\.(Value|\*)' src/main/java

2. Comprueba la inmutabilidad y la identidad. Confirma que nada modifica la instancia, y que dos instancias con los mismos datos son realmente intercambiables. Comprueba que la clase no la use nada que necesite un JavaBean, y busca campos mutables: @Value no copia tags, así que esta clase ya estaba exponiendo su lista interna.

3. Conviértela. Los campos pasan a ser componentes, y la lista mutable recibe la copia que le faltaba. Esto también cambia el comportamiento: @Value aceptaba una lista null, y este record la rechaza.

CustomerDto.java, después
public record CustomerDto(String name, String email, List<String> tags) {
  public CustomerDto {
    tags = List.copyOf(tags);
  }
}

4. Actualiza las llamadas. dto.getName() pasa a ser dto.name(). Es la mayor parte del trabajo en una base de código grande, pero el compilador te enumera todas las llamadas que ya no puede resolver. Lo que no enumerará es lo que encuentra los accessors por reflexión o por nombre: piensa en templates, lenguajes de expresiones y librerías de mapeo.

5. Prueba lo que el compilador no puede comprobar. Ejecuta tus tests de serialización, y busca cualquier cosa que dependa del toString anterior, como parsers de logs o tests de snapshot, porque el formato cambia:

Salida
CustomerDto(name=Ada, email=ada@marcnuri.com, tags=[vip])
CustomerDto[name=Ada, email=ada@marcnuri.com, tags=[vip]]

Si la clase forma parte de la API pública de una librería, la conversión es un breaking change: sus getters desaparecen, y también cualquier superclase.

Conclusión

⁠

Un record es una clase cuyo estado es su API, y el compilador escribe el resto. La sintaxis se aprende en minutos. Lo que lleva más tiempo es el hábito: copiar lo mutable en un constructor compacto, y comprobar cómo trata cada framework a un record antes de fiarte.

Implementa una interfaz sealed con records, omite el default, y el compilador encontrará cada switch que se rompa con un caso nuevo. Con las versiones actuales de Jackson, Hibernate y Spring Data, los records funcionan en todas las capas de una aplicación salvo en las entidades.

Te Puede Interesar

⁠
  • Guía Completa de Java Virtual Threads (Project Loom)
  • Pattern Matching para instanceof en Java
  • Java Streams: Convertir List en Map
  • Cómo generar un serialVersionUID en IntelliJ IDEA
Icono de TwitterIcono de FacebookIcono de LinkedInIcono de PinterestIcono de correo electrónico

Navegador de artículos
Ejecutar Kubernetes CronJob manualmente desde Java usando YAKCCómo instalar Prometheus y Grafana en Minikube
© 2007 - 2026 Marc Nuri