Kotlin Lab

Aprender / Diseño de apps móviles en voz alta

Diseño de apps móviles en voz alta

En una entrevista oral con seniors casi siempre aparece una pregunta abierta: "¿Cómo diseñarías…?". No buscan una respuesta correcta. Evalúan cuatro cosas:

  1. Si preguntas antes de diseñar. Un mid que empieza a dibujar capas sin aclarar requisitos suspende aunque el diseño sea bueno.

  2. Si tienes una estructura. Saltar de Room a biometría y de vuelta a Compose transmite desorden.

  3. Si ves los casos difíciles. El happy path lo sabe hacer cualquiera; los errores, la concurrencia y la seguridad son lo que separa a un mid de un junior.

  4. Si nombras los trade-offs. "Haría X porque Y; el coste es Z; si el requisito cambiara, haría W."

Este documento tiene un guion para cualquier pregunta de diseño y tres casos del dominio del equipo de Payments and Taxes, con respuesta modelo y repreguntas.


1. El guion (para cualquier pregunta de diseño)

Unos 15 minutos en total. Dilo en voz alta para que vean la estructura: "Primero voy a aclarar requisitos, luego te cuento las capas y el flujo, y al final errores, seguridad y tests."

PasoTiempoQué haces
1. Requisitos2–3 minPreguntas. Funcionales (qué hace) y no funcionales (offline, latencia, seguridad, escala). Anota lo que te digan.
2. Pantallas y estados2 minQué ve el usuario y en qué estados puede estar cada pantalla. sealed en voz alta.
3. Capas2 minUI → ViewModel → (UseCase) → Repository → Remote/Local. Quién es la fuente de verdad.
4. Flujo feliz2 minUn recorrido de principio a fin: qué llama a qué y qué viaja entre capas.
5. Errores y bordes3 minRed, timeouts, doble tap, process death, concurrencia, datos inconsistentes. Aquí está la nota.
6. Seguridad1–2 minAutenticación, datos sensibles, almacenamiento, logs. En banca no es opcional.
7. Tests y observabilidad1 minQué testearías en cada capa y qué medirías en producción.
8. Fuera de alcance30 sQué dejarías para después y por qué. Demuestra criterio.

Frases que ayudan:

  • "Antes de diseñar, ¿puedo hacer un par de preguntas sobre el alcance?"

  • "Voy a asumir X; si no es así, cambia esta parte."

  • "Aquí hay un trade-off: …"

  • "Esto lo dejaría fuera de la primera versión porque…"

  • "En Kontor lo resolví así…" (si aplica, usa tu proyecto).


2. Caso 1 — Enviar una transferencia SEPA

"Diseña el flujo de enviar una transferencia: formulario, revisión, confirmación."

Requisitos que deberías preguntar

  • ¿SEPA normal o también SEPA Instant? (Instant es casi en tiempo real; la normal puede tardar hasta el siguiente día hábil.)

  • ¿Hay autenticación reforzada (SCA de PSD2)? ¿Con biometría, con la app de autorización, con ambas?

  • ¿Límites diarios? ¿Quién los valida?

  • ¿Se pueden guardar beneficiarios?

  • ¿Qué pasa si el usuario sale de la app a mitad?

Respuesta modelo

Pantallas y estados. Tres pasos: formulario, revisión y resultado. El ViewModel expone un único StateFlow<TransferUiState> con el paso actual, los campos, los errores de validación y un submission de tipo sealed: Idle, Submitting, Succeeded(receipt), Failed(error) y Unknown. Este último lo explico después y es la clave del caso.

Capas.

TransferScreen ──eventos──▶ TransferViewModel ──▶ SubmitTransferUseCase ──▶ TransferRepository
      ◀──── UiState ─────────────┘                                        ├─ TransferApi (Retrofit)
                                                                          └─ PendingTransferDao (Room)
  • El formulario se valida en el cliente para dar feedback inmediato (formato de IBAN, importe > 0, longitud de la referencia), pero el servidor es quien manda: saldo, límites, fraude y bloqueos se validan allí.

  • Lo que escribe el usuario va en SavedStateHandle: sobrevive a process death a mitad de formulario.

Flujo feliz. El usuario confirma → el ViewModel genera una idempotency key (un UUID) al entrar en el paso de revisión, no al pulsar el botón → pasa a Submitting → el UseCase llama al repositorio → POST /transfers con la key en una cabecera → el servidor devuelve un recibo → Succeeded.

Errores y bordes (la parte importante).

  • Doble tap: el botón se deshabilita en Submitting, pero eso no basta: la idempotency key garantiza que dos peticiones iguales producen una sola transferencia.

  • Timeout: "un timeout no significa que la transferencia haya fallado". La petición pudo llegar al servidor y ejecutarse. Por eso existe el estado Unknown: no le digo al usuario "ha fallado, vuelve a intentarlo" porque podría pagar dos veces. Reintento con la misma key o consulto el estado de la operación por esa key. El servidor devuelve el resultado original si ya la procesó.

  • Errores de negocio (saldo insuficiente, límite superado, IBAN bloqueado): vienen del servidor como errores tipados y se muestran en el paso correspondiente, no como un "algo ha ido mal" genérico.

  • El usuario sale de la app durante el envío: el envío no puede vivir en viewModelScope, que se cancelaría. Lo lanzo en un scope de aplicación y guardo la operación pendiente (key, importe, destinatario) en Room. Al volver, la pantalla muestra el estado real consultando por la key.

  • Process death con una operación pendiente: como está en Room, al arrancar se puede reconciliar con el servidor.

Seguridad.

  • SCA antes de enviar: BiometricPrompt con un CryptoObject ligado a una clave del Android Keystore, de modo que la firma de la operación demuestre que el usuario autenticó en ese dispositivo. Sin CryptoObject la biometría solo es un "sí/no" que se puede saltar.

  • Nada de IBAN completos ni importes en logs ni en analytics.

  • FLAG_SECURE en las pantallas de revisión y resultado para que no salgan en capturas ni en el selector de apps recientes.

Tests.

  • Dominio: validaciones del formulario en JVM.

  • ViewModel: con un fake repository que puede devolver éxito, error de negocio y timeout. El test clave: "tras un timeout, reintentar envía la misma idempotency key".

  • Repositorio: MockWebServer para mapear códigos HTTP a errores tipados.

  • UI: un test de Compose del paso de revisión.

Fuera de alcance: beneficiarios guardados, transferencias programadas (caso 2) y plantillas.

Repreguntas de los seniors

  • ¿Por qué generas la key al entrar en revisión y no al pulsar el botón? Porque si la genero al pulsar, un segundo tap tras un timeout generaría otra key: el servidor vería dos operaciones distintas. La key identifica la intención de pagar esto, no el click.

  • ¿Y si el usuario cambia el importe tras un timeout? Entonces es otra operación: nueva key. Pero antes tengo que resolver el estado de la anterior; no dejaría editar mientras haya una operación en Unknown.

  • ¿Por qué no WorkManager para el envío? Para algo que el usuario está mirando y que necesita su autenticación en el momento, un scope de aplicación basta y la respuesta es inmediata. WorkManager tiene sentido para la reconciliación de pendientes en segundo plano, que sí debe completarse aunque se mate el proceso.

  • ¿Validas el IBAN en cliente con mod 97? Sí, para dar feedback rápido y ahorrar una llamada, pero sabiendo que el servidor vuelve a validar. El cliente nunca es la fuente de verdad en reglas de negocio.

  • ¿Cómo sabes si el error es de red o de negocio? El repositorio mapea: excepción de I/O o timeout → error de red (reintentable); 4xx con un código de negocio → error tipado (no reintentable); 5xx → error de servidor (reintentable con backoff). Un sealed interface TransferError.


3. Caso 2 — Pagos programados y offline

"El usuario quiere programar transferencias para una fecha futura, verlas y cancelarlas. Tiene que funcionar razonablemente sin conexión."

Requisitos que deberías preguntar

  • ¿Pagos únicos o también periódicos (cada mes)?

  • ¿Hasta cuándo se puede cancelar? ¿El día anterior a las 23:59? ¿Quién lo decide?

  • ¿Qué significa exactamente "sin conexión"? ¿Ver, crear, cancelar?

  • ¿Hay que avisar al usuario antes de que se ejecute?

Respuesta modelo

La decisión central: "el cliente nunca ejecuta un pago programado". El servidor guarda la orden y la ejecuta en la fecha indicada. La app solo crea, muestra y cancela órdenes. Si el móvil está apagado ese día, el pago se hace igual.

Pantallas y estados. Una lista agrupada por fecha, un detalle con botón de cancelar y el formulario de creación (reutiliza el del caso 1, con un selector de fecha y periodicidad).

Capas y fuente de verdad.

  • Room es la fuente de verdad para la UI: la lista observa ScheduledPaymentDao.observeAll(): Flow<List<…>>.

  • El servidor es la fuente de verdad del negocio: Room es una caché que se sincroniza.

  • Cada fila tiene un syncState: Synced, PendingCancel o Failed.

Offline: qué permito y qué no.

AcciónSin conexiónPor qué
Ver la lista y el detalle✅ Desde la caché, con la hora de la última sincronizaciónEs información, no dinero
Crear un pago programado❌ Bloqueado con un mensaje claroNecesita SCA y validación de servidor (saldo, límites)
Cancelar⚠️ Se marca PendingCancel, pero se avisa de que no está garantizado hasta sincronizarSi la fecha límite pasa antes de que haya conexión, el pago se ejecuta

Esta tabla en voz alta es lo que más puntúa: demuestra que piensas en el usuario y en el dinero, no solo en la tecnología.

Sincronización.

  • Al abrir la pantalla: muestro la caché y revalido con el servidor (stale-while-revalidate).

  • Cancelaciones pendientes: un OneTimeWorkRequest de WorkManager con restricción NetworkType.CONNECTED, backoff exponencial y la idempotency key de la cancelación. Sobrevive a process death y a reinicios.

  • Si el servidor rechaza la cancelación (fecha límite superada), la fila vuelve a Synced y se notifica al usuario.

Fechas. Las fechas de ejecución son LocalDate (días, no instantes) y la zona horaria que manda es la del banco, no la del móvil. Un usuario de viaje en otra zona horaria no puede mover el día de un pago.

Recordatorios. Una notificación local el día anterior, programada con WorkManager. Si el servidor envía push, mejor: así no depende de que el móvil haya sincronizado.

Tests. DAO con Room in-memory (orden, agrupación por fecha). ViewModel con un fake que simula estar sin conexión. Worker con TestListenableWorkerBuilder: si la red falla devuelve retry; si el servidor rechaza, failure y la fila vuelve a Synced.

Fuera de alcance: editar un pago programado (se puede modelar como cancelar y crear) y periodicidades complejas.

Repreguntas de los seniors

  • ¿Por qué no dejas crear offline y lo envías al recuperar conexión? Porque crear una orden de pago necesita autenticación reforzada en el momento y validación de saldo y límites en el servidor. Un pago "aceptado" offline que luego el servidor rechaza es peor experiencia que decir "necesitas conexión".

  • ¿Y si el usuario cancela offline y el pago se ejecuta antes de sincronizar? Por eso la UI dice que la cancelación está pendiente y no garantizada. Cuando llegue la respuesta del servidor, la fila refleja la realidad: ejecutado.

  • ¿AlarmManager o WorkManager para el recordatorio? WorkManager: es la API recomendada para trabajo diferible y garantizado, y respeta Doze. AlarmManager con alarmas exactas solo si la hora exacta es crítica, y desde Android 12 necesita un permiso especial. Un recordatorio "el día anterior" no lo necesita.

  • ¿Cómo evitas que la lista parpadee al sincronizar? La UI observa Room, no la respuesta de red. La sincronización escribe en Room dentro de una transacción (upsert + borrar lo que ya no existe) y el Flow emite una sola vez con el resultado final.

  • ¿Paginación? Para pagos programados, el volumen es pequeño: no la pondría. Si fuera el histórico de movimientos, Paging 3 con RemoteMediator.


4. Caso 3 — Documentos fiscales

"Los usuarios deben poder ver y descargar sus certificados fiscales anuales (PDF) desde la app."

Requisitos que deberías preguntar

  • ¿Qué tamaño tienen? ¿Cuántos por usuario?

  • ¿Se pueden ver sin conexión una vez descargados?

  • ¿El usuario debe poder compartirlos (por ejemplo, con su gestor)?

  • ¿Caducan o se pueden regenerar? ¿Puede cambiar un documento ya emitido?

Respuesta modelo

Pantallas. Una lista por año y tipo, con un estado por documento: NotDownloaded, Downloading(progress), Downloaded o Failed. Pulsar abre el PDF; un menú permite compartir.

Capas.

  • La lista de metadatos (id, año, tipo, tamaño, versión o hash) viene de la API y se cachea en Room.

  • Los ficheros se guardan en el almacenamiento interno de la app (filesDir o cacheDir), nunca en almacenamiento compartido: son datos fiscales de una persona.

Descarga.

  • PDFs de pocos MB: descarga con OkHttp en streaming a un fichero temporal y renombrado al terminar, para no dejar nunca un PDF a medias con el nombre bueno. Se lanza desde el ViewModel en un scope que sobrevive a salir de la pantalla.

  • Si fueran grandes o tuvieran que completarse en segundo plano: WorkManager con progreso (setProgress) observado desde la UI.

  • DownloadManager no: está pensado para descargas que el usuario ve en el sistema (notificación, carpeta pública) y no te deja refrescar el token de sesión si caduca a mitad.

Invalidación. Cada documento tiene una versión o un hash. Si el servidor emite una corrección, el hash cambia y el fichero local se marca como obsoleto.

Visualización y compartir.

  • Para ver: PdfRenderer dentro de la app (sin sacar el fichero de ella) o un Intent a un visor externo.

  • Para compartir: FileProvider con una URI content:// y permiso de lectura temporal. Nunca una ruta de fichero directa.

  • Antes de compartir, un aviso: "vas a sacar un documento con datos fiscales de la app".

Seguridad.

  • Ficheros en almacenamiento interno: otras apps no pueden leerlos.

  • Cifrado en reposo adicional si el banco lo exige: clave en el Android Keystore y cifrado con Tink.

  • Borrar los ficheros al cerrar sesión.

  • FLAG_SECURE en el visor.

  • La URL de descarga necesita el token de sesión; si caduca a mitad, refresco el token y reanudo la descarga.

Tests. El repositorio con MockWebServer: descarga correcta, corte a mitad (no queda fichero corrupto) y error 401 con refresco de token. ViewModel: transiciones de estado con progreso. Un test de que el logout borra los ficheros.

Fuera de alcance: búsqueda dentro de los PDFs, anotaciones y firma.

Repreguntas de los seniors

  • ¿Por qué no guardas el PDF en Descargas para que el usuario lo encuentre? Porque en almacenamiento compartido cualquier app con permiso puede leerlo, y no lo puedo borrar al cerrar sesión. Si el usuario quiere sacarlo, lo comparte de forma explícita con FileProvider.

  • ¿Cómo muestras el progreso? OkHttp me da el Content-Length y voy contando bytes al escribir; emito el progreso por un Flow o, con WorkManager, con setProgress. Lo limito a unas pocas actualizaciones por segundo para no recomponer en cada bloque leído.

  • ¿Qué pasa si se corta la conexión al 80 %? El fichero temporal se borra o se reanuda con una cabecera Range si el servidor la soporta. Lo importante es que nunca aparezca como Downloaded un fichero incompleto: el renombrado atómico al final lo garantiza.

  • ¿Cachearías todos los documentos de golpe? No: solo bajo demanda. Son datos sensibles y ocupan espacio; descargar lo que el usuario no ha pedido aumenta riesgo y consumo sin beneficio.

  • ¿Cómo detectas que un documento cambió? Comparando la versión o el hash de los metadatos con lo que guardé en Room al descargarlo. Si no coinciden, lo marco como desactualizado y lo vuelvo a descargar al abrirlo.


5. Temas que salen en cualquier caso de banca

Ten una frase preparada para cada uno:

TemaFrase
Dinero"Céntimos en Long o BigDecimal, nunca Double, y la moneda junto al importe."
Fuente de verdad"El servidor manda en el negocio; Room manda en lo que ve la UI."
Idempotencia"Toda escritura de dinero lleva una idempotency key; un timeout no es un fallo."
Autenticación"SCA con BiometricPrompt y un CryptoObject del Keystore, no un sí/no."
Datos sensibles"Nada de PII en logs ni analytics; almacenamiento interno; FLAG_SECURE en pantallas sensibles."
Red"Errores tipados en el repositorio: red, servidor y negocio no se tratan igual."
Certificate pinning"Lo valoraría, sabiendo que obliga a rotar pines con cuidado o te quedas sin app."
Release"Feature flag y staged rollout; vigilar crash rate y errores de negocio antes de subir el porcentaje."
Accesibilidad"Semántica en importes y estados, tamaños táctiles y contraste. En banca también es un requisito legal."

6. Errores típicos al responder

  • Empezar a diseñar sin hacer ni una pregunta.

  • Hablar 10 minutos de Compose y 30 segundos de errores.

  • Decir "lo valido en el cliente" sin añadir "y el servidor vuelve a validar".

  • Tratar un timeout como un fallo y ofrecer "reintentar" sin idempotencia.

  • Proponer tecnología sin decir por qué: "uso WorkManager" frente a "uso WorkManager porque tiene que completarse aunque se mate el proceso".

  • No decir qué dejas fuera.

  • Quedarte callado al pensar. Piensa en voz alta: "estoy dudando entre A y B; me inclino por A porque…".


7. Cómo practicarlo

  1. Lee un caso entero una vez.

  2. Al día siguiente, sin mirar, responde en voz alta con el guion de la sección 1 y grábate. Máximo 15 minutos.

  3. Compara con la respuesta modelo: ¿qué errores y bordes se te olvidaron?

  4. Responde las repreguntas sin mirar.

  5. Repite con el siguiente caso. Al tercer día, pídeme un caso nuevo que no esté aquí: hago de entrevistador y repregunto según lo que digas.

Fuente: docs/MOBILE_DESIGN.md