Le vrai coût de SwiftData, c'est la discipline de schéma
Le ShoppingItem de Get Bananas est l’exemple canonique des raisons pour lesquelles la discipline de schéma SwiftData est importante. Le schéma initial n’incluait pas d’horodatage lastModified ; l’ajouter par la suite a nécessité une forme de migration spécifique parce que les données existantes étaient déjà sur disque, et le champ a été rendu optionnel précisément pour corriger un crash de migration survenu lorsqu’il avait été ajouté pour la première fois en non optionnel.1
L’API de SwiftData se résume à deux macros. @Model sur une classe en fait un type persistant. @Attribute(.unique) sur une propriété lui confère une contrainte d’unicité. Le framework masque la gestion de la pile Core Data, la chorégraphie des value-transformers et le code répétitif autour de NSManagedObjectContext. Ce que le framework ne masque pas, c’est la migration de schéma ; il rend simplement la migration déclarative au lieu d’impérative. Le coût de ne pas prêter attention aux migrations, c’est le bug qui efface les données d’un utilisateur lors d’une mise à jour de routine.
La thèse : SwiftData est peu coûteux à mettre en place et coûteux à migrer de façon négligée. La discipline, c’est le nommage, l’optionalité et VersionedSchema dès le premier jour, pas le jour où vous réalisez que vous auriez dû le faire.
TL;DR
- La macro
@Modeltransforme une classe en un type persistant SwiftData. Le framework génère le schéma à partir des déclarations de propriétés à la compilation. - Ajouter une nouvelle propriété optionnelle est une migration sans impact : la migration légère de SwiftData s’en charge. Ajouter une propriété non optionnelle à un schéma existant nécessite un
VersionedSchemaplus unMigrationPlanqui indique au framework comment peupler le nouveau champ pour les lignes existantes. - Le coût d’omettre
VersionedSchemadès le premier jour, c’est que tout changement non trivial de schéma en v2 risque de supprimer la base de données d’un utilisateur, parce que la voie légère est conservatrice et abandonne lorsqu’elle ne peut pas inférer la migration. @Attribute(.unique)est le bon outil pour les clés naturelles (unUUIDque vous avez généré, un identifiant externe que vous avez importé).@Relationshipest le bon outil pour les références parent/enfant. Toutes deux sont des macros qui génèrent la bonne plomberie Core Data sous le capot.2
Ce que @Model fait réellement
Un type SwiftData est une classe Swift à laquelle la macro @Model est appliquée. Le ShoppingItem de Get Bananas en est la forme canonique :
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()
}
}
Trois détails de cette forme que l’API masque.
@Model ne nécessite pas de déclaration séparée de schéma de magasin persistant. SwiftData lit la définition de la classe à la compilation et synthétise le schéma. Les propriétés de la classe deviennent les attributs du modèle ; leurs types Swift deviennent les types de colonnes. Il n’y a pas de fichier .xcdatamodeld à maintenir (bien que le NSManagedObjectModel sous-jacent de Core Data existe toujours et soit ce qui supporte le schéma à l’exécution).2
@Attribute(.unique) est une contrainte sur une seule colonne, pas une déclaration de PRIMARY KEY. L’identité persistante de SwiftData est le PersistentIdentifier, généré automatiquement par ligne. La déclaration @Attribute(.unique) indique au framework « cette colonne stocke au plus une ligne par valeur ». Lorsque vous insérez un modèle avec une valeur .unique qui existe déjà, SwiftData effectue un upsert : la ligne existante est mise à jour plutôt que rejetée. La sémantique est importante pour le code de production : .unique n’est pas une validation au niveau de l’UI qui empêche les doublons d’être soumis ; c’est une garantie de stockage « au plus un » qui fusionne silencieusement. Le motif id: UUID ci-dessus est celui recommandé pour la synchronisation inter-processus (où vous voulez un identifiant stable qui survit à la disparition du PersistentIdentifier en mémoire), et le comportement d’upsert est exactement ce que vous souhaitez lorsque le même UUID arrive par deux chemins de synchronisation.
Les classes @Model sont des types par référence, pas des types par valeur. Modifier une propriété sur une instance de ShoppingItem déclenche le suivi des changements de SwiftData ; le framework enregistre le changement et le persiste lors de la prochaine sauvegarde du contexte. L’intégration SwiftUI via @Query rerend toute vue observant le prédicat correspondant. Le motif est similaire à @Observable (couvert dans De quoi SwiftUI est fait), avec la persistance superposée par-dessus.
Les champs optionnels sont la migration bon marché
Le champ lastModified: Date? sur ShoppingItem est optionnel, et cette optionalité est porteuse. Le champ a été ajouté après la sortie de la v1 pour prendre en charge la synchronisation inter-appareils et la résolution de conflits ; les lignes existantes sur les appareils des utilisateurs n’avaient aucune valeur lastModified. Un champ optionnel sans valeur par défaut permet à la migration légère de SwiftData de gérer l’ajout sans écrire le moindre code de migration : les lignes existantes obtiennent nil ; les nouvelles lignes obtiennent ce que l’init définit.3
La voie de migration légère est la voie polie du framework. SwiftData inspecte le nouveau schéma et le magasin persistant, infère le plus petit changement compatible, et l’applique. La migration est automatique ; l’utilisateur ne voit rien ; l’application se lance normalement sur les données existantes. Les cas que la voie légère gère proprement :
- Ajouter une propriété optionnelle
- Supprimer une propriété (les données sont supprimées ; les lectures existantes ne voient plus la colonne)
- Renommer un attribut que le framework peut faire correspondre par indication (en utilisant
@Attribute(originalName: ...)) - Renommer une classe
@Modelque le framework peut faire correspondre (en utilisant@Model.originalNameou une indication)
Les cas où la voie légère abandonne :
- Ajouter une propriété non optionnelle sans valeur par défaut à un schéma existant (les lignes existantes n’ont aucune valeur pour la peupler)
- Changer le type d’une propriété (par exemple,
Int→String) - Diviser un modèle en deux modèles, ou en fusionner deux en un
- Tout ce qui nécessite une logique personnalisée pour migrer
Lorsque la voie légère abandonne, le comportement sûr est de faire échouer la migration. Le comportement non sûr serait de supprimer la base de données et de repartir de zéro ; le framework est conservateur et refuse de le faire silencieusement. L’utilisateur voit l’application planter au lancement avec une erreur de migration ; le développeur voit une trace de pile pointant vers l’incohérence de schéma ; personne ne perd de données, mais tout le monde perd la confiance.
Le coût d’omettre VersionedSchema dès le premier jour se manifeste à la frontière v2 → v3, lorsque vous ajoutez la troisième fonctionnalité dont le changement de schéma dépasse ce que la voie légère peut gérer.
VersionedSchema et MigrationPlan : la discipline du premier jour
VersionedSchema déclare une version spécifique du schéma de modèle. MigrationPlan déclare comment migrer d’une version à la suivante.4 La forme :
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)
]
}
Les classes de modèles elles-mêmes se déplacent dans l’espace de noms du schéma versionné :
extension SchemaV1 {
@Model
final class ShoppingItemV1 { /* v1 fields */ }
}
extension SchemaV2 {
@Model
final class ShoppingItemV2 { /* v2 fields, including lastModified */ }
}
Le ModelContainer est construit avec le plan de migration :
let container = try ModelContainer(
for: ShoppingItemV2.self,
migrationPlan: AppMigrationPlan.self,
configurations: ModelConfiguration("ShoppingList")
)
Le plan de migration donne au framework un graphe typé de l’évolution du schéma. Lorsque l’application livrant la v2 se lance contre une base de données v1, le framework parcourt le plan de migration, applique les étapes nommées, et amène la base de données à la v2. Lorsque vous livrez la v3, vous ajoutez SchemaV3.self à schemas et une nouvelle MigrationStage entre v2 et v3.
La discipline consiste à livrer VersionedSchema en v1, même lorsqu’il n’y a qu’une seule version. Le coût de le faire est un fichier supplémentaire et une déclaration enum supplémentaire. Le coût de ne pas le faire, c’est que le premier changement non trivial de schéma de la v2 nécessite d’envelopper rétroactivement la v1 dans un VersionedSchema, ce qui est faisable mais demande de la précaution pour faire correspondre exactement la forme de la v1 afin que le framework puisse identifier les données existantes comme SchemaV1. Le futur-vous travaillant sur la v2 paiera la taxe ; le présent-vous peut la payer une fois et l’oublier.
MigrationStage personnalisé pour les cas difficiles
Les migrations légères couvrent la plupart des changements additifs. Les changements de type, les divisions, les fusions et les peuplements conditionnels nécessitent 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()
}
)
]
Les deux closures se déclenchent avant et après que le framework applique la migration structurelle. willMigrate s’exécute contre le schéma v1 ; didMigrate s’exécute contre le schéma v2. Le corps de la closure est du code SwiftData normal (descripteurs de fetch, sauvegardes de contexte de modèle, les mêmes APIs utilisés dans l’application en cours d’exécution), opérant contre un contexte transitoire en cours de migration.
Le motif qui survit en production est de garder willMigrate vide et de placer toute la logique de peuplement dans didMigrate. Lire les données v1 dans willMigrate est autorisé, mais le schéma v2 n’existe pas encore du point de vue du framework, donc tout calcul doit être préparé dans un magasin transitoire que la closure didMigrate peut lire. La règle plus simple : les migrations structurelles sont le travail du framework ; peupler les champs uniquement v2 sur les lignes existantes est le travail de didMigrate.
Quand @Attribute et @Relationship méritent leur nom
Deux macros effectuent la majeure partie du travail de décoration de schéma dans les classes @Model.
@Attribute décore une propriété unique avec une contrainte ou une indication :
@Attribute(.unique)impose l’unicité, comme dansShoppingItem.id@Attribute(.externalStorage)stocke les gros blobsDataen dehors de la base de données (données d’image, tampons audio)@Attribute(originalName: "old_field_name")fait correspondre une propriété à une colonne renommée pendant la migration@Attribute(.transformable(by: ...))applique unValueTransformerà un type non-Codable
La bonne discipline : utilisez .unique pour les champs qui devraient véritablement être uniques (un UUID que vous avez généré, un identifiant externe), utilisez .externalStorage pour tout blob de plus de quelques Ko, utilisez originalName lorsqu’un renommage de propriété en v2 perdrait autrement les données v1.
@Relationship décore une propriété qui pointe vers une autre classe @Model ou une collection de celles-ci :
@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?
}
Le deleteRule: .cascade signifie que supprimer le List parent supprime toutes les lignes ShoppingItem enfants. Le paramètre inverse: indique au framework quelle propriété sur l’enfant pointe vers le parent ; le framework l’utilise pour une maintenance bidirectionnelle prévisible. SwiftData peut parfois inférer l’inverse automatiquement, et inverse: nil est pris en charge pour les relations explicitement unidirectionnelles, mais la valeur par défaut sûre est de déclarer inverse: chaque fois que l’inférence serait ambiguë.5
La bonne discipline : déclarez les relations avec deleteRule explicite (la valeur par défaut est .nullify, qui est rarement ce que vous voulez) et déclarez inverse: chaque fois que la relation est bidirectionnelle (plutôt que de vous fier à l’inférence du framework). Les valeurs par défaut implicites sont généralement erronées ; la forme explicite est un paramètre supplémentaire et un bug évité pour toujours.
Ce que je construirais différemment
Trois motifs que les applications du cluster soit livrent, soit auraient souhaité livrer.
Livrer VersionedSchema dès la v1. Chaque classe @Model mise en production devrait vivre à l’intérieur d’un VersionedSchema dès le premier jour. Le coût est d’un enum enveloppant par version de schéma. Le bénéfice, c’est que le premier changement non trivial de la v2 est un ajout d’une seule ligne à MigrationPlan.schemas au lieu d’un refactoring rétroactif de deux jours.
Rendre chaque horodatage optionnel. Les champs comme lastModified, createdAt et updatedAt qui existent pour la synchronisation inter-appareils ou la résolution de conflits devraient être optionnels en v1 si le produit v1 n’en a pas besoin. L’optionalité maintient la migration vers la v2 (lorsque vous en avez besoin) bon marché. Les remplir sur les lignes existantes pendant didMigrate est une boucle ; les rendre non optionnels dès la v1 est une contrainte qui peut casser le backfill sur les données utilisateur.
Utilisez les UUID comme clé naturelle, pas le PersistentIdentifier. Le PersistentIdentifier de SwiftData est en mémoire. La synchronisation inter-appareils, l’intégration MCP (couverte dans Deux écosystèmes d’agents, une seule liste de courses) et toute référence hors processus nécessitent un identifiant stable. Un UUID avec @Attribute(.unique) est la bonne forme ; le PersistentIdentifier en mémoire est la mauvaise forme pour tout ce qui traverse une frontière de processus.
Quand @Model est la mauvaise réponse
Trois cas où SwiftData n’est pas le bon outil :
État clé/valeur à enregistrement unique. Les paramètres de l’application, la langue sélectionnée par l’utilisateur, l’horodatage de la dernière synchronisation. Utilisez UserDefaults ou NSUbiquitousKeyValueStore (couvert dans Cinq plateformes Apple, trois fichiers partagés). La surcharge de SwiftData pour une seule ligne est un cérémonial gaspillé ; les magasins clé-valeur sont le bon substrat.
Données dont la source d’autorité est le serveur, sans écritures hors ligne. Une liste récupérée depuis une API REST et affichée en lecture seule. SwiftData est exagéré si la source de vérité est le serveur et que le cache local n’est qu’un cache. Un simple instantané Codable dans Documents/ plus un tableau mis en cache en mémoire suffit ; la taxe de migration SwiftData ne vaut pas la peine d’être payée si les données ne survivent pas à une réinitialisation matérielle.
Coordination multi-processus. SwiftData opère à l’intérieur d’un processus. Un serveur MCP s’exécutant en dehors de l’application iOS ne peut pas lire ou écrire dans le conteneur SwiftData de l’application. L’état inter-processus nécessite une forme différente : un fichier JSON iCloud Drive, un conteneur App Group partagé, ou une couche de synchronisation explicite qui fait le pont entre les processus. (Get Bananas associe SwiftData à JSON iCloud Drive précisément pour cette raison.)6
Les données sont de gros blobs qui changent rarement. Un fichier audio de 10 Mo, un jeu de données d’images de 50 Mo. Utilisez @Attribute(.externalStorage) si les blobs sont à l’intérieur des lignes SwiftData ; sinon utilisez directement le système de fichiers avec des métadonnées dans SwiftData pointant vers les URL des fichiers.
Ce que le motif signifie pour les applications livrant sur iOS 26+
Trois enseignements.
-
Les macros sont la partie facile. Les migrations sont le coût.
@Modelet@Attributesont des déclarations de deux lignes qui masquent beaucoup de plomberie Core Data. La discipline de migration est ce que vous payez réellement sur la durée de vie de l’application ; concevez la v1 en pensant à la v2. -
VersionedSchemadès le premier jour est non négociable pour les applications mises en production. L’enumenveloppant est un fichier supplémentaire. Le coût rétroactif de l’ajouter plus tard est bien plus élevé. -
Les champs optionnels et les relations explicites sont l’assurance bon marché. Horodatages optionnels pour les métadonnées de synchronisation,
deleteRuleetinverse:explicites sur les relations. Toutes deux sont de minuscules déclarations qui achètent beaucoup de flexibilité pour la v2.
Le cluster complet Apple Ecosystem : App Intents typés pour Apple Intelligence ; serveurs MCP pour les agents inter-LLM ; la question de routage entre eux ; Foundation Models pour l’LLM sur appareil et le protocole Tool ; Live Activities pour la machine à états de l’écran de verrouillage sur iOS ; le contrat de runtime watchOS sur Apple Watch ; les internes de SwiftUI pour le substrat du framework ; le modèle mental spatial de RealityKit pour les scènes visionOS ; les motifs Liquid Glass pour la couche visuelle ; la livraison multi-plateforme pour la portée inter-appareils. Le hub se trouve à la série Apple Ecosystem. Pour un contexte plus large iOS-avec-agents-IA, consultez le guide de développement d’agents iOS.
FAQ
Quelle est la différence entre @Model et le NSManagedObject de Core Data ?
@Model est une macro Swift qui génère la plomberie NSManagedObject sous le capot. SwiftData utilise Core Data comme magasin de soutien, donc le modèle d’exécution est le même ; la différence est la surface. @Model supprime le fichier .xcdatamodeld, le cérémonial des value-transformers et la gestion du cycle de vie de NSManagedObjectContext. Vous obtenez le même magasin persistant avec une API de forme Swift.
Ai-je besoin de VersionedSchema si je n’ai jamais l’intention de changer le schéma ?
Si votre application pourrait livrer une v2, oui. S’il s’agit d’une démo unique, non. Le coût de VersionedSchema dès la v1 est une déclaration enum supplémentaire. Le coût de l’ajouter rétroactivement à la v2 consiste à faire correspondre exactement la forme du schéma v1 pour que le framework reconnaisse les données existantes, ce qui est faisable mais sujet aux erreurs. La plupart des applications mises en production auront finalement besoin d’un changement de schéma ; budgétez-le en v1.
Quand devrais-je utiliser @Attribute(.unique) ?
Quand le champ est une clé naturelle pour la ligne : un UUID que vous avez généré, un identifiant externe que vous avez importé, un slug que vous avez attribué. SwiftData traite .unique comme un upsert : si vous insérez un modèle dont la valeur .unique existe déjà, la ligne existante est mise à jour plutôt qu’une nouvelle ligne ajoutée. Cette sémantique est ce qui rend les chemins de synchronisation de style upsert (le même UUID provenant de deux appareils) sûrs ; c’est aussi pourquoi .unique est le mauvais outil sur les champs de nom d’affichage comme title, parce que deux utilisateurs tapant le même titre fusionneraient silencieusement leurs lignes au lieu de produire deux enregistrements distincts.
Comment gérer un champ non optionnel ajouté à un schéma existant ?
Utilisez un MigrationStage.custom avec une closure didMigrate qui peuple le champ sur les lignes existantes. Ou, plus simple : déclarez le champ comme optionnel dans la nouvelle version de schéma et remplissez-le paresseusement à l’accès. L’optionalité est la migration la moins coûteuse ; les ajouts non optionnels nécessitent une logique de peuplement explicite.
Qu’est-ce que PersistentIdentifier par rapport à mon propre UUID ?
PersistentIdentifier est l’ID de ligne en mémoire de SwiftData ; il est généré automatiquement et survit à la durée de vie du processus en cours d’exécution. Votre propre UUID avec @Attribute(.unique) est un identifiant stable inter-processus et inter-appareils. Utilisez PersistentIdentifier pour les références en mémoire à l’intérieur de l’application. Utilisez un UUID pour tout ce qui traverse une frontière de processus (synchronisation inter-appareils, intégrations externes, outils MCP, appels réseau).
Références
-
Get Bananas de l’auteur, une application SwiftUI de liste de courses qui associe SwiftData avec la synchronisation JSON iCloud Drive et un serveur MCP. Le modèle
ShoppingItema évolué tout au long du cycle de développement initial ; le champlastModified: Date?a été ajouté après le schéma initial (commit268a00ddu 2025-12-01, « Make lastModified optional to fix migration crash ») parce que le rendre non optionnel cassait la migration lorsque les lignes existantes n’avaient aucune valeur pour le peupler. ↩ -
Apple Developer, « SwiftData » et « Adding and editing persistent data in your app ». La macro
@Model, la surface de contrainte@Attributeet la relation avec leNSManagedObjectModelde Core Data. ↩↩ -
Apple Developer, « Preserving your app’s model data across launches » et « Adopting SwiftData for a Core Data app ». Sémantique de la migration légère et ce qui déclenche l’abandon du framework. ↩
-
Apple Developer, « VersionedSchema » et « SchemaMigrationPlan ». Déclarations de schémas versionnés, définitions d’étapes de migration, et le constructeur
ModelContainerqui prend un plan de migration. ↩ -
Apple Developer, « Defining data relationships with enumerations and model classes » et « Schema.Relationship ». La macro
@Relationship, les optionsdeleteRule(.cascade,.nullify,.deny,.noAction) et le rôle du paramètreinverse:dans la maintenance des relations bidirectionnelles. ↩ -
Analyse de l’auteur dans Deux écosystèmes d’agents, une seule liste de courses, 29 avril 2026, et Cinq plateformes Apple, trois fichiers partagés. Les motifs de synchronisation inter-processus et inter-appareils de Get Bananas + Return qui complètent (et parfois remplacent) SwiftData à l’intérieur d’un workflow multi-processus. ↩