Arquitectar una app real: el plano completo de un producto
El ejercicio culminante del track: diseñar de principio a fin una app con cuentas de usuario, datos locales, sincronización entre dispositivos y presencia fuera de la app. No una lista de tecnologías, sino el orden de las decisiones —qué se decide primero porque condiciona todo lo demás, qué se aplaza deliberadamente y qué errores de secuencia cuestan una reescritura entera.
Arquitectar no es elegir un patrón. Es ordenar decisiones. Casi toda la literatura sobre arquitectura de apps discute qué elegir —MVVM o TCA, SwiftData o Core Data, CloudKit o servidor propio— y casi ninguna discute en qué orden elegirlo, que es donde se juega el resultado. Hay decisiones cuyo coste de cambio crece de forma explosiva con el tiempo y decisiones que puedes revertir un martes por la tarde; confundirlas es la patología más cara del oficio. Esta lección recorre el plano completo de un producto realista —una app de tareas compartidas con cuentas, datos locales, sincronización y widgets— pero el objeto de estudio no es la app: es la secuencia. Al final deberías poder mirar cualquier propuesta de producto y decir con confianza qué hay que cerrar en la primera semana y qué sería una insensatez decidir tan pronto.
- Ordenar las decisiones de arquitectura por su coste de reversión en lugar de por su atractivo técnico.
- Establecer el corte entre dominio, datos y presentación, y defender esa frontera con reglas de dependencia comprobables.
- Diseñar la sincronización como problema de modelo de datos, no como una integración que se añade al final.
- Trazar un plan de ejecución por fases donde cada una entrega algo publicable y verifica una hipótesis del plano.
Fijemos el producto para tener algo concreto sobre lo que razonar. Una app de listas compartidas: la persona crea tareas sin cuenta y sin conexión desde el primer segundo, puede opcionalmente iniciar sesión para tener sus datos en todos sus dispositivos, puede compartir una lista con otra persona, y quiere ver sus tareas de hoy en un widget y añadir alguna por voz. Es deliberadamente modesto y contiene, sin embargo, todos los problemas duros del oficio: identidad, offline primero, resolución de conflictos, límites de proceso y una superficie fuera de la app.
El orden correcto de las decisiones
La primera pregunta no es qué patrón usar, es cuál es el modelo de dominio y qué invariantes debe respetar. Una tarea pertenece a una lista; una lista tiene una persona propietaria y cero o más invitadas; una tarea completada conserva quién la completó y cuándo. Esas frases, escritas antes de abrir Xcode, deciden más sobre la app que cualquier framework, porque son las que sobrevivirán a las tres reescrituras de la capa de presentación que ese producto va a sufrir en cinco años. El dominio es la decisión más cara de revertir y por eso va primero.
La segunda es la topología de la verdad: dónde nace un dato, quién puede modificarlo y cómo se resuelve el desacuerdo. En nuestro producto la respuesta es local primero: la fuente de verdad de escritura es el dispositivo, el servidor es un replicador y la interfaz nunca espera a la red para confirmar una acción. Esta decisión también es casi irreversible, porque una app diseñada asumiendo que el servidor manda tiene la lógica de estados de carga esparcida por todas las vistas y convertirla a local primero es una reescritura, no una refactorización.
La tercera es la identidad y la cuenta, que suele decidirse tarde y debería decidirse tercera. Si la app funciona sin cuenta y la cuenta llega después, existe un momento de fusión —los datos anónimos del dispositivo se incorporan a una cuenta recién creada— que hay que diseñar explícitamente. Aplazar esa pregunta produce la clase de fallo que enfurece: alguien usa la app tres semanas, se registra y sus tareas desaparecen.
Solo después de esas tres viene el patrón de presentación, que es —y conviene decirlo sin ceremonia— la decisión más reversible de todas. Cambiar una pantalla de MV a MVVM es trabajo de una tarde por pantalla. Elegir mal el modelo de dominio es trabajo de un trimestre.
Ante cualquier decisión, pregúntate qué costaría revertirla dentro de dieciocho meses con diez mil personas usando la app. Si la respuesta es una migración de datos en producción, decide despacio y por escrito. Si es tocar unos cuantos archivos, decide rápido y sigue. Este único criterio ordena mejor una agenda de arquitectura que cualquier debate sobre patrones, y tiene la virtud de ser comprobable en lugar de opinable.
Las capas y la regla de dependencia
Con el dominio fijado, la estructura se sigue casi sola. Tres capas y una única regla: las dependencias apuntan siempre hacia el dominio. El dominio no importa nada del sistema; contiene tipos, invariantes y reglas puras que se pueden probar sin simulador. La capa de datos implementa los protocolos que el dominio declara. La presentación depende del dominio y jamás de la implementación concreta de datos.
// Dominio: puro, sin SwiftData, sin URLSession, sin SwiftUI.
struct Tarea: Identifiable, Sendable, Equatable {
let id: UUID
var titulo: String
var completada: Bool
var actualizadaEn: Date
var listaID: UUID
}
protocol RepositorioTareas: Sendable {
func tareas(de lista: UUID) async throws -> [Tarea]
func guardar(_ tarea: Tarea) async throws
func observarCambios(de lista: UUID) -> AsyncStream<[Tarea]>
}
// Datos: la implementacion vive en otro modulo y nadie la importa
// salvo el punto de composicion de la app.
actor RepositorioSwiftData: RepositorioTareas { /* ... */ }
Ese AsyncStream merece un comentario porque es la costura donde el nivel 15 se encuentra con el nivel 11: el repositorio no devuelve una foto, devuelve un flujo, y la vista se suscribe a él con .task. Así el plano de datos empuja hacia el plano de presentación sin que la presentación tenga que preguntar periódicamente, y el estado de la pantalla vuelve a ser lo que debe ser, una proyección.
La modularización del nivel 17 se vuelve entonces mecánica: un paquete para el dominio, uno por cada implementación de datos, uno por área funcional de la interfaz y uno de composición que es el único que conoce a todos. La regla de dependencia deja de ser una intención y pasa a ser algo que el compilador verifica, porque un módulo que no importa a otro sencillamente no puede llamarlo. Es la única forma conocida de que una frontera arquitectónica sobreviva a la presión de una fecha de entrega.
flowchart TD UI[Modulos de interfaz] --> DOM[Dominio: tipos reglas protocolos] DATA[Modulo de datos local] --> DOM SYNC[Modulo de sincronizacion] --> DOM WID[Extension de widget] --> DOM APP[Modulo de composicion] --> UI APP --> DATA APP --> SYNC style DOM fill:#a6e3a1,color:#11111b style APP fill:#f9e2af,color:#11111b style UI fill:#89b4fa,color:#11111b style SYNC fill:#fab387,color:#11111b
La sincronización define la arquitectura
Aquí está el corazón duro del producto y la razón por la que la sincronización no puede añadirse al final: cambia el modelo de datos. Una app local necesita saber el estado actual de una tarea. Una app sincronizada necesita saber además cuándo cambió cada campo, qué dispositivo lo cambió, qué cambios están pendientes de subir y qué hacer cuando dos versiones se contradicen. Eso no es una capa que se apoya encima: son columnas nuevas, invariantes nuevos y un ciclo de vida nuevo para cada registro.
El diseño que funciona en la inmensa mayoría de productos de esta escala es el de cola de operaciones locales. La interfaz nunca escribe en la red: escribe en el almacén local y encola una operación. Un componente separado drena esa cola cuando hay conexión, con reintentos y retroceso exponencial. La reconciliación se resuelve con una regla escrita —último cambio gana por campo, con marca de tiempo del servidor para evitar relojes mentirosos— y los casos que esa regla no cubre se elevan a la persona usuaria en lugar de resolverse en silencio.
// La operacion es un dato, no una llamada. Se persiste, se reintenta,
// se inspecciona y se prueba sin red.
enum OperacionPendiente: Codable, Sendable {
case crear(Tarea)
case actualizarTitulo(id: UUID, titulo: String, en: Date)
case completar(id: UUID, por: UUID, en: Date)
case borrar(id: UUID, en: Date)
}
protocol ColaDeSincronizacion: Sendable {
func encolar(_ operacion: OperacionPendiente) async
func drenar() async // idempotente y cancelable
}
Modelar las operaciones como datos tiene tres consecuencias que compensan de sobra su coste. La primera es que el borrado deja de ser destructivo: una eliminación es una operación con marca de tiempo, no la ausencia de una fila, porque de otro modo un dispositivo desconectado resucitaría lo borrado al reconectar. La segunda es que la app se vuelve probable sin red: se encolan operaciones, se drena contra un doble de prueba y se afirma sobre el resultado. Y la tercera es que la interfaz obtiene gratis su estado de sincronización —hay operaciones pendientes o no— sin que ninguna vista sepa nada de la red.
Confiar en el reloj del dispositivo para ordenar cambios: un usuario con la fecha mal puesta corrompe el orden de todos. Tratar el borrado como ausencia de dato en lugar de como un hecho con fecha: los registros reaparecen. Y hacer que la interfaz espere la confirmación del servidor para mostrar el cambio: la app se siente lenta y, en el metro, rota. Las tres se evitan en el momento del diseño y ninguna se arregla barata después.
El plan de ejecución por fases
Un plano sin secuencia de construcción es un dibujo. Las fases que siguen tienen una propiedad deliberada: cada una termina en algo publicable y cada una verifica una hipótesis distinta del plano, de modo que si el plano está equivocado te enteras pronto y no en el mes ocho.
La fase uno es la app local completa, sin cuenta y sin red: dominio, repositorio local, las pantallas principales y las pruebas del dominio. Verifica que el modelo aguanta el uso real. La fase dos añade cuenta y sincronización con la cola de operaciones, incluida la fusión de datos anónimos al registrarse; verifica la hipótesis más cara del producto. La fase tres añade compartir listas, que es donde aparecen los permisos entre personas y los conflictos verdaderos. La fase cuatro extiende la app fuera de sí misma —widget y App Intents— y verifica que las fronteras de módulos eran reales, porque una extensión con presupuesto de memoria estrecho no puede arrastrar media app para pintar tres tareas. La fase cinco es endurecimiento: accesibilidad auditada, rendimiento medido con Instruments, observabilidad y el ciclo de publicación automatizado.
Primero el dominio
Tipos, invariantes y reglas puras antes que cualquier framework. Es lo más caro de revertir, por eso se decide antes.
Local primero
La escritura nace en el dispositivo y la interfaz nunca espera a la red. Decidirlo tarde equivale a reescribir la app.
Operaciones como datos
La cola persistida hace la sincronización inspeccionable, reintentable y probable sin conexión.
Fronteras compiladas
Un módulo que no importa a otro no puede llamarlo. Es la única forma de que una capa sobreviva a una fecha de entrega.
Hay una asimetría que atraviesa todo lo anterior y que, una vez vista, reorganiza la forma de trabajar: las decisiones de arquitectura no se distribuyen de manera uniforme en coste, sino que se reparten en dos poblaciones con órdenes de magnitud de diferencia entre ellas. Un puñado son casi irreversibles porque tienen datos de personas reales del otro lado —la forma del modelo de dominio, la dirección en la que fluye la verdad, el significado de la identidad y de la fusión de cuentas, el hecho de que un borrado sea un evento y no una ausencia— y su coste de cambio no crece de forma lineal con el tiempo, sino con el número de instalaciones que ya guardaron datos según la regla vieja. La inmensa mayoría, en cambio, son baratas hasta lo trivial: qué patrón de presentación, cómo se llama un módulo, si esta pantalla usa un modelo de vista o no, qué biblioteca de red. El error que arruina proyectos no es elegir mal en la segunda población, porque ahí equivocarse cuesta una tarde; es gastar la energía de diseño en la segunda mientras la primera se decide por accidente, en un commit del martes, sin que nadie note que acaba de fijar para siempre cómo se comportarán dos dispositivos que se contradicen. De ahí se sigue la definición operativa: arquitectar es identificar el pequeño conjunto de decisiones irreversibles, cerrarlas despacio, por escrito y con las consecuencias enunciadas, y a continuación defender agresivamente el derecho a no decidir el resto hasta que exista información suficiente. Esa defensa tiene un nombre técnico —las fronteras— y una implementación concreta: los protocolos del dominio y los módulos que el compilador vigila. Un protocolo bien puesto es exactamente una decisión aplazada de forma segura, porque permite que la implementación se elija más tarde, se sustituya en una prueba y se cambie sin tocar a quien la usa. Un módulo bien cortado es un compromiso de no depender de lo que aún no sabes. Por eso una arquitectura sana no se reconoce por su elegancia en el diagrama, sino por cuántas cosas importantes puede cambiar tarde y barato; y por eso los productos que mueren de rigidez casi nunca murieron por elegir el patrón equivocado, sino por haber dejado que preguntas irreversibles se respondieran solas mientras el equipo discutía la reversible.
Ordena por coste de reversión, no por atractivo. Primero el dominio con sus invariantes, después la topología de la verdad —local primero para este producto—, después la identidad y la fusión de cuentas, y solo entonces el patrón de presentación, que es lo más reversible. Tres capas con una regla: las dependencias apuntan al dominio, y los módulos hacen que el compilador la verifique. La sincronización no es una capa añadida: cambia el modelo, exige marcas de tiempo, borrados como eventos y una cola de operaciones persistida. Y el plan avanza por fases publicables, cada una verificando una hipótesis del plano.
- Escribe en una página, sin nombrar una sola tecnología, el modelo de dominio de tu app: entidades, relaciones e invariantes que deben cumplirse siempre.
- Clasifica veinte decisiones técnicas de tu proyecto en irreversibles y baratas según la prueba de los dieciocho meses, y comprueba cuántas horas de discusión se dedicaron a cada grupo.
- Diseña la fusión de datos anónimos al crear una cuenta y enumera qué ocurre si la persona ya tenía datos en esa cuenta desde otro dispositivo.
- Modela como enumeración las operaciones pendientes de tu dominio y define para cada una la regla de resolución de conflictos, incluido el borrado.
- Dibuja tu grafo de módulos y señala cada flecha que apunta en dirección contraria al dominio; cada una es una frontera que el compilador ya no puede defender.