Día 2 de 30 Fase 1: Java Vanilla — El Núcleo del Lenguaje (17 & 21)

Cimientos del Lenguaje, POO Limpia y Records Inmutables

Primitivos vs Wrappers, Modificadores de Acceso y Modelado de Datos en Java 17 y 21

Para dominar. Al terminar deberías poder hacerlo sin ayuda.

  • 3h de estudio
  • 5 conceptos
  • 4 preguntas
  • 2 katas
Java 17 LTSJava 21 LTSPOOEncapsulamientoRecordsInmutabilidad
Al terminar este día sabrás 4
  • Explicar dónde vive cada valor en memoria y por qué == falla con wrappers.
  • Diseñar clases que protegen sus invariantes con métodos de negocio en lugar de setters.
  • Escribir records con validación, copias defensivas y métodos propios.
  • Usar var y los modificadores de acceso con criterio, no por costumbre.

Primitivos vs Wrappers: Almacenamiento Directo frente a Objetos en Heap

Los tipos primitivos (int, double, boolean) guardan su valor numérico directamente en el Stack. Los wrappers (Integer, Double) son objetos completos que viven en el Heap, ocupan más memoria y pueden ser nulos (null).

En una línea int edad = 25; (directo y rápido) vs Integer edadWrapper = Integer.valueOf(25);

Las tres trampas del autoboxing java
Integer a = 127, b = 127;
System.out.println(a == b);        // true: mismo objeto del caché
Integer c = 128, d = 128;
System.out.println(c == d);        // false: dos objetos distintos
System.out.println(c.equals(d));   // true: compara el valor

// Trampa 2: unboxing de null
Map<String, Integer> stock = new HashMap<>();
int unidades = stock.get("teclado"); // NullPointerException al desempaquetar
int seguras = stock.getOrDefault("teclado", 0); // correcto

// Trampa 3: boxing dentro de un bucle caliente
Long total = 0L;                   // wrapper: crea un objeto por iteración
for (long i = 0; i < 1_000_000; i++) {
    total += i;                    // unbox, suma, box
}
long rapido = 0L;                  // primitivo: sin objetos intermedios
Cómo funciona por dentro

Un int ocupa 4 bytes y su valor se guarda directamente donde se declara: en el marco de pila del método si es variable local, o dentro del objeto si es un campo. Un Integer es un objeto en el heap con cabecera (unos 12-16 bytes) más el valor; la variable solo guarda una referencia. Por eso un List<Integer> de un millón de elementos ocupa varias veces más que un int[] equivalente.

El autoboxing convierte automáticamente entre ambos mundos. Integer x = 5 se compila como Integer.valueOf(5), que devuelve objetos cacheados para el rango -128 a 127. Fuera de ese rango cada llamada crea un objeto nuevo, y comparar con == compara referencias, no valores. El unboxing es la conversión inversa: si el wrapper es null, desempaquetarlo lanza NullPointerException, un error frecuente al leer un Map<String, Integer>.

Regla práctica: primitivos para cálculo y contadores; wrappers solo cuando necesitas null como valor legítimo o cuando la API lo exige (genéricos, colecciones). Compara wrappers siempre con equals u Objects.equals.

Permite optimizar el rendimiento y evitar NullPointerException al desempaquetar valores numéricos.

Se conecta con Memoria Stack vs HeapAutoboxing y UnboxingColecciones Genéricas

Cuatro bloques cronometrados. No pases al siguiente sin tener el resultado esperado.

  1. Bloque 1: Cimientos y Primitivos (0 - 25 min)

    Qué estudiar. Diferencia de memoria Stack vs Heap. Primitivos vs Wrappers. Caché de Integer.

    Pregunta a responder. ¿Por qué desempaquetar un Long nulo lanza NullPointerException y cómo evitarlo?

    Resultado esperado. Clase ejecutable probando los límites del Integer Cache (-128 a 127) y boxing.

  2. Bloque 2: Inmutabilidad y Records (25 - 50 min)

    Qué estudiar. Sintaxis de record en Java 17 y constructor compacto para validar invariantes.

    Pregunta a responder. ¿Cómo garantizar que un record con una lista interna sea 100% inmutable?

    Resultado esperado. Record UsuarioDto con constructor compacto que use List.copyOf() para defensa contra mutaciones.

  3. Bloque 3: Diferencias Java 17 vs Java 21 (50 - 80 min)

    Qué estudiar. Pattern Matching para instanceof en Java 17 frente a Record Patterns en Java 21.

    Pregunta a responder. ¿Cómo se desestructura un record en Java 21 frente a Java 17?

    Resultado esperado. Snippet de código comparando la extracción en Java 17 con record.campo() y en 21 con desestructuración directa.

  4. Cierre y Consolidación (80 - 90 min)

    Qué estudiar. Repaso activo de los 5 conceptos centrales sin mirar las notas.

    Pregunta a responder. Responder de memoria las 4 preguntas anti-memorización.

    Resultado esperado. Resumen en el cuaderno de notas personal de la app con los 3 aprendizajes clave de hoy.

Tres formas de comprobar si lo has entendido, de menos a más exigente: explicarlo con tus palabras, responder preguntas trampa y resolver el reto del tutor.

Explícalo desde cero

Lee cada ficha y explícala en voz alta antes de seguir: si puedes contárselo a alguien que empieza, lo has entendido.

La diferencia real entre Stack y Heap para primitivos y objetos

Lo que debes entender

El Stack guarda los valores inmediatos de las variables de método. Si es un primitivo (int), guarda el número; si es un objeto, guarda el puntero de memoria hacia el Heap.

Analogía para contarlo

El Stack es como un bloc de notas inmediato en tu mano; el Heap es como un almacén grande donde guardas cajas y en tu bloc solo anotas el número de estantería.

Lo que suele explicarse mal

Decir que los primitivos "son más rápidos" sin explicar que evitan indirecciones de punteros y recolección de basura.

Señal de que aún no lo dominas

Si dices que Integer a = 10; guarda el 10 directamente en el hilo sin crear un objeto en memoria.

Por qué los Records son inmutables por diseño

Lo que debes entender

Un record no tiene setters porque sus campos son implícitamente private final. Una vez creado, jamás cambia su estado interno.

Analogía para contarlo

Un billete de avión: tiene tu nombre, vuelo y asiento impresos. Si cambias de vuelo, no borras el billete con típex; la aerolínea emite un billete nuevo.

Lo que suele explicarse mal

Explicarlo como "un simple DTO de Lombok" en lugar de un tipo formal del compilador Java con semántica matemática de tupla nombrada.

Señal de que aún no lo dominas

Si intentas crear un método setter dentro de un record o no sabes qué es un constructor compacto.

Preguntas trampa

Responde de memoria, en voz alta o por escrito, y después abre la respuesta razonada: lo importante es el porqué, no la palabra exacta.

Nivel 1 (Básico) ¿Por qué Integer a = 127; Integer b = 127; a == b; devuelve true, pero con 128 devuelve false? Ver respuesta

Respuesta razonada

Integer a = 127 se compila como Integer.valueOf(127). Ese método devuelve instancias cacheadas para valores entre -128 y 127 (el límite superior se puede ampliar con -XX:AutoBoxCacheMax). Por eso a y b apuntan al mismo objeto y ==, que compara referencias, devuelve true.

Con 128 cada valueOf crea un objeto distinto y == devuelve false. La lección no es memorizar el rango, sino que == en objetos compara identidad. Para wrappers usa equals o, si pueden ser null, Objects.equals(a, b).

Qué está evaluando la pregunta

Expone si entiendes el Integer Cache (-128 a 127) implementado por la JVM para ahorrar memoria, y por qué los wrappers JAMÁS se comparan con ==.

Nivel 2 (Intermedio) ¿Por qué un record en Java no puede extender de otra clase con extends? Ver respuesta

Respuesta razonada

Todo record extiende implícitamente java.lang.Record, y Java solo permite heredar de una clase. Además, el diseño es intencionado: un record es un portador transparente de datos cuyo estado está definido solo por sus componentes. Si heredara campos de un padre, equals, hashCode y toString dejarían de describir el estado completo.

Lo que sí puede hacer un record es implementar interfaces, incluidas interfaces sealed, que es la forma idiomática de modelar jerarquías de datos en Java 17 y 21.

Qué está evaluando la pregunta

Expone si sabes que todos los records extienden implícitamente de java.lang.Record y en Java no existe la herencia múltiple de clases.

Nivel 3 (Avanzado) Si los campos de un record son inmutables (final), ¿por qué un record Tarea(List<String> etiquetas) puede no ser inmutable en la práctica? Ver respuesta

Respuesta razonada

El campo etiquetas es final, así que no puedes hacer que apunte a otra lista. Pero la lista a la que apunta puede ser un ArrayList mutable que el llamador conserva: si después hace etiquetas.add("urgente"), el record cambia sin que nadie lo haya tocado. Además, el hashCode del record cambia, lo que rompe su uso como clave de un HashMap.

La solución es copiar de forma inmutable en el constructor compacto: etiquetas = List.copyOf(etiquetas);. List.copyOf también rechaza null dentro de la lista y no copia si ya recibe una lista inmutable.

Qué está evaluando la pregunta

Expone la diferencia crítica entre referencia inmutable y contenido inmutable: si pasas un ArrayList mutable al record, cualquiera desde afuera puede hacer etiquetas.add().

Nivel 4 (Entrevista Senior) En un puesto que exige Java 17, un desarrollador propone migrar DTOs a records. ¿Qué consideración crítica debe tener si esos DTOs se serializan con Jackson o se usan como entidades JPA? Ver respuesta

Respuesta razonada

Con Jackson: desde la versión 2.12 Jackson deserializa records usando el constructor canónico, sin necesidad de constructor vacío ni setters, y Spring Boot 3 ya trae una versión compatible. Conviene revisar que no haya configuraciones antiguas que dependan de setters o @JsonProperty en campos.

Con JPA: un record no puede ser @Entity. Hibernate necesita un constructor sin argumentos, campos no final para rellenarlos tras la consulta y la posibilidad de crear subclases proxy para la carga perezosa, y un record es final con campos final. Los records sí sirven como proyecciones de consultas (select new com.acme.TareaResumen(t.id, t.titulo) ...) y como DTOs de la API, que es exactamente donde deben estar.

Qué está evaluando la pregunta

En Java 17 Jackson necesita la versión adecuada para mapear records sin constructor vacío, y en JPA Hibernate exige proxies mutables, por lo que los records no pueden ser entidades @Entity.

Reto del tutor

Un desarrollador júnior crea la clase CuentaBancaria con campos public double saldo; y añade getters y setters para todo. Explica por qué este diseño es deficiente y cómo corregirlo usando principios de Java 17 / 21.

Lo que una respuesta suele tener bien

Identifica que hacer los campos públicos o tener setters abiertos permite asignar saldos negativos desde cualquier lugar.

Lo que suele quedarse en la superficie

Decir simplemente "falta poner private a los campos y usar getters/setters", porque un setter setSaldo(saldo) sigue permitiendo poner saldo en -5000 sin validar.

El concepto de fondo que suele faltar

No entender que una cuenta bancaria es un objeto con estado de negocio mutable controlado por invariantes (ingresar(monto), retirar(monto)), no una estructura de datos.

Cómo lo respondería alguien senior en una entrevista

El diseño viola el principio de encapsulamiento: saldo debe ser private BigDecimal saldo; (evitando errores de redondeo de punto flotante en dinero). No debe existir setSaldo(), sino operaciones del dominio como ingresar(BigDecimal monto) y retirar(BigDecimal monto) que validen que el monto sea positivo y que haya fondos suficientes. Para transferir datos de lectura a la vista o API, se expone un record CuentaDto(String numero, BigDecimal saldo) { } inmutable.

Proyecto «Pedidos» · paso 2 de 30

Modelar el catálogo con records

Representar productos, clientes y líneas de pedido como datos inmutables que no pueden nacer inválidos.

De qué va el proyecto

El backend de una tienda online: clientes, catálogo de productos y pedidos con sus líneas, pagos y envíos. Empieza como un proyecto Maven con records y termina como un sistema con API REST, seguridad, mensajería, contenedores y observabilidad. Cada paso construye sobre el anterior, así que trabaja siempre en el mismo repositorio.

Qué hacer

  1. Crea record Producto(String sku, String nombre, BigDecimal precio) y valida en el constructor compacto que el precio sea positivo.
  2. Crea record Cliente(long id, String nombre, Email email) con Email como record propio que valida el formato.
  3. Crea record LineaPedido(String sku, int cantidad, BigDecimal precioUnitario) con un método subtotal().

Está listo cuando

  • Ningún record se puede crear con datos inválidos: los tests lo demuestran.
  • No hay setters ni campos públicos mutables en el dominio.

Katas: ejercicios cortos

Reglas de oro

  • Nunca compares Wrappers con ==. Usa siempre .equals() o primitivos.
  • Los DTOs de salida y entrada en APIs deben ser record inmutables.
  • Si un record contiene una colección, usa List.copyOf(coleccion) en el constructor para evitar que la modifiquen desde afuera.

Java 17 frente a Java 21

CaracterísticaJava 17 LTSJava 21 LTS
Record Patterns (Desestructuración) No disponible (preview en 19/20) Estándar oficial en 21 LTS
Pattern Matching para instanceof Estándar if (o instanceof String s) Estándar
Uso en Spring Boot 3 Versión mínima requerida Totalmente soportado y recomendado
Se guarda automáticamente

Fuentes oficiales

Revisado el . Este día en Markdown