Zero-login con SwiftData y CloudKit: un diario privado#
Un diario personal es probablemente la peor categoría de app para poner una pantalla de registro. El usuario abre la app para escribir sobre el día más vulnerable del mes, y lo primero que ve es "Crea una cuenta", "Elige una contraseña", "Verifica tu email". No es una hipótesis: es exactamente el paso donde perdería parte de la audiencia de Lanternly, una app de diario actualmente en desarrollo como competidora de Day One.
La solución a la que llegué es zero-login: la app no tiene ningún concepto de "cuenta". El perfil vive localmente, y la sincronización entre iPhone e iPad ocurre a través de la base de datos privada de CloudKit de la cuenta de iCloud ya conectada en el dispositivo. Sin pantalla de inicio de sesión, sin contraseña, sin servidor que la verifique. Este artículo cubre la arquitectura, las restricciones que SwiftData impone junto a CloudKit, y cómo el zero-login se propaga a decisiones de producto como la exportación e importación de datos.
Por qué zero-login, y no simplemente "Sign in with Apple"#
La primera pregunta que me hicieron colegas fue: "Sign in with Apple es un solo botón, ¿por qué complicarlo?". La diferencia es fundamental. Incluso la autenticación más ligera implica una cuenta — una entidad que hay que registrar en algún lugar, vincular a los datos, gestionar cuando cambia el ID de iCloud, y soportar en tickets ("no puedo iniciar sesión", "mis datos desaparecieron al cambiar de teléfono").
Zero-login elimina toda esa categoría de problema: sin cuenta no hay inicio de sesión, no hay contraseña olvidada, no hay migración al cambiar de dispositivo, porque "dispositivo" e "identidad del usuario" nunca se separan. CloudKit ya sabe quién es el dueño de los datos: quien haya iniciado sesión en iCloud en ese dispositivo. No necesito resolver de nuevo un problema que Apple ya resolvió a nivel de sistema operativo.
Esta decisión no fue gratuita: significa que Lanternly no tiene una ruta de "iniciar sesión con otra cuenta de iCloud y ver tus datos" — solo una transferencia explícita. Para un diario, donde los datos ya son sensibles por naturaleza, considero esto un compromiso razonable y no una carencia.
Arquitectura: SwiftData local, CloudKit como transporte de sincronización#
SwiftData (el sucesor de Core Data anunciado en WWDC23) ofrece almacenamiento local de fábrica, y una ModelConfiguration con el parámetro cloudKitDatabase la convierte en sincronización offline-first sin escribir una sola línea de código de servidor:
import SwiftData
enum JournalStore {
static let cloudContainerID = "iCloud.com.yourteam.yourapp"
static let schema = Schema([
JournalEntry.self,
JournalAttachment.self,
Profile.self,
])
/// Test/CI builds pass `-localStoreOnly` to skip CloudKit entirely —
/// there is no entitlement to sign against in CI, and simulator
/// screenshots would otherwise crash trying to reach a private database.
private static var localStoreOnly: Bool {
#if DEBUG
CommandLine.arguments.contains("-localStoreOnly")
#else
false
#endif
}
@MainActor
static func makeContainer() -> ModelContainer {
guard !localStoreOnly else {
return makeLocalContainer()
}
let cloudConfig = ModelConfiguration(
schema: schema,
cloudKitDatabase: .private(cloudContainerID)
)
// CloudKit can be unavailable for reasons that have nothing to do
// with the schema: no iCloud sign-in, restricted account, missing
// entitlement in a dev build. Falling back to a local-only store
// keeps the app usable instead of crashing on first launch.
if let container = try? ModelContainer(for: schema, configurations: cloudConfig) {
return container
}
return makeLocalContainer()
}
@MainActor
private static func makeLocalContainer() -> ModelContainer {
let localConfig = ModelConfiguration(schema: schema, cloudKitDatabase: .none)
guard let container = try? ModelContainer(for: schema, configurations: localConfig) else {
fatalError("Could not create local ModelContainer: schema is invalid")
}
return container
}
}Fíjate en -localStoreOnly: no es un flag teórico. CloudKit no está disponible en CI ni en las capturas de pantalla del simulador — no hay un entitlement firmado, y a veces tampoco una cuenta de iCloud activa. Sin un fallback local explícito, la app se cae en el primer arranque del entorno de pruebas en lugar de en producción — lo cual es peor, porque disfraza un problema real de "los tests son inestables".
Lo que la compatibilidad con CloudKit te obliga a reescribir en @Model#
Aquí empieza la parte menos agradable de una arquitectura zero-login. CloudKit impone requisitos estrictos sobre el esquema, y SwiftData no puede eludirlos: o bien desactiva silenciosamente una función incompatible, o falla al iniciar el contenedor. Tres restricciones concretas con las que me topé antes de escribir una sola línea de código de sincronización:
- Sin restricciones de unicidad.
@Attribute(.unique)funciona en un almacén SwiftData puramente local, pero deja de estar disponible en cuanto una configuración conecta CloudKit — porque CloudKit no puede garantizar unicidad atómica entre dispositivos que pueden estar offline en cualquier momento. - Todo atributo no opcional debe tener un valor por defecto. Un esquema con un campo obligatorio sin valor por defecto falla al iniciar el contenedor, en lugar de degradar con elegancia.
- Toda relación debe ser opcional — incluidas las relaciones to-many que normalmente harías no-opcionales con un array vacío por defecto en una app puramente local.
import SwiftData
@Model
final class JournalEntry {
var id: UUID = UUID()
var createdAt: Date = Date.now
var text: String = ""
var mood: Int?
var tags: [String] = []
// CloudKit requires every relationship to be optional — even
// to-many ones you would normally default to an empty array.
@Relationship(deleteRule: .cascade, inverse: \JournalAttachment.entry)
var attachments: [JournalAttachment]? = []
// No @Attribute(.unique) here: CloudKit cannot enforce atomic
// uniqueness across devices, so SwiftData disables it the moment
// a CloudKit-backed configuration is used. Uniqueness, if you need
// it, becomes an application-level check instead of a schema one.
init(id: UUID = UUID(), text: String = "", mood: Int? = nil) {
self.id = id
self.text = text
self.mood = mood
}
}La conclusión práctica: los invariantes de negocio que antes vivían a nivel de esquema (un id único, un campo obligatorio) pasan a la validación a nivel de aplicación. Esto no es un fallo de CloudKit, sino una consecuencia de que la sincronización es distribuida: las comprobaciones atómicas de unicidad a través de la red entre dispositivos offline son físicamente imposibles sin un árbitro central, y un árbitro central es exactamente el servidor que una arquitectura zero-login intenta evitar.
La base de datos privada de CloudKit: qué significa realmente "tu propio iCloud"#
Es importante no simplificar en exceso la afirmación "los datos viven en el iCloud del usuario, así que ya son E2E de fábrica" — no es del todo exacto, y prefiero decirlo con honestidad en lugar de convertirlo en un eslogan de marketing.
La base de datos privada de CloudKit (.private(cloudContainerID)) coloca físicamente los datos en el almacenamiento de iCloud del usuario individual — nunca pasan por un servidor que yo controle. Cada registro se cifra con claves generadas en el dispositivo de confianza del usuario antes de subir nada. Pero el cifrado de extremo a extremo completo, donde ni siquiera Apple tiene acceso a las claves, solo se activa cuando el usuario activa Advanced Data Protection en su configuración de iCloud — es una opción que hay que activar, no el comportamiento por defecto. Sin ella, Apple puede técnicamente entregar datos ante una solicitud legal; con ella, Apple físicamente no tiene las claves para entregar.
Para Lanternly, esto significa ser honesto en la descripción de privacidad, en lugar de prometer "cifrado de nivel militar" sin la salvedad. La base de datos privada de CloudKit es sustancialmente mejor que un servidor que yo controlo por completo, pero no es zero-knowledge por defecto — y un desarrollador que afirme lo contrario está engañando a sus usuarios.
Exportación sin candados e importación desde Day One: cómo el zero-login moldea las decisiones de producto#
Zero-login no es solo una decisión de arquitectura de sincronización: es una restricción que se propaga por todo el producto. Si no hay cuenta del lado del servidor, tampoco hay exportación "en la nube" a través de una API de backend — la exportación tiene que ser una operación local, en el propio dispositivo, donde los datos ya viven físicamente.
Para Lanternly, esto se tradujo en una decisión de producto concreta: imprimir el diario en formato libro (PDF y EPUB) se genera enteramente on-device, sin subir texto a un servicio externo de renderizado. Y, igual de importante desde el punto de vista de producto: exportación sin candados — sin límite premium de "solo 10 páginas gratis" ni suscripción obligatoria para recuperar tus propios datos en un formato abierto. Los datos pertenecen al usuario, no a una suscripción.
Importar desde Day One sigue la misma lógica: la app lee la exportación local del competidor y traslada las entradas a su propio modelo SwiftData on-device — de nuevo, sin ningún servidor intermediario que retenga temporalmente las entradas personales de otra persona durante la migración.
Backend propio vs. Firebase vs. CloudKit + SwiftData#
| Criterio | Backend propio | Firebase | CloudKit + SwiftData |
|---|---|---|---|
| Registro de usuario | Propio (email/OAuth) — obligatorio | Firebase Auth — obligatorio | No hace falta: identidad = sesión de iCloud en el dispositivo |
| Dónde viven físicamente los datos | Tu servidor/base de datos | Servidores de Google | Base de datos privada de iCloud del usuario |
| Cifrado E2E por defecto | Depende de tu implementación | No (datos legibles en el servidor de Google) | No por defecto; lo activa el usuario con Advanced Data Protection |
| Infraestructura y DevOps | Responsabilidad total del desarrollador | Gestionada, pero con coste creciente | Ninguna — un contenedor de app, no un servidor |
| Alcance multiplataforma | Cualquiera | iOS/Android/Web | Solo ecosistema Apple |
| Offline-first de fábrica | Hay que construirlo a mano | Caché offline parcial de Firestore | Integrado en SwiftData + ModelConfiguration |
| Restricciones de esquema | Ninguna (base de datos propia) | Esquema NoSQL flexible | Sin constraints de unicidad; opcional/valor por defecto obligatorio |
Conclusión y checklist antes del release#
Zero-login sobre SwiftData y CloudKit no es una receta universal, sino un compromiso deliberado: renunciar al alcance multiplataforma fuera de Apple y a parte de la flexibilidad del esquema, a cambio de no tener servidor, no tener pantalla de login y datos que pertenecen físicamente al usuario. Para una app como un diario personal, que vive enteramente dentro del ecosistema Apple, ese compromiso no se percibe como una limitación, sino como una promesa: "solo tú ves estas entradas".
Antes de llevar esta arquitectura a producción, conviene repasar una checklist breve:
- El esquema de SwiftData es compatible con CloudKit: sin
@Attribute(.unique), todo campo no opcional tiene un valor por defecto, toda relación es opcional - Existe un fallback local explícito si CloudKit no está disponible (sin sesión de iCloud, cuenta restringida, build de desarrollo sin entitlement)
- Los builds de test/CI usan un flag como
-localStoreOnlyen lugar de intentar acceder a un contenedor CloudKit real - La descripción de privacidad indica honestamente cuándo se activa el E2E completo (Advanced Data Protection), en lugar de darlo por hecho
- La exportación de datos es una operación local, en el dispositivo, que no requiere un servidor intermediario
- Las restricciones de producto (paywalls, límites) nunca bloquean el acceso del usuario a sus propios datos
Para más detalles sobre resolución de conflictos y suscripciones de cambios en CloudKit, consulta "CloudKit sin servidor: sincronización offline-first en producción", donde cubro la misma arquitectura aplicada a los datos de salud de MeteoHealth.



