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.
1
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íneaint edad = 25; (directo y rápido) vs Integer edadWrapper = Integer.valueOf(25);
Las tres trampas del autoboxingjava
Integera=127, b = 127;
System.out.println(a == b); // true: mismo objeto del cachéIntegerc=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 nullMap<String, Integer> stock=newHashMap<>();
intunidades= stock.get("teclado"); // NullPointerException al desempaquetarintseguras= stock.getOrDefault("teclado", 0); // correcto// Trampa 3: boxing dentro de un bucle calienteLongtotal=0L; // wrapper: crea un objeto por iteraciónfor (longi=0; i < 1_000_000; i++) {
total += i; // unbox, suma, box
}
longrapido=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.
Usar Integer para contadores masivos o comparar wrappers con == esperando que compare el número.
Idéntico en Java 17 y Java 21: las colecciones como List<Integer> siguen requiriendo wrappers.
Se conecta conMemoria Stack vs HeapAutoboxing y UnboxingColecciones Genéricas
2
Encapsulamiento con Intención: Métodos de Negocio en vez de Setters Ciegos
Encapsular no es crear getters y setters para todo; es proteger los invariantes de la clase y exponer métodos que reflejen acciones reales del dominio.
En una líneatarea.marcarComoCompletada() en lugar de tarea.setCompletada(true).
Tarea con invariantes protegidosjava
publicclassTarea {
privatefinal Long id;
private String titulo;
privateEstadoestado= Estado.PENDIENTE;
private Instant completadaEn;
publicTarea(Long id, String titulo) {
this.id = id;
renombrar(titulo); // reutiliza la validación
}
publicvoidrenombrar(String nuevoTitulo) {
if (nuevoTitulo == null || nuevoTitulo.isBlank()) {
thrownewIllegalArgumentException("El título es obligatorio");
}
this.titulo = nuevoTitulo.strip();
}
publicvoidcompletar(Clock reloj) {
if (estado == Estado.COMPLETADA) {
thrownewIllegalStateException("La tarea ya estaba completada");
}
this.estado = Estado.COMPLETADA;
this.completadaEn = Instant.now(reloj); // estado y fecha cambian juntos
}
publicStringtitulo() { return titulo; }
publicbooleanestaCompletada() { return estado == Estado.COMPLETADA; }
enumEstado { PENDIENTE, COMPLETADA }
}
Cómo funciona por dentro
Un invariante es una regla que debe cumplirse siempre que el objeto sea observable: el saldo nunca es negativo, una tarea completada tiene fecha de cierre, un pedido enviado no admite nuevas líneas. Encapsular consiste en que sea imposible romper esas reglas desde fuera.
Los setters genéricos trasladan la responsabilidad a quien llama: cualquiera puede poner el objeto en un estado inválido o a medio actualizar. Los métodos de negocio (completar(), retirar(importe)) concentran la validación en un solo sitio, expresan la intención del dominio y permiten cambiar la representación interna sin tocar el resto del código.
Esto no significa prohibir todos los getters. Exponer datos para leerlos es razonable; lo que se evita es exponer la mutación sin control. Si una clase es solo una bolsa de datos sin reglas, probablemente debería ser un record.
Evita que el estado del objeto quede corrupto o en estados intermedios inválidos desde otras clases.
Poner setters públicos a todos los campos y permitir que cualquiera modifique campos sin validación.
Aplica a cualquier versión de Java. En 17 y 21 se complementa con record para datos puros.
Se conecta conInmutabilidadConstructores CanónicosClean Code
3
Records de Java: Inmutabilidad Garantizada por el Compilador
Una clase de datos inmutable donde el compilador genera automáticamente constructor, getters, equals(), hashCode() y toString().
En una líneapublic record TareaDto(Long id, String titulo, boolean completada) {}
Record con validación y copia defensivajava
publicrecordTareaDto(Long id, String titulo, List<String> etiquetas) {
// Constructor compacto: valida y normaliza; las asignaciones las añade el compiladorpublic TareaDto {
Objects.requireNonNull(titulo, "titulo");
if (titulo.isBlank()) thrownewIllegalArgumentException("titulo vacío");
titulo = titulo.strip();
etiquetas = List.copyOf(etiquetas); // copia inmutable: corta el vínculo con la lista original
}
// Fábrica con nombre expresivopublicstaticTareaDtonueva(String titulo) {
returnnewTareaDto(null, titulo, List.of());
}
// Los records no tienen setters: se crea una copia modificada ("wither")publicTareaDtoconTitulo(String nuevo) {
returnnewTareaDto(id, nuevo, etiquetas);
}
}
vart= TareaDto.nueva(" Leer JEP 395 ");
System.out.println(t.titulo()); // "Leer JEP 395"
System.out.println(t); // TareaDto[id=null, titulo=Leer JEP 395, etiquetas=[]]
Cómo funciona por dentro
Un record declara sus componentes en la cabecera y el compilador genera campos private final, un constructor canónico, accesores con el mismo nombre que el componente (titulo(), no getTitulo()), y equals, hashCode y toString basados en todos los componentes. Es implícitamente final y extiende java.lang.Record.
El constructor compacto permite validar y normalizar sin repetir asignaciones: escribes la lógica y el compilador añade las asignaciones al final. También puedes añadir métodos de instancia, métodos estáticos de fábrica e implementar interfaces. Lo que no puedes es añadir campos de instancia extra ni heredar de otra clase.
La inmutabilidad de un record es superficial: los campos no se pueden reasignar, pero si un componente es una lista mutable, su contenido sí puede cambiar. Por eso, para componentes de colección, haz una copia defensiva con List.copyOf en el constructor compacto.
Elimina cientos de líneas de código repetitivo sin librerías externas como Lombok.
Intentar usar un record para una entidad mutable de base de datos JPA (JPA requiere proxies mutables).
Estándar en Java 17. En Java 21 se pueden desestructurar en sentencias switch con pattern matching.
Se conecta conDTOs de APIContrato equals/hashCodeProgramación Concurrente Thread-Safe
Compruébalo: ¿Qué pasa si en el constructor compacto escribes this.titulo = titulo.strip();?
No compila. En el constructor compacto los campos se asignan automáticamente al final, así que no puedes asignarlos tú. Se reasigna el parámetro (titulo = titulo.strip();) y el compilador copia ese valor al campo.
4
Modificadores de Acceso y el Principio de Menor Privilegio
Java tiene 4 niveles de visibilidad: private, default (package-private), protected y public. Cada campo debe ser lo más privado posible.
En una líneaprivate final String titulo; accesible solo por métodos internos.
Paquete por funcionalidad con API mínimajava
// com/acme/tareas/TareaService.javapackage com.acme.tareas;
publicclassTareaService { // público: lo usan otros paquetesprivatefinal TareaRepository repo; // privado: detalle interno
TareaService(TareaRepository repo) { // package-private: Spring lo inyecta igualthis.repo = repo;
}
publicTareaDtocrear(String titulo) { // la única operación públicavartarea=newTarea(null, titulo);
return TareaMapper.aDto(repo.guardar(tarea));
}
}
// com/acme/tareas/TareaMapper.javapackage com.acme.tareas;
finalclassTareaMapper { // invisible fuera de com.acme.tareasprivateTareaMapper() {}
staticTareaDtoaDto(Tarea t) { /* ... */returnnull; }
}
Cómo funciona por dentro
Java tiene cuatro niveles de visibilidad, de más a menos restrictivo: private (solo la propia clase), sin modificador o package-private (la clase y su paquete), protected (el paquete más las subclases, estén donde estén) y public (todo el mundo).
El nivel package-private está infravalorado. Permite agrupar en un paquete por funcionalidad (tareas) el controlador, el servicio y el repositorio, y dejar públicas solo las clases que otros paquetes deben usar. El resto queda como detalle de implementación que puedes cambiar sin miedo. Spring funciona sin problema con clases y constructores package-private.
El principio de menor privilegio dice: empieza con lo más restrictivo y abre solo cuando haga falta. Cada miembro public es una promesa que otros pueden empezar a usar y que te costará retirar. protected es aún más delicado porque expone detalles internos a una jerarquía que no controlas.
Oculta los detalles internos de implementación para que cambiar el interior de una clase no rompa el resto del sistema.
Declarar atributos con visibilidad public o protected por comodidad en lugar de privados.
Idéntico en Java 17 y 21.
Se conecta conArquitectura ModularEncapsulamientoInyección por Constructor
5
Inferencia de Tipos con `var`: Tipado Estático Inmutable
var permite que el compilador deduzca el tipo de una variable local sin tener que repetirlo a ambos lados de la asignación.
En una líneavar tareas = new ArrayList<TareaDto>(); (el tipo es exactamente ArrayList).
Cuándo ayuda y cuándo confundejava
// Bien: el tipo es obvio a la derechavartareas=newArrayList<TareaDto>();
varporEstado=newHashMap<Estado, List<TareaDto>>();
for (var entrada : porEstado.entrySet()) { // evita Map.Entry<Estado, List<TareaDto>>
System.out.println(entrada.getKey() + " -> " + entrada.getValue().size());
}
// Mal: el lector no sabe qué tipo esvarr= servicio.procesar(peticion);
// Trampa del diamantevarlista=newArrayList<>(); // ArrayList<Object>
lista.add("texto");
lista.add(42); // compila: el tipo es Object// Trampa numéricavartotal=0; // int, no long
total += 3_000_000_000L; // compila, pero desborda en silencio (conversión implícita del +=)
Cómo funciona por dentro
var solo existe para variables locales, índices de bucles for y recursos de try-with-resources. El compilador infiere el tipo a partir del inicializador en tiempo de compilación y lo fija para siempre: no puedes asignar después un valor de otro tipo. No se puede usar en campos, parámetros ni tipos de retorno, y exige inicializador (var x; no compila, var x = null; tampoco).
Usa var cuando el tipo es evidente a la derecha (new, fábricas con nombre claro) o cuando el tipo completo es ruido (Map.Entry<String, List<Tarea>> en un bucle). Evítalo cuando el lector no puede deducir el tipo (var resultado = servicio.procesar();) o con literales numéricos ambiguos.
Cuidado con el diamante: var lista = new ArrayList<>(); infiere ArrayList<Object>, porque no hay tipo destino del que deducir el genérico.
Hace el código más limpio y legible cuando el tipo a la derecha es obvio.
Creer que var convierte a Java en un lenguaje de tipado dinámico; el tipo se fija al compilar.
Disponible desde Java 10, completamente maduro en Java 17 y 21.
Se conecta conCompilador javacTipado EstáticoLegibilidad de Código
Cuatro bloques cronometrados. No pases al siguiente sin tener el resultado esperado.
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.
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.
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.
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
Crea record Producto(String sku, String nombre, BigDecimal precio) y valida en el constructor compacto que el precio sea positivo.
Crea record Cliente(long id, String nombre, Email email) con Email como record propio que valida el formato.
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.