← Todos los articulos

El verdadero costo de SwiftData es la disciplina de esquema

El ShoppingItem de Get Bananas es el ejemplo canónico de por qué importa la disciplina de esquema en SwiftData. 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 da una restricción de unicidad. El framework oculta la gestión del stack de Core Data, el baile del transformador de valores y el boilerplate del NSManagedObjectContext. Lo que el framework no oculta es la migración del esquema; simplemente hace que la migración sea 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 de migrar con descuido. La disciplina es el nombrado, 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

  • El 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 lo maneja. Agregar una propiedad no opcional a un esquema existente requiere un VersionedSchema más un MigrationPlan que le diga al framework cómo poblar el nuevo campo para 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 tirar la base de datos del usuario, porque el camino ligero es conservador y abandona 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. Ambos son macros que generan la plomería correcta de Core Data por debajo.2

Lo que @Model realmente hace

Un tipo de SwiftData es una clase de Swift con el macro @Model aplicado. 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 de 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 de 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 evita que se envíen duplicados; es una garantía de almacenamiento “como máximo uno” que fusiona silenciosamente. El patrón id: UUID de arriba es el recomendado para 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 por referencia, no tipos por valor. Mutar una propiedad en una instancia de ShoppingItem activa 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 mediante @Query re-renderiza cualquier vista que observe el predicado coincidente. El patrón es similar a @Observable (cubierto en De qué está hecho SwiftUI), con la persistencia superpuesta encima.

Los campos opcionales son la migración barata

El campo lastModified: Date? en ShoppingItem es opcional, y la opcionalidad es portante. El campo se agregó después de publicar 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 valor de lastModified. Un campo opcional sin valor predeterminado permite que la migración ligera de SwiftData maneje la adición sin escribir código de migración: las filas existentes obtienen nil; las filas nuevas obtienen lo que el init establezca.3

El camino de migración ligera es el camino educado 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 con los datos existentes. Los casos que el camino ligero maneja limpiamente:

  • 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 el camino ligero abandona:

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

Cuando el camino ligero abandona, el comportamiento seguro es fallar la migración. El comportamiento inseguro sería tirar la base de datos y empezar de cero; 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 de esquema; nadie pierde datos, pero todos pierden confianza.

El costo de saltarse VersionedSchema desde el día uno aparece en el límite v2 → v3, cuando agregas la tercera función cuyo cambio de esquema excede lo que el camino ligero maneja.

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 clases del modelo en sí se mueven 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 publica 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 publicas v3, agregas SchemaV3.self a schemas y un nuevo MigrationStage entre v2 y v3.

La disciplina es publicar VersionedSchema en v1, incluso cuando solo hay una versión. El costo de hacerlo es un archivo extra y una declaración de 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 coincidir con la forma exacta de v1 para 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 olvidarse.

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 clausuras 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 clausura es código normal de SwiftData (descriptores de fetch, guardados de contexto de 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 de 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 clausura 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 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 deberían 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 en 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?
}

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

La disciplina correcta: declara las relaciones con deleteRule explícito (el predeterminado 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 predeterminados implícitos suelen ser incorrectos; la forma explícita es un parámetro extra y un bug guardado para siempre.

Qué construiría diferente

Tres patrones que las apps en el clúster o publican o desearían haber publicado.

Publica VersionedSchema desde v1. Cada clase @Model que se publique debe 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 un refactor retroactivo de dos días.

Haz cada marca de tiempo 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 solo bucle; hacerlos no opcionales desde v1 es una restricción que puede romper el backfill en datos del usuario.

Usa UUIDs como la clave natural, no el PersistentIdentifier. El PersistentIdentifier de SwiftData está en proceso. La sincronización entre dispositivos, la integración con 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 incorrecta para cualquier cosa que cruce un límite de proceso.

Cuándo @Model es la respuesta incorrecta

Tres casos en los que SwiftData no es la herramienta correcta:

Estado clave/valor de un solo registro. Configuración de la app, el idioma seleccionado del usuario, la marca de tiempo de la última sincronización. Usa UserDefaults o NSUbiquitousKeyValueStore (cubierto en Cinco plataformas de Apple, tres archivos compartidos). El overhead 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 excesivo si la fuente de la verdad es el servidor y la caché local es solo una caché. Una instantánea simple Codable en Documents/ más un arreglo cacheado en memoria es suficiente; el impuesto de migración de SwiftData no vale la pena pagarlo si los datos no sobreviven a un hard reset.

Coordinación multi-proceso. SwiftData opera dentro de un proceso. Un servidor MCP que se ejecute fuera de la app de 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 de App Group compartido o una capa de sincronización explícita que conecte 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.

Qué significa el patrón para apps que se publican en iOS 26+

Tres conclusiones.

  1. Los macros son la parte fácil. Las migraciones son el costo. @Model y @Attribute son declaraciones de dos líneas que ocultan mucha plomerí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 no es negociable para apps que se publican. El enum envolvente es un archivo extra. El costo retroactivo de agregarlo después es mucho mayor.

  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 clúster 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; la publicación multi-plataforma para alcance entre dispositivos. El hub está en la serie del Ecosistema Apple. Para un contexto más amplio sobre iOS con agentes de AI, consulta la guía de Desarrollo de Agentes en iOS.

FAQ

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

@Model es un macro de Swift que genera la plomería de NSManagedObject por debajo. SwiftData usa Core Data como su almacén de respaldo, por lo que el modelo en tiempo de ejecución es el mismo; la diferencia es la superficie. @Model elimina el archivo .xcdatamodeld, la ceremonia del transformador de valores 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 pudiera publicar una v2, sí. Si es una demo de un solo uso, no. El costo de VersionedSchema desde v1 es una declaración de enum extra. El costo de agregarlo retroactivamente en v2 es coincidir con 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 publican 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 agregar una nueva. Esa semántica es lo que hace que las rutas de sincronización estilo upsert (el mismo UUID llegando desde dos dispositivos) sean seguras; también es por eso que .unique es la herramienta incorrecta 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 clausura 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 rellénalo perezosamente al acceder. La opcionalidad es la migración más barata; las adiciones no opcionales necesitan 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 temprano 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”. El macro @Model, la superficie de restricción @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é activa al framework para abandonar. 

  4. Apple Developer, “VersionedSchema” y “SchemaMigrationPlan”. Declaraciones de esquema versionado, definiciones de etapa 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”. El macro @Relationship, las opciones de deleteRule (.cascade, .nullify, .deny, .noAction) y el rol del parámetro inverse: en el mantenimiento bidireccional de relaciones. 

  6. Análisis del autor en Dos ecosistemas de agentes, una lista de compras, 29 de abril de 2026, y Cinco plataformas de 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 flujo de trabajo multi-proceso. 

Artículos relacionados

Migraciones de SwiftData: ligeras vs. personalizadas, y cuándo no necesitas una V2

El modelo de migración de SwiftData usa VersionedSchema, MigrationStage y SchemaMigrationPlan. La mayoría de los cambios…

12 min de lectura

SwiftData en iOS 27: observación e historial

iOS 27 le da a SwiftData observación de cambios de primera clase con ResultsObserver, observación del historial persiste…

11 min de lectura

La capa de limpieza es el verdadero mercado de los agentes de IA

Charlie Labs pasó de construir agentes a limpiar lo que dejan. El mercado de agentes de IA se está moviendo de la genera…

15 min de lectura