Records en Java: guía completa con record patterns, JPA y Jackson
¿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.
public record Point(int x, int y) {}var point = new Point(1, 2);
System.out.println(point);
System.out.println(point.x());Point[x=1, y=2]
1Los 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.
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.
| Java | Lanzamiento | JEP | Qué cambió |
|---|---|---|---|
| 14 | Marzo de 2020 | JEP 359 | Records, primera preview |
| 15 | Septiembre de 2020 | JEP 384 | Segunda preview: reglas de acceso del constructor canónico, prohibido asignar campos en un constructor compacto |
| 16 | Marzo de 2021 | JEP 395 | Records definitivos; las clases internas pueden declarar records miembro |
| 17 | Septiembre de 2021 | JEP 409 | Clases sealed definitivas |
| 19 | Septiembre de 2022 | JEP 405 | Record patterns, primera preview |
| 20 | Marzo de 2023 | JEP 432 | Record patterns, segunda preview |
| 21 | Septiembre de 2023 | JEP 440, JEP 441 | Record patterns y pattern matching para switch definitivos |
| 22 | Marzo de 2024 | JEP 456 | Variables y patrones sin nombre (_) definitivos |
| 25 | Septiembre de 2025 | JEP 513 | Cuerpos 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.
javac Point.java
javap -p PointCompiled 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.

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.
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:
Point.java:2: error: invalid canonical constructor in record Point
Point(int x, int y) {
^
(attempting to assign stronger access privileges; was public)
1 errorEl 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.
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:
Name.java:3: error: cannot assign a value to final variable value
this.value = value.strip();
^
1 errorCó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.
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.
public record User(@NotBlank String name, @Email String email) {}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);
}User[name= , email=not-an-email]
email: must be a well-formed email address
name: must not be blankEl 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.
public record Team(String name, List<String> members) {}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);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.
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:
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.
public record Scores(int[] values) {}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})));[99, 2, 3]
falseArreglarlo bien exige todo lo que el record debía ahorrarte.
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.
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);
}
}System.out.println(Money.of("19.99", "EUR"));
System.out.println(Money.zero("USD"));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.
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:
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(...).
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.
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:
Point.java:2: error: field declaration must be static
private int z;
^
(consider replacing field with record component)
1 errorHay 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:
Point.java:3: error: '{' expected
public record Point(int x, int y) extends Base {}
^
1 errorTampoco 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.
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.
public record Measure(double value) {}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);true
false
false
trueLos 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.
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:
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.
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);
}
}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);[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[].
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());[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.
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");
}
}Temperature[celsius=21]
java.io.InvalidObjectException: below absolute zero: -300
java.io.InvalidClassException: Plain; local class incompatible: stream classdesc serialVersionUID = 1, local class serialVersionUID = 2El 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.
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.
Object value = new Point(3, 4);
if (value instanceof Point(int x, int y)) {
System.out.println(x + y);
}7Usar 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).
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);
};
}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))));12.57
12.0Record 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.
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";
};
}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))));circle at the origin
circle of radius 2
empty rectangleSwitch 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.
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:
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.
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.
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));{"full_name":"Ada Lovelace","skills":["math","engines"]}
Employee[fullName=Ada Lovelace, skills=[math, engines]]
trueJackson 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:
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.
public record Ordered(String zeta, String alpha) {}
public class Plain {
public String zeta = "z";
public String alpha = "a";
}{"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.
public record Person(String firstName, String lastName) {
public String getFullName() {
return firstName + " " + lastName;
}
}{"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:
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:
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:
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.
@Embeddable
public record Address(String street, String city, @ManyToOne(fetch = FetchType.LAZY) Country country) {}@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.
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
}@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.
package com.marcnuri;
public record CustomerSummary(String name, String city) {}em.createQuery(
"select new com.marcnuri.CustomerSummary(c.name, c.address.city) from Customer c",
CustomerSummary.class
).getResultList();[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.
public record CustomerContact(String name, String email) {}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
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 customerTres detalles que debes tener en cuenta:
- Fuera de las consultas nativas, un record
publicpuede tener constructores adicionales: Spring Data usa el canónico, y@PersistenceCreatorselecciona otro. Un record package-private con un segundo constructor falla conConverterNotFoundException, 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, noselect 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.nameme 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 recordpubliccon 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.
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);
}
}var ticket = new Ticket(42, "Fix the build", Status.OPEN, "marc");
System.out.println(ticket.withStatus(Status.CLOSED));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.
@Builder(toBuilder = true)
@With
public record Ticket(long id, @NonNull String title, @Singular List<String> labels) {
public Ticket {
labels = List.copyOf(labels);
}
}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());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ística | Record de Java | Clase normal | Lombok @Value | data class de Kotlin |
|---|---|---|---|---|
| Mutabilidad | Campos final, inmutabilidad superficial | Lo que escribas | Campos final, inmutabilidad superficial | val o var, a tu elección |
| Java mínimo | 16 | Cualquiera | También JDKs antiguos | También JDKs antiguos; @JvmRecord necesita 16 |
| Herencia | No puede extender ni ser extendido | Completa | Puede extender una clase; final salvo con @NonFinal | Puede extender una clase, no puede ser extendida |
| Igualdad | Todos los componentes, generada | Lo que escribas | Campos no estáticos ni transient, generada | Propiedades del constructor primario, generada |
| Boilerplate | Ninguno | Todo | Ninguno, pero necesita un annotation processor | Ninguno, pero necesita Kotlin |
| Accessors | name() | Lo que escribas | getName() | getName() desde Java |
| Copia con un cambio | A mano, o @With de Lombok | A mano | @With | Integrada: copy(), todos los argumentos desde Java |
| Descomposición | Record patterns | No | No | val (a, b) = user, solo en Kotlin |
| Encaje con frameworks | Muy bueno; sin entidades JPA ni herramientas solo para JavaBeans | Todo | Bueno para lectura; sin setters ni constructor sin argumentos | Bueno, con su propio módulo de Jackson |
| Interoperabilidad | Java puro | Java puro | Java puro, con un annotation processor al compilar | Necesita 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:
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.
@JvmRecord
data class UserRecord(val name: String, val age: Int)
data class UserData(val name: String, val age: Int)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());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.copyOfpor 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
defaultpara que el compilador compruebe tusswitch. - 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
unmodifiableListcomo 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
@Entityque se persiste sin errores y luego falla al cargar: ¿Puede un record ser una entidad JPA?. - Un componente
nullen unswitchcon patrones: Switch exhaustivo. - Parsear
toStringo persistirhashCode: equals, hashCode y toString.
Cómo migrar una clase a un record
Vamos a migrar un DTO típico de Lombok.
@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:
grep -rlE 'lombok\.(Value|\*)' src/main/java2. 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.
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:
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.
