← Todos los articulos

El verdadero costo de SwiftData es la disciplina del esquema

Género: shipped-code. La publicación documenta las decisiones de esquema de SwiftData en Get Bananas, Return y Reps: tres apps en las que el esquema sobrevivió a una migración limpia o pagó un impuesto por no haber planificado la migración. El ShoppingItem de Get Bananas es el ejemplo canónico. El esquema original no incluía una marca de tiempo lastModified; agregarla después requirió una forma de migración específica porque los datos existentes ya estaban en disco, y el campo se hizo opcional precisamente para corregir un crash de migración que ocurrió cuando se agregó por primera vez como no opcional.1

La API de SwiftData son dos macros. @Model sobre una clase la convierte en un tipo persistente. @Attribute(.unique) sobre una propiedad le otorga una restricción de unicidad. El framework oculta la gestión del stack de Core Data, el baile de los value-transformer y el boilerplate de NSManagedObjectContext. Lo que el framework no oculta es la migración del esquema; simplemente vuelve la migración declarativa en lugar de imperativa. El costo de no prestar atención a las migraciones es el bug que borra los datos de un usuario en una actualización rutinaria.

La tesis: SwiftData es barato para empezar y caro para migrar de forma descuidada. La disciplina está en los nombres, la opcionalidad y VersionedSchema desde el día uno, no el día en que te das cuenta de que deberías haberlo hecho.

TL;DR

  • La macro @Model convierte una clase en un tipo persistente de SwiftData. El framework genera el esquema a partir de las declaraciones de propiedades en tiempo de compilación.
  • Agregar una nueva propiedad opcional es una migración sin operación: la migración ligera de SwiftData se encarga de ello. Agregar una propiedad no opcional a un esquema existente requiere un VersionedSchema más un MigrationPlan que le indique al framework cómo poblar el nuevo campo en las filas existentes.
  • El costo de saltarse VersionedSchema desde el día uno es que cualquier cambio de esquema no trivial en v2 corre el riesgo de eliminar la base de datos de un usuario, porque la ruta ligera es conservadora y se rinde cuando no puede inferir la migración.
  • @Attribute(.unique) es la herramienta correcta para claves naturales (un UUID que generaste, un ID externo que importaste). @Relationship es la herramienta correcta para referencias padre/hijo. Ambas son macros que generan la fontanería de Core Data correcta bajo el capó.2

Lo que @Model realmente hace

Un tipo de SwiftData es una clase Swift con la macro @Model aplicada. El ShoppingItem de Get Bananas es la forma canónica:

import Foundation
import SwiftData

@Model
final class ShoppingItem {
    @Attribute(.unique) var id: UUID
    var name: String
    var amount: String
    var section: String
    var isChecked: Bool
    var isOptional: Bool
    var sortOrder: Int
    var lastModified: Date?

    init(id: UUID = UUID(), name: String, amount: String, section: String,
         isOptional: Bool = false, sortOrder: Int = 0) {
        self.id = id
        self.name = name
        self.amount = amount
        self.section = section
        self.isChecked = false
        self.isOptional = isOptional
        self.sortOrder = sortOrder
        self.lastModified = Date()
    }
}

Tres detalles sobre esa forma que la API oculta.

@Model no requiere una declaración separada de esquema de almacén persistente. SwiftData lee la definición de la clase en tiempo de compilación y sintetiza el esquema. Las propiedades de la clase se convierten en los atributos del modelo; sus tipos Swift se convierten en los tipos de columna. No hay archivo .xcdatamodeld que mantener (aunque el NSManagedObjectModel subyacente de Core Data sigue existiendo y es lo que respalda el esquema en tiempo de ejecución).2

@Attribute(.unique) es una restricción sobre una sola columna, no una declaración PRIMARY KEY. La identidad persistente de SwiftData es el PersistentIdentifier, generado automáticamente por fila. La declaración @Attribute(.unique) le dice al framework “esta columna almacena como máximo una fila por valor”. Cuando insertas un modelo con un valor .unique que ya existe, SwiftData realiza un upsert: la fila existente se actualiza en lugar de ser rechazada. La semántica importa para el código de producto: .unique no es una validación a nivel de UI que impida que se envíen duplicados; es una garantía de almacenamiento de como máximo una fila que silenciosamente fusiona. El patrón id: UUID de arriba es el recomendado para la sincronización entre procesos (donde quieres un identificador estable que sobreviva a la desaparición del PersistentIdentifier en proceso), y el comportamiento de upsert es exactamente lo que quieres cuando el mismo UUID llega desde dos rutas de sincronización.

Las clases @Model son tipos de referencia, no tipos de valor. Mutar una propiedad en una instancia de ShoppingItem dispara el seguimiento de cambios de SwiftData; el framework registra el cambio y lo persiste en el siguiente guardado de contexto. La integración con SwiftUI a través de @Query vuelve a renderizar cualquier vista que observe el predicado coincidente. El patrón es similar a @Observable (cubierto en Lo que SwiftUI realmente es), con la persistencia montada encima.

Los campos opcionales son la migración barata

El campo lastModified: Date? en ShoppingItem es opcional, y la opcionalidad es estructural. El campo se agregó después del lanzamiento de v1 para soportar la sincronización entre dispositivos y la resolución de conflictos; las filas existentes en los dispositivos de los usuarios no tenían un valor lastModified. Un campo opcional sin valor por defecto permite que la migración ligera de SwiftData maneje la adición sin escribir ningún código de migración: las filas existentes obtienen nil; las filas nuevas obtienen lo que sea que el init establezca.3

La ruta de migración ligera es la ruta cortés del framework. SwiftData inspecciona el nuevo esquema y el almacén persistente, infiere el cambio compatible más pequeño y lo aplica. La migración es automática; el usuario no ve nada; la app se inicia normalmente sobre los datos existentes. Los casos que la ruta ligera maneja sin problemas:

  • Agregar una propiedad opcional
  • Eliminar una propiedad (los datos se descartan; las lecturas existentes ya no ven la columna)
  • Renombrar un atributo que el framework puede emparejar mediante una pista (usando @Attribute(originalName: ...))
  • Renombrar una clase @Model que el framework puede emparejar (usando @Model.originalName o una pista)

Los casos en los que la ruta ligera se rinde:

  • Agregar una propiedad no opcional sin valor por defecto a un esquema existente (las filas existentes no tienen valor con el cual poblarla)
  • Cambiar el tipo de una propiedad (por ejemplo, IntString)
  • Dividir un modelo en dos modelos, o fusionar dos en uno
  • Cualquier cosa que requiera lógica personalizada para migrar

Cuando la ruta ligera se rinde, el comportamiento seguro es hacer fallar la migración. El comportamiento inseguro sería eliminar la base de datos y empezar de nuevo; el framework es conservador y se niega a hacer eso silenciosamente. El usuario ve la app crashear al iniciar con un error de migración; el desarrollador ve un stack trace que apunta al desajuste del esquema; nadie pierde datos, pero todos pierden confianza.

El costo de saltarse VersionedSchema desde el día uno aparece en la frontera v2 → v3, cuando agregas la tercera función cuyo cambio de esquema excede lo que la ruta ligera puede manejar.

VersionedSchema y MigrationPlan: la disciplina del día uno

VersionedSchema declara una versión específica del esquema del modelo. MigrationPlan declara cómo migrar de una versión a la siguiente.4 La forma:

import SwiftData

enum SchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] = [ShoppingItemV1.self]
}

enum SchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] = [ShoppingItemV2.self]
}

enum AppMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] = [
        SchemaV1.self,
        SchemaV2.self,
    ]

    static var stages: [MigrationStage] = [
        MigrationStage.lightweight(fromVersion: SchemaV1.self, toVersion: SchemaV2.self)
    ]
}

Las propias clases del modelo pasan al espacio de nombres del esquema versionado:

extension SchemaV1 {
    @Model
    final class ShoppingItemV1 { /* v1 fields */ }
}

extension SchemaV2 {
    @Model
    final class ShoppingItemV2 { /* v2 fields, including lastModified */ }
}

El ModelContainer se construye con el plan de migración:

let container = try ModelContainer(
    for: ShoppingItemV2.self,
    migrationPlan: AppMigrationPlan.self,
    configurations: ModelConfiguration("ShoppingList")
)

El plan de migración le da al framework un grafo tipado de cómo evoluciona el esquema. Cuando la app que envía v2 se inicia contra una base de datos v1, el framework recorre el plan de migración, aplica las etapas nombradas y lleva la base de datos a v2. Cuando lances v3, agregas SchemaV3.self a schemas y un nuevo MigrationStage entre v2 y v3.

La disciplina es enviar VersionedSchema en v1, incluso cuando solo hay una versión. El costo de hacerlo es un archivo extra y una declaración enum extra. El costo de no hacerlo es que el primer cambio de esquema no trivial de v2 requiere envolver retroactivamente v1 en un VersionedSchema, lo cual es factible pero requiere cuidado para emparejar la forma exacta de v1 de modo que el framework pueda identificar los datos existentes como SchemaV1. El tú-del-futuro trabajando en v2 pagará el impuesto; el tú-del-presente puede pagarlo una vez y olvidarlo.

MigrationStage personalizado para los casos difíciles

Las migraciones ligeras cubren la mayoría de los cambios aditivos. Los cambios de tipo, las divisiones, las fusiones y las poblaciones condicionales necesitan un MigrationStage.custom:

static var stages: [MigrationStage] = [
    MigrationStage.custom(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self,
        willMigrate: { context in
            // Read v1 rows; stage any derived state to a transient store
            // (UserDefaults / temp file) since the v1 and v2 contexts do
            // not share state, and didMigrate cannot read v1.
            let v1Items = try context.fetch(FetchDescriptor<ShoppingItemV1>())
            stageDerivedState(from: v1Items)
        },
        didMigrate: { context in
            // Populate v2-only fields on existing rows
            let v2Items = try context.fetch(FetchDescriptor<ShoppingItemV2>())
            for item in v2Items where item.lastModified == nil {
                item.lastModified = Date()
            }
            try context.save()
        }
    )
]

Las dos closures se disparan antes y después de que el framework aplique la migración estructural. willMigrate se ejecuta contra el esquema v1; didMigrate se ejecuta contra el esquema v2. El cuerpo de la closure es código SwiftData normal (descriptores de fetch, guardados de contexto del modelo, las mismas APIs usadas en la app en ejecución), operando contra un contexto transitorio en migración.

El patrón que sobrevive en producción es mantener willMigrate vacío y poner toda la lógica de población en didMigrate. Leer datos v1 dentro de willMigrate está permitido, pero el esquema v2 aún no existe desde la perspectiva del framework, por lo que cualquier cómputo tiene que ser preparado en un almacén transitorio que la closure didMigrate pueda leer. La regla más simple: las migraciones estructurales son trabajo del framework; poblar campos exclusivos de v2 en filas existentes es trabajo de didMigrate.

Cuándo @Attribute y @Relationship se ganan sus nombres

Dos macros hacen la mayor parte del trabajo de decoración del esquema en las clases @Model.

@Attribute decora una sola propiedad con una restricción o pista:

  • @Attribute(.unique) impone la unicidad, como en ShoppingItem.id
  • @Attribute(.externalStorage) almacena blobs grandes de Data fuera de la base de datos (datos de imagen, buffers de audio)
  • @Attribute(originalName: "old_field_name") empareja una propiedad con una columna renombrada durante la migración
  • @Attribute(.transformable(by: ...)) aplica un ValueTransformer a un tipo no Codable

La disciplina correcta: usa .unique para campos que genuinamente deben ser únicos (un UUID que generaste, un ID externo), usa .externalStorage para cualquier blob de más de unos pocos KB, usa originalName cuando un renombrado de v2 de una propiedad de otro modo perdería los datos de v1.

@Relationship decora una propiedad que apunta a otra clase @Model o a una colección de ellas:

@Model
final class List {
    var name: String

    @Relationship(deleteRule: .cascade, inverse: \ShoppingItem.list)
    var items: [ShoppingItem] = []
}

@Model
final class ShoppingItem {
    var name: String
    var list: List?
}

deleteRule: .cascade significa que eliminar la List padre elimina todas las filas hijas de ShoppingItem. El parámetro inverse: le dice al framework qué propiedad del hijo apunta de vuelta al padre; el framework lo usa para un mantenimiento bidireccional predecible. SwiftData a veces puede inferir el inverso automáticamente, e inverse: nil está soportado para relaciones explícitamente unidireccionales, pero el valor por defecto seguro es declarar inverse: siempre que la inferencia pueda ser ambigua.5

La disciplina correcta: declara las relaciones con un deleteRule explícito (el valor por defecto es .nullify, que rara vez es lo que quieres) y declara inverse: siempre que la relación sea bidireccional (en lugar de depender de la inferencia del framework). Los valores por defecto implícitos suelen estar mal; la forma explícita es un parámetro extra y un bug guardado para siempre.

Lo que construiría de manera diferente

Tres patrones que las apps del cluster o bien envían o bien desearían haber enviado.

Envía VersionedSchema desde v1. Cada clase @Model que se envíe debería vivir dentro de un VersionedSchema desde el día uno. El costo es un enum envolvente por versión de esquema. El beneficio es que el primer cambio no trivial de v2 es una adición de una línea a MigrationPlan.schemas en lugar de una refactorización retroactiva de dos días.

Haz que cada marca de tiempo sea opcional. Campos como lastModified, createdAt y updatedAt que existen para sincronización entre dispositivos o resolución de conflictos deberían ser opcionales en v1 si el producto v1 no los necesita. La opcionalidad mantiene barata la migración a v2 (cuando sí los necesites). Llenarlos en filas existentes durante didMigrate es un loop; hacerlos no opcionales desde v1 es una restricción que puede romper el backfill sobre los datos del usuario.

Usa UUIDs como la clave natural, no el PersistentIdentifier. El PersistentIdentifier de SwiftData es en proceso. La sincronización entre dispositivos, la integración MCP (cubierta en Dos ecosistemas de agentes, una lista de compras) y cualquier referencia fuera del proceso necesitan un identificador estable. Un UUID con @Attribute(.unique) es la forma correcta; el PersistentIdentifier en proceso es la forma equivocada para cualquier cosa que cruce un límite de proceso.

Cuándo @Model es la respuesta equivocada

Tres casos donde SwiftData no es la herramienta correcta:

Estado clave/valor de un solo registro. Configuraciones de la app, el idioma seleccionado por el usuario, la marca de tiempo de la última sincronización. Usa UserDefaults o NSUbiquitousKeyValueStore (cubierto en Cinco plataformas Apple, tres archivos compartidos). La sobrecarga de SwiftData para una sola fila es ceremonia desperdiciada; los almacenes clave-valor son el sustrato correcto.

Datos autoritativos del servidor sin escrituras offline. Una lista obtenida de una API REST y mostrada en solo lectura. SwiftData es exagerado si la fuente de verdad es el servidor y la caché local es solo una caché. Una simple instantánea Codable en Documents/ más un array cacheado en memoria es suficiente; el impuesto de migración de SwiftData no vale la pena pagarlo si los datos no sobreviven a un reinicio duro.

Coordinación entre múltiples procesos. SwiftData opera dentro de un proceso. Un servidor MCP que se ejecuta fuera de la app iOS no puede leer ni escribir el contenedor SwiftData de la app. El estado entre procesos necesita una forma diferente: un archivo JSON en iCloud Drive, un contenedor App Group compartido o una capa de sincronización explícita que conecte los procesos. (Get Bananas combina SwiftData con JSON de iCloud Drive precisamente por esta razón.)6

Los datos son blobs grandes que cambian rara vez. Un archivo de audio de 10MB, un dataset de imágenes de 50MB. Usa @Attribute(.externalStorage) si los blobs están dentro de filas de SwiftData; de lo contrario, usa el sistema de archivos directamente con metadatos en SwiftData apuntando a URLs de archivos.

Lo que el patrón significa para apps que se envían en iOS 26+

Tres conclusiones.

  1. Las macros son la parte fácil. Las migraciones son el costo. @Model y @Attribute son declaraciones de dos líneas que ocultan mucha fontanería de Core Data. La disciplina de migración es lo que realmente pagas durante la vida útil de la app; diseña v1 con v2 en mente.

  2. VersionedSchema desde el día uno es innegociable para apps que se envían. El enum envolvente es un archivo extra. El costo retroactivo de agregarlo después es mucho más alto.

  3. Los campos opcionales y las relaciones explícitas son el seguro barato. Marcas de tiempo opcionales para metadatos de sincronización, deleteRule e inverse: explícitos en las relaciones. Ambas son declaraciones diminutas que compran mucha flexibilidad para v2.

El cluster completo del Ecosistema Apple: App Intents tipados para Apple Intelligence; servidores MCP para agentes entre LLM; la pregunta de enrutamiento entre ellos; Foundation Models para LLM en dispositivo y el protocolo Tool; Live Activities para la máquina de estados de la pantalla de bloqueo en iOS; el contrato del runtime de watchOS en Apple Watch; los internos de SwiftUI para el sustrato del framework; el modelo mental espacial de RealityKit para escenas de visionOS; los patrones de Liquid Glass para la capa visual; el envío multiplataforma para alcance entre dispositivos. El hub está en la Serie Ecosistema Apple. Para un contexto más amplio sobre iOS con agentes de IA, consulta la guía de desarrollo de agentes iOS.

Preguntas frecuentes

¿Cuál es la diferencia entre @Model y el NSManagedObject de Core Data?

@Model es una macro de Swift que genera la fontanería de NSManagedObject bajo el capó. SwiftData usa Core Data como almacén de respaldo, por lo que el modelo en tiempo de ejecución es el mismo; la diferencia está en la superficie. @Model elimina el archivo .xcdatamodeld, la ceremonia de los value-transformer y la gestión del ciclo de vida del NSManagedObjectContext. Obtienes el mismo almacén persistente con una API con forma de Swift.

¿Necesito VersionedSchema si nunca planeo cambiar el esquema?

Si tu app podría enviar una v2, sí. Si es una demo de un solo uso, no. El costo de VersionedSchema desde v1 es una declaración enum extra. El costo de agregarlo retroactivamente en v2 es emparejar la forma exacta del esquema v1 para que el framework reconozca los datos existentes, lo cual es factible pero propenso a errores. La mayoría de las apps que se envían eventualmente necesitarán un cambio de esquema; presupuéstalo en v1.

¿Cuándo debo usar @Attribute(.unique)?

Cuando el campo es una clave natural para la fila: un UUID que generaste, un ID externo que importaste, un slug que asignaste. SwiftData trata .unique como upsert: si insertas un modelo cuyo valor .unique ya existe, la fila existente se actualiza en lugar de añadirse una nueva. Esa semántica es lo que hace que las rutas de sincronización tipo upsert (el mismo UUID viniendo de dos dispositivos) sean seguras; también es por lo que .unique es la herramienta equivocada en campos de nombre visible como title, porque dos usuarios escribiendo el mismo título fusionarían silenciosamente sus filas en lugar de producir dos registros distintos.

¿Cómo manejo un campo no opcional agregado a un esquema existente?

Usa un MigrationStage.custom con una closure didMigrate que pueble el campo en filas existentes. O, más fácil: declara el campo como opcional en la nueva versión del esquema y llénalo perezosamente al accederlo. La opcionalidad es la migración más barata; las adiciones no opcionales requieren lógica de población explícita.

¿Qué es PersistentIdentifier vs mi propio UUID?

PersistentIdentifier es el ID de fila en proceso de SwiftData; se genera automáticamente y sobrevive el tiempo de vida del proceso en ejecución. Tu propio UUID con @Attribute(.unique) es un identificador estable entre procesos y entre dispositivos. Usa PersistentIdentifier para referencias en proceso dentro de la app. Usa un UUID para cualquier cosa que cruce un límite de proceso (sincronización entre dispositivos, integraciones externas, herramientas MCP, llamadas de red).

Referencias


  1. Get Bananas del autor, una app de lista de compras en SwiftUI que combina SwiftData con sincronización JSON de iCloud Drive y un servidor MCP. El modelo ShoppingItem evolucionó a lo largo del ciclo inicial de desarrollo; el campo lastModified: Date? se agregó después del esquema inicial (commit 268a00d el 2025-12-01, “Make lastModified optional to fix migration crash”) porque hacerlo no opcional rompía la migración cuando las filas existentes no tenían valor con el cual poblarlo. 

  2. Apple Developer, “SwiftData” y “Adding and editing persistent data in your app”. La macro @Model, la superficie de restricciones @Attribute y la relación con el NSManagedObjectModel de Core Data. 

  3. Apple Developer, “Preserving your app’s model data across launches” y “Adopting SwiftData for a Core Data app”. Semántica de migración ligera y qué hace que el framework se rinda. 

  4. Apple Developer, “VersionedSchema” y “SchemaMigrationPlan”. Declaraciones de esquema versionado, definiciones de etapas de migración y el constructor de ModelContainer que toma un plan de migración. 

  5. Apple Developer, “Defining data relationships with enumerations and model classes” y “Schema.Relationship”. La macro @Relationship, las opciones de deleteRule (.cascade, .nullify, .deny, .noAction) y el papel del parámetro inverse: en el mantenimiento de relaciones bidireccionales. 

  6. Análisis del autor en Dos ecosistemas de agentes, una lista de compras, 29 de abril de 2026, y Cinco plataformas Apple, tres archivos compartidos. Los patrones de sincronización entre procesos y entre dispositivos de Get Bananas + Return que complementan (y a veces reemplazan) a SwiftData dentro de un workflow multi-proceso. 

Artículos relacionados

Two Agent Ecosystems, One Shopping List: An MCP Server Living Alongside an iOS App

Get Bananas runs on iOS, macOS, watchOS, visionOS. It also lives inside Claude Desktop as an MCP server. The bridge is i…

19 min de lectura

Foundation Models On-Device LLM: The Tool Protocol

iOS 26's Foundation Models framework puts a 3B-parameter LLM on every Apple Intelligence device. The Tool protocol is th…

15 min de lectura

The Cleanup Layer Is the Real AI Agent Market

Charlie Labs pivoted from building agents to cleaning up after them. The AI agent market is moving from generation to pr…

15 min de lectura