Si llevas Kontor a la entrevista, los seniors lo van a mirar con ojos de code review. Eso es bueno: es la ocasión de demostrar criterio. Lo que hunde a un candidato no es que su proyecto tenga fallos, sino no saber cuáles son.
Este documento es la revisión que te haría un senior de un equipo de pagos, con las referencias exactas al código de ../kontor. Para cada punto tienes por qué importa, cómo lo arreglarías y la frase para decirlo.
Regla de oro: sé tú quien saque los fallos antes de que te los saquen. "Es una demo; si fuera a producción, lo primero que cambiaría es…" es una de las frases más fuertes que puedes decir.
1. Lo que un senior te va a señalar
1.1 La transferencia se envía en viewModelScope
feature/transfer/.../TransferViewModel.kt:176 — onConfirm() lanza el envío con viewModelScope.launch.
Por qué importa: si el usuario pulsa atrás mientras se envía, el ViewModel se destruye, el scope se cancela y la llamada se cancela en el cliente. La transferencia puede haberse ejecutado en el servidor sin que la app lo sepa ni actualice Room.
Arreglo: lanzar el envío en un scope de aplicación inyectado (
@SingletonconSupervisorJob()) y guardar la operación pendiente para reconciliarla. Es lo que practicas en el nivel 8 del lab.Frase: "En Kontor el envío vive en
viewModelScope, así que salir de la pantalla lo cancela. En producción lo movería a un scope de aplicación y guardaría la operación pendiente con su idempotency key."
1.2 No hay idempotency key
TransferViewModel.kt:169 y DefaultBankRepository.submitTransfer.
Por qué importa: un timeout acaba en
submitErrory el usuario puede volver a confirmar. Si la primera petición llegó al servidor, paga dos veces.Arreglo: generar un UUID al entrar en el paso de revisión, guardarlo en
SavedStateHandle, enviarlo en una cabeceraIdempotency-Keyy reutilizarlo en los reintentos. Está planteado como ticket 15 dedocs/KONTOR_TICKETS.md.Frase: "Lo tengo documentado como gap: un timeout se trata como error y no hay idempotency key, así que un reintento podría duplicar el pago."
1.3 El formulario no sobrevive a process death
TransferViewModel.kt — todo el estado está en un MutableStateFlow; no hay SavedStateHandle.
Por qué importa: si el usuario sale a copiar el IBAN de un correo y el sistema mata el proceso, al volver ve el formulario vacío.
Arreglo: guardar en
SavedStateHandlelo que escribe el usuario, la cuenta elegida (su ID) y el paso actual. Es el ticket 9.Frase: "El ViewModel sobrevive al giro, pero no a process death; el borrador debería ir en
SavedStateHandle."
1.4 Un timeout se muestra como un fallo
TransferViewModel.kt:201 — cualquier error acaba en submitError = error.message.
Por qué importa: un timeout no significa que la transferencia haya fallado. Decir "no se ha podido completar" puede llevar al usuario a repetirla.
Arreglo: un
sealed interfacede resultado que distingaFailed(rechazada: seguro que no se hizo) deUnknown(timeout: no lo sabemos). AnteUnknown, reintentar con la misma key o consultar el estado por la key.Frase: "Modelaría la incertidumbre en el tipo: rechazada no es lo mismo que desconocida."
1.5 Errores como String y mensajes de excepción en la UI
TransferUiState.submitError: String? (TransferViewModel.kt:49) y los require/error() de DefaultBankRepository.submitTransfer (línea 78).
Por qué importa: la UI enseña
exception.message, un texto técnico en inglés y sin traducir. Y la UI no puede reaccionar distinto a "saldo insuficiente" que a "sin conexión".Arreglo: un
sealed interface TransferError(InsufficientFunds,Network,Server,Rejected(code)) que el repositorio mapea desde HTTP y excepciones, y la UI traduce astringResource.Frase: "Los errores esperados deberían ser tipos, no strings; así la UI decide el mensaje y se puede traducir."
1.6 El cliente decide el saldo final
DefaultBankRepository.kt:78 valida contra el availableCents de la caché y la línea 86 escribe en Room account.balanceCents - request.amountCents.
Por qué importa: el saldo en caché puede estar desactualizado (otra transferencia desde la web, un cargo). El cliente valida y calcula el saldo nuevo con datos que quizá no son reales.
Arreglo: la validación del cliente es solo para dar feedback rápido; la autoritativa es la del servidor. Tras una transferencia, el saldo nuevo viene en la respuesta del servidor o se refresca la cuenta.
Frase: "Valido en cliente por experiencia de usuario, pero el servidor manda; en producción, el saldo nuevo vendría de su respuesta."
Matiz que puedes añadir: el backend de la demo es estático, por eso hay que preservar los saldos locales en
refresh()(línea 61). Explícalo tú antes de que te lo pregunten.
1.7 La fecha de la operación se inventa si falla el parseo
DefaultBankRepository.kt:82 — runCatching { Instant.parse(…) }.getOrElse { Instant.now() }.
Por qué importa: si el servidor manda una fecha mal formada, la app la sustituye por la hora del móvil sin avisar. En un extracto bancario, una fecha inventada es un dato falso, y además esconde un bug del backend.
Arreglo: tratarlo como error de contrato: registrarlo (sin datos personales) y mostrar la operación como pendiente de confirmar, o fallar el mapeo.
Frase: "Preferiría fallar de forma visible a inventarme una fecha en un movimiento bancario."
1.8 La app no se bloquea al pasar a background
AppViewModel.kt:25 — unlocked solo cambia con unlock() y lock().
Por qué importa: en banca lo normal es volver a pedir autenticación tras unos minutos en background. Ahora, si el usuario deja la app abierta, quien coja el móvil entra sin desbloquear (mientras no haya process death).
Arreglo: observar
ProcessLifecycleOwnery bloquear al pasar aON_STOPsi han pasado más de N minutos al volver.Lo que está bien y debes decir: que
unlockedno esté enSavedStateHandlees correcto a propósito: tras process death, la app vuelve bloqueada.
1.9 Sesión y red de demo
core/data/.../remote/AuthInterceptor.kt usa un token fijo y network_security_config.xml no tiene pinning.
Ya está documentado en el propio código: el token vendría de un almacén respaldado por el Keystore y se refrescaría con un
Authenticatorde OkHttp tras un 401.Pinning: di que lo valorarías y nombra el riesgo: si rotan el certificado y no has actualizado los pines, la app deja de funcionar para todos. Se mitiga con pines de respaldo y una fecha de caducidad.
2. Lo que está bien y puedes defender
No todo es autocrítica. Estas decisiones son sólidas y conviene que las nombres:
| Decisión | Dónde | Cómo defenderla |
|---|---|---|
Dinero en céntimos Long, nunca Double | core/domain/.../money/Money.kt | "Los importes son enteros en céntimos; solo se formatean al mostrar." |
resultOf relanza CancellationException | DefaultBankRepository.kt:142 | "No uso runCatching en código suspend porque se traga la cancelación." |
| La transferencia se aplica en una transacción de Room | TransferDao.applyTransfer con @Transaction | "Saldo y movimiento se actualizan juntos o no se actualiza nada." |
refresh() lanza las dos peticiones en paralelo | DefaultBankRepository.refresh | "coroutineScope + async: si una falla, se cancela la otra." |
FLAG_SECURE en release | MainActivity.kt:34 | "Las pantallas no salen en capturas ni en recientes; en debug lo quito para poder hacer capturas." |
allowBackup="false" y sin tráfico en claro | AndroidManifest.xml | "Los datos no se copian en backups de Google y todo va por HTTPS." |
stateIn con WhileSubscribed(5_000) | AppViewModel.kt | "Un giro no reinicia el Flow; en background se para." |
| Dominio sin Android | :core:domain | "Se testea en JVM y podría compartirse con KMP." |
3. Cómo usarlo en la entrevista
En tu pitch de Kontor, termina con un límite: "…y lo primero que cambiaría para producción es el envío de la transferencia: hoy vive en
viewModelScopey no tiene idempotency key." Abre la conversación justo donde tú quieres.Si te preguntan por algo de la lista, reconoce, explica por qué importa y di cómo lo arreglarías. Nunca lo defiendas como si estuviera bien.
Si tienes tiempo antes de la entrevista, haz los tickets 9 y 15 de
docs/KONTOR_TICKETS.md. Pasar de "lo sé" a "lo he arreglado, mira el commit" cambia la conversación.