Junior dev en la era de la IA: qué aprender para no ser reemplazado
1. Descripción general
Las herramientas de generación de código ahora pueden escribir controladores, entidades o pruebas de muestra en segundos. Por eso muchos desarrolladores junior se preocupan de que su posición desaparezca. Este artículo no predice el futuro. Se enfoca en una pregunta más práctica: cuando escribir código no es lo difícil, ¿qué habilidades deberías desarrollar?
Después de leerlo, tendrás una lista de habilidades prioritarias. Cada habilidad viene con un ejemplo en Java que puedes ejecutar inmediatamente. El artículo también deja claro cuándo no deberías delegar el trabajo a un asistente de código.
2. Por qué esta pregunta atrae tanta atención
Antes, a los junior generalmente se les asignaban tareas repetitivas como escribir CRUD, mapear DTO o corregir pequeños errores. Era también cómo aprendían el oficio. Ahora gran parte de este trabajo puede generarse automáticamente. El costo de crear código ha bajado dramáticamente.
El costo de estar equivocado sigue siendo igual. El código generado puede compilarse, verse razonable y pasar una prueba simple, pero estar equivocado en lugares sutiles: divisas, concurrencia, transacciones, seguridad. Cuando el código sale a producción, sigues siendo responsable.
Por eso el valor se está desplazando desde “poder escribir código” a tres tareas:
- Evaluar si el código es correcto o incorrecto y por qué.
- Verificar el comportamiento mediante pruebas.
- Entender el runtime, el framework y el sistema en el que se ejecuta el código.
Las secciones siguientes recorren cada habilidad en detalle.
3. Leer y revisar código mejor que escribir código rápido
La primera habilidad es mirar un fragmento de código “que se ve bien” y detectar que está mal. Supongamos que le pides a un asistente de código que escriba una función para calcular el total de un pedido y recibes esto:
import java.util.List;
record OrderLine(String product, double price, int quantity) {}
record Order(List<OrderLine> lines) {
double total() {
return lines.stream()
.mapToDouble(l -> l.price() * l.quantity())
.sum();
}
}
public class Main {
public static void main(String[] args) {
var order = new Order(List.of(
new OrderLine("Bolígrafo", 0.1, 1),
new OrderLine("Cuaderno", 0.2, 1)));
System.out.println(order.total());
}
}
El código es corto y legible, pero el resultado es:
0.30000000000000004
double representa números usando formato binario de punto flotante. Por eso 0.1 y 0.2 no se almacenan con precisión. Con divisas, este error se acumula a través de miles de transacciones y se convierte en un error de conciliación. La corrección usa BigDecimal:
import java.math.BigDecimal;
import java.util.List;
record OrderLine(String product, BigDecimal price, int quantity) {
BigDecimal subtotal() {
return price.multiply(BigDecimal.valueOf(quantity));
}
}
record Order(List<OrderLine> lines) {
BigDecimal total() {
return lines.stream()
.map(OrderLine::subtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
Hay tres puntos a notar:
pricetiene tipoBigDecimal. Deberías crear valores connew BigDecimal("0.1"), no connew BigDecimal(0.1).subtotal()multiplica el precio por la cantidad sin pasar pordouble.reducecomienza conBigDecimal.ZEROpor lo que un pedido vacío devuelve0en lugar de un error.
Esta es exactamente la habilidad que las herramientas aún no pueden reemplazar. Debes hacer preguntas sobre tipos de datos, valores límite y casos vacíos. Cuando revises, pregúntate: “¿Qué entrada haría que esta función falle?”
4. Escribir pruebas como especificación
Sin pruebas, no sabes si el código que acabas de recibir es correcto o no. Las pruebas también son la forma más clara de describir qué quieres. Cuanto más clara sea la descripción, más fácil será verificar el código generado o escrito por colegas.
En un proyecto Spring Boot, solo necesitas una dependencia para tener JUnit 5 y AssertJ:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Pruebas para la clase Order anterior:
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class OrderTest {
@Test
@DisplayName("El total suma correctamente números decimales")
void totalIsExactForDecimals() {
var order = new Order(List.of(
new OrderLine("Bolígrafo", new BigDecimal("0.1"), 1),
new OrderLine("Cuaderno", new BigDecimal("0.2"), 1)));
assertThat(order.total()).isEqualByComparingTo("0.3");
}
@Test
@DisplayName("Un pedido vacío tiene total cero")
void emptyOrderTotalIsZero() {
var order = new Order(List.of());
assertThat(order.total()).isEqualByComparingTo(BigDecimal.ZERO);
}
@Test
@DisplayName("El total se multiplica correctamente por la cantidad")
void totalMultipliesByQuantity() {
var order = new Order(List.of(
new OrderLine("Libro", new BigDecimal("12.50"), 3)));
assertThat(order.total()).isEqualByComparingTo("37.50");
}
}
Algunos detalles dignos de aprender:
@DisplayNameregistra la regla de negocio en lenguaje legible. Leer un informe de pruebas es entender qué debe hacer el sistema.isEqualByComparingTocompara por valor.new BigDecimal("0.3").equals(new BigDecimal("0.30"))devuelvefalseporque difieren en escala. Este es un error muy común.- Cada prueba verifica exactamente un comportamiento: caso normal, caso vacío, caso de multiplicación por cantidad.
Nota: No dejes que la herramienta genere tanto código como pruebas y luego solo veas verde y te sientas tranquilo. Si ambos entienden mal el requisito, la prueba igual pasará. Deberías escribir al menos las pruebas para las reglas de negocio importantes.
5. Entender el runtime: la concurrencia no se puede adivinar
La mayoría de los errores difíciles solo aparecen cuando múltiples threads se ejecutan al mismo tiempo. En Spring, todos los beans son singleton por defecto, lo que significa que toda solicitud usa la misma instancia. El siguiente fragmento parece inofensivo:
import java.util.concurrent.Executors;
class ReportService {
private int processedCount = 0;
void process() {
processedCount++;
}
int processedCount() {
return processedCount;
}
}
public class CounterDemo {
public static void main(String[] args) {
var service = new ReportService();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(service::process);
}
}
System.out.println("Procesados: " + service.processedCount());
}
}
Executors.newVirtualThreadPerTaskExecutor() de Java 21 crea un virtual thread para cada tarea. El bloque try-with-resources espera a que terminen todos antes de cerrar. Esperamos que imprima 10000, pero en realidad, a menudo ves un número más pequeño, y diferente cada vez que se ejecuta:
Procesados: 9871
La razón es que processedCount++ no es una operación atómica. Consta de tres pasos: leer, sumar y escribir. Dos threads pueden leer el mismo valor, causando que se pierda un incremento. La corrección:
import java.util.concurrent.atomic.AtomicInteger;
class ReportService {
private final AtomicInteger processedCount = new AtomicInteger();
void process() {
processedCount.incrementAndGet();
}
int processedCount() {
return processedCount.get();
}
}
Esta vez el resultado es siempre 10000. La lección más amplia es: los beans singleton no deben mantener estado que cambie por solicitud. Cuando leas un @Service, busca campos que no sean final. Ese es generalmente donde está el error escondido.
No necesitas memorizar todas las APIs de java.util.concurrent. Pero necesitas entender conceptos como race condition, visibility o lock para reconocer el error cuando lo veas.
6. Entender el framework: saber cómo funciona la “magia”
Spring te ayuda a escribir menos código mediante anotaciones, a cambio de que muchas cosas ocurran bajo el capó. Si no entiendes el mecanismo subyacente, te sorprenderás. El ejemplo clásico es auto-invocación con @Transactional:
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class PaymentService {
private final PaymentRepository paymentRepository;
public PaymentService(PaymentRepository paymentRepository) {
this.paymentRepository = paymentRepository;
}
public void payAll(List<Long> paymentIds) {
paymentIds.forEach(this::pay);
}
@Transactional
public void pay(Long paymentId) {
var payment = paymentRepository.findById(paymentId).orElseThrow();
payment.markPaid();
paymentRepository.save(payment);
}
}
A primera vista, cada llamada a pay se ejecutará en una transacción. En realidad, no. Spring aplica @Transactional a través de un proxy que envuelve al bean. Cuando payAll llama a this.pay(...), la llamada va directamente al objeto original y evita el proxy. Por lo tanto, no se abre ninguna transacción.
Hay dos formas comunes de arreglarlo:
- Coloca
@Transactionalen el método que se llama desde fuera, es decir,payAll, si quieres que todo el lote se ejecute en una transacción. - Mueve
paya un bean diferente, por ejemplo,SinglePaymentProcessor, e inyéctalo enPaymentServicepara que la llamada pase por el proxy.
Entender el mecanismo de proxy te ayuda a explicar muchos otros fenómenos: @Async que no se ejecuta de forma asincrónica, @Cacheable que no cachea, o anotaciones en métodos private que se ignoran. Este es el conocimiento base que te ayuda a depurar más rápido que cualquier herramienta de sugerencia.
7. Observar el sistema en lugar de adivinar
El código que funciona correctamente en tu máquina no significa que funcione correctamente en producción. Un junior que sepa leer logs, métricas y health checks tendrá mucho más valor que muchas personas que solo saben escribir características. Con Spring Boot, empiezas con Actuator:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
Luego configura los endpoints que necesitas:
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: when-authorized
logging:
level:
root: INFO
org.springframework.transaction: DEBUG
El significado de cada parte:
exposure.includeabre solo los tres endpoints necesarios. Abrirlo todo con"*"es un error de seguridad común.show-details: when-authorizedsolo muestra detalles de salud para usuarios autenticados.- Activar
DEBUGparaorg.springframework.transactionpermite ver en los logs cuándo se abre y confirma una transacción. Esta es una forma de verificar directamente el error de auto-invocación de la sección 6.
Verificación rápida:
curl http://localhost:8080/actuator/health
{"status":"UP"}
El hábito a cultivar aquí es: cuando hay un error, recopila evidencia de logs, métricas y stack traces antes de corregir. No intentes aleatoriamente cada fragmento de código sugerido hasta que el error desaparezca por sí solo.
8. Usar el asistente de código correctamente y cuándo no usarlo
El asistente de código es una herramienta útil. Evitarlo no te ayuda a progresar más. Lo importante es que mantengas el rol de quien decide. El proceso razonable consta de cuatro pasos:
- Escribe primero tú el requisito y las pruebas principales, como en la sección 4.
- Pide a la herramienta que sugiera la implementación.
- Lee cada línea. Si no puedes explicar una línea, no la fusiones.
- Ejecuta las pruebas, agrega pruebas para casos límite que se te ocurrieron al revisar.
No deberías delegar el trabajo a un asistente de código, o debes ser especialmente cuidadoso, en estos casos:
- Código de seguridad: autenticación, autorización, cifrado, manejo de tokens. Un pequeño error aquí puede tener consecuencias graves, y el código incorrecto a menudo se parece mucho al código correcto.
- Cuando estás aprendiendo un concepto nuevo. Si la herramienta escribe por ti tu primer ejercicio de concurrencia, te perderás exactamente la parte más importante del aprendizaje.
- Datos sensibles: no pegues secretos, información de clientes o código interno en herramientas que tu empresa aún no ha autorizado.
- Negocio específico que la herramienta no tiene contexto, como reglas fiscales o políticas de descuento propias. Primero pregunta a quien entienda el negocio.
Nota: Una señal peligrosa es cuando fusionas código más rápido de lo que lo entiendes. Cuando eso sucede, ya no eres ingeniero, solo estás retransmitiendo código.
9. Trayectoria recomendada para junior
No necesitas aprenderlo todo a la vez. Un orden razonable es:
- Fundamentos del lenguaje: tipos de datos, colecciones,
equals/hashCode, excepciones, concurrencia básica. - Pruebas: JUnit 5, AssertJ, cómo nombrar pruebas por comportamiento, pruebas de casos límite.
- Framework desde adentro: inyección de dependencia, ciclo de vida de beans, proxies, transacciones.
- Datos: SQL real, índices, aislamiento de transacciones, error de N+1 queries con JPA.
- Operaciones: leer logs, métricas, health checks, depuración en entornos cercanos a producción.
- Comunicación: convertir requisitos vagos en criterios de aceptación claros, escribir descripciones de pull requests fáciles de revisar.
La última habilidad a menudo se subestima. Alguien que sabe hacer las preguntas correctas, como “¿y si el pedido está vacío?” o “¿si dos solicitudes llegan al mismo tiempo?”, siempre será necesario, sin importar quién escriba el código o qué herramienta se use.
10. Conclusión
En este artículo, vemos que el valor de un junior dev se está desplazando desde la velocidad de escritura de código hacia la capacidad de evaluar, verificar y entender el sistema. Los ejemplos ilustran errores que el código “que se ve bien” aún comete: imprecisión de double con divisas, race conditions en beans singleton, e @Transactional ignorado debido a auto-invocación. También has visto cómo usar pruebas y Actuator para verificar por ti mismo en lugar de adivinar.
Usa el asistente de código como a un colega que escribe rápido pero es propenso a errores. Aprovechas su velocidad, pero siempre lees de nuevo, escribes pruebas y entiendes cada línea antes de fusionar. Esa es la forma de no ser reemplazado y convertirte en alguien que el equipo no puede prescindir.