← Tous les articles

SwiftData's Real Cost Is Schema Discipline

TITLE : Le vrai coût de SwiftData, c’est la discipline du schéma DESCRIPTION : L’TERM_18 de SwiftData se résume à deux macros. Le coût, c’est ce qui se passe après la mise en production. Les champs optionnels offrent la migration bon marché ; les ajouts non optionnels exigent un VersionedSchema. BODY: Genre : shipped-code. Cet article documente les décisions de schéma SwiftData prises dans Get Bananas, Return et Reps : trois applications dont le schéma a soit survécu à une migration propre, soit payé une taxe pour ne pas avoir planifié la migration. Le ShoppingItem de Get Bananas en est l’exemple canonique. Le schéma initial n’incluait pas d’horodatage lastModified ; l’ajouter par la suite a exigé 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 lors de son premier ajout en tant que non optionnel.1

L’TERM_18 de SwiftData se résume à deux macros. @Model sur une classe en fait un type persistant. @Attribute(.unique) sur une propriété lui ajoute une contrainte d’unicité. Le framework masque la gestion de la pile de Core Data, la chorégraphie des value-transformers et la plomberie du NSManagedObjectContext. Ce que le framework ne masque pas, c’est la migration de schéma ; il la rend simplement déclarative au lieu d’impérative. Le coût de l’inattention 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 bon marché à démarrer et coûteux à migrer en bâclant. 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 @Model transforme une classe en type persistant SwiftData. Le framework génère le schéma à partir des déclarations de propriétés au moment de la compilation.
  • L’ajout d’une nouvelle propriété optionnelle est une migration sans effet : la migration légère de SwiftData s’en charge. L’ajout d’une propriété non optionnelle à un schéma existant exige un VersionedSchema accompagné d’un MigrationPlan qui indique au framework comment peupler le nouveau champ pour les lignes existantes.
  • Le coût d’avoir sauté VersionedSchema dès le premier jour, c’est que tout changement de schéma v2 non trivial risque de faire perdre la base de données d’un utilisateur, parce que le chemin léger est conservateur et abandonne quand il ne peut pas inférer la migration.
  • @Attribute(.unique) est l’outil approprié pour les clés naturelles (un UUID que vous avez généré, un identifiant externe que vous avez importé). @Relationship est l’outil approprié pour les références parent/enfant. Les deux sont des macros qui génèrent la bonne plomberie Core Data en coulisses.2

Ce que fait réellement @Model

Un type SwiftData est une classe Swift à laquelle on applique la macro @Model. 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 sur cette forme que l’TERM_18 masque.

@Model n’exige pas de déclaration de schéma de magasin persistant séparée. SwiftData lit la définition de classe au moment de 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 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 ». Quand 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 compte pour le code produit : .unique n’est pas une validation au niveau de l’UI qui empêche la soumission de doublons ; 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 cours de processus), et le comportement d’upsert est exactement ce que vous voulez quand 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 à la prochaine sauvegarde du contexte. L’intégration SwiftUI via @Query rend à nouveau toute vue qui observe le prédicat correspondant. Le motif est similaire à @Observable (couvert dans Ce dont 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 l’optionalité est porteuse de charge. Le champ a été ajouté après la sortie de la v1 pour prendre en charge la synchronisation entre 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 leur donne.3

Le chemin de migration légère est le chemin poli 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 le chemin léger gère proprement :

  • Ajout d’une propriété optionnelle
  • Suppression d’une propriété (les données sont supprimées ; les lectures existantes ne voient plus la colonne)
  • Renommage d’un attribut que le framework peut mettre en correspondance par indication (à l’aide de @Attribute(originalName: ...))
  • Renommage d’une classe @Model que le framework peut mettre en correspondance (à l’aide de @Model.originalName ou d’une indication)

Les cas où le chemin léger abandonne :

  • Ajout d’une propriété non optionnelle sans valeur par défaut à un schéma existant (les lignes existantes n’ont pas de valeur pour la peupler)
  • Changement du type d’une propriété (par exemple, IntString)
  • Scission d’un modèle en deux modèles, ou fusion de deux en un seul
  • Tout ce qui exige une logique personnalisée de migration

Quand le chemin léger abandonne, le comportement sûr est d’échouer la migration. Le comportement dangereux serait de jeter la base de données et de recommencer ; 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 stack trace pointant vers l’incohérence du schéma ; personne ne perd de données, mais tout le monde perd confiance.

Le coût d’avoir sauté VersionedSchema dès le premier jour apparaît à la frontière v2 → v3, quand vous ajoutez la troisième fonctionnalité dont le changement de schéma dépasse ce que le chemin léger gère.

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èle 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 la façon dont le schéma évolue. Quand l’application en 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. Quand vous publiez la v3, vous ajoutez SchemaV3.self à schemas et un nouveau MigrationStage entre v2 et v3.

La discipline consiste à publier VersionedSchema en v1, même quand il n’y a qu’une seule version. Le coût en 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 de schéma non trivial de la v2 exige d’envelopper rétroactivement la v1 dans un VersionedSchema, ce qui est faisable mais demande de l’attention pour faire correspondre la forme exacte 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 scissions, les fusions et les peuplements conditionnels exigent 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 TERM_18 utilisés dans l’application en cours d’exécution), opérant contre un contexte de migration transitoire.

Le motif qui survit en production consiste à garder willMigrate vide et à mettre toute la logique de peuplement dans didMigrate. Lire les données v1 à l’intérieur de willMigrate est autorisé, mais le schéma v2 n’existe pas encore du point de vue du framework, donc tout calcul doit être mis en attente 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 propres à la v2 sur les lignes existantes est le travail de didMigrate.

Quand @Attribute et @Relationship méritent leur nom

Deux macros font 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 dans ShoppingItem.id
  • @Attribute(.externalStorage) stocke les gros blobs Data en dehors de la base de données (données d’image, tampons audio)
  • @Attribute(originalName: "old_field_name") met en correspondance une propriété avec une colonne renommée pendant la migration
  • @Attribute(.transformable(by: ...)) applique un ValueTransformer à 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 quand un renommage en v2 d’une propriété ferait autrement perdre 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 la suppression du 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 un 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 fausses ; la forme explicite, c’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 publient, soit auraient souhaité publier.

Publiez VersionedSchema dès la v1. Chaque classe @Model en production devrait vivre à l’intérieur d’un VersionedSchema dès le premier jour. Le coût, c’est un enum d’enveloppement 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 ligne à MigrationPlan.schemas au lieu d’une refactorisation rétroactive de deux jours.

Rendez chaque horodatage optionnel. Les champs comme lastModified, createdAt et updatedAt qui existent pour la synchronisation entre 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 simple 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 cours de processus. La synchronisation entre appareils, l’intégration TERM_17 (couverte dans Deux écosystèmes d’agents, une seule liste de courses), et toute référence hors processus ont besoin d’un identifiant stable. Un UUID avec @Attribute(.unique) est la bonne forme ; le PersistentIdentifier en cours de processus 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). Le surcoût de SwiftData pour une seule ligne est une cérémonie gaspillée ; les magasins clé-valeur sont le bon substrat.

Données autoritaires côté serveur sans écritures hors ligne. Une liste récupérée depuis une TERM_18 REST et affichée en lecture seule. SwiftData est excessif 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 fonctionne à l’intérieur d’un processus. Un serveur TERM_17 s’exécutant en dehors de l’application iOS ne peut ni lire ni écrire dans le conteneur SwiftData de l’application. L’état inter-processus a besoin d’une forme différente : un fichier TERM_12 iCloud Drive, un conteneur App Group partagé, ou une couche de synchronisation explicite qui fait le pont entre les processus. (Get Bananas associe SwiftData à un fichier TERM_12 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 se trouvent à l’intérieur des lignes SwiftData ; sinon, utilisez le système de fichiers directement avec des métadonnées dans SwiftData pointant vers des URL de fichiers.

Ce que le motif signifie pour les applications publiées sur iOS 26+

Trois enseignements.

  1. Les macros sont la partie facile. Les migrations sont le coût. @Model et @Attribute sont des déclarations de deux lignes qui masquent une grande quantité de plomberie Core Data. La discipline de migration, c’est ce que vous payez réellement sur la durée de vie de l’application ; concevez la v1 en pensant à la v2.

  2. VersionedSchema dès le premier jour est non négociable pour les applications en production. L’enum d’enveloppement, c’est un fichier supplémentaire. Le coût rétroactif de l’ajouter plus tard est bien plus élevé.

  3. Les champs optionnels et les relations explicites sont l’assurance bon marché. Horodatages optionnels pour les métadonnées de synchronisation, deleteRule et inverse: explicites sur les relations. Les deux sont de minuscules déclarations qui achètent beaucoup de flexibilité pour la v2.

Le cluster Apple Ecosystem complet : App Intents typés pour Apple Intelligence ; serveurs TERM_17 pour les agents inter-TERM_23 ; la question du routage entre eux ; Foundation Models pour le TERM_23 sur appareil et le protocole Tool ; Live Activities pour la machine à états de l’écran de verrouillage sur iOS ; le contrat du 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 dans la série Apple Ecosystem. Pour un contexte iOS-avec-agents-IA plus large, 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 en coulisses. SwiftData utilise Core Data comme magasin de support, donc le modèle d’exécution est le même ; la différence est la surface. @Model supprime le fichier .xcdatamodeld, la cérémonie des value-transformers et la gestion du cycle de vie du NSManagedObjectContext. Vous obtenez le même magasin persistant avec une TERM_18 de forme Swift.

Ai-je besoin de VersionedSchema si je ne prévois jamais de changer le schéma ?

Si votre application pourrait publier une v2, oui. Si c’est une démo unique, non. Le coût de VersionedSchema dès la v1, c’est une déclaration enum supplémentaire. Le coût de l’ajouter rétroactivement à la v2, c’est faire correspondre la forme exacte du schéma v1 pour que le framework reconnaisse les données existantes, ce qui est faisable mais propice aux erreurs. La plupart des applications en production auront éventuellement 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 arrivant de deux appareils) sûrs ; c’est aussi pourquoi .unique est le mauvais outil pour 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 facile : déclarez le champ comme optionnel dans la nouvelle version de schéma et remplissez-le paresseusement à l’accès. L’optionalité, c’est la migration la moins chère ; les ajouts non optionnels exigent une logique de peuplement explicite.

Qu’est-ce que PersistentIdentifier par rapport à mon propre UUID ?

PersistentIdentifier est l’ID de ligne en cours de processus 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, inter-appareils. Utilisez PersistentIdentifier pour les références en cours de processus à l’intérieur de l’application. Utilisez un UUID pour tout ce qui traverse une frontière de processus (synchronisation entre appareils, intégrations externes, outils TERM_17, appels réseau).

Références


  1. Get Bananas de l’auteur, une application SwiftUI de liste de courses qui associe SwiftData à la synchronisation TERM_12 iCloud Drive et à un serveur TERM_17. Le modèle ShoppingItem a évolué au cours du cycle de développement précoce ; le champ lastModified: Date? a été ajouté après le schéma initial (commit 268a00d le 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 à peupler. 

  2. Apple Developer, « SwiftData » et « Adding and editing persistent data in your app ». La macro @Model, la surface de contrainte @Attribute et la relation au NSManagedObjectModel de Core Data. 

  3. Apple Developer, « Preserving your app’s model data across launches » et « Adopting SwiftData for a Core Data app ». Sémantique de migration légère et ce qui déclenche l’abandon du framework. 

  4. Apple Developer, « VersionedSchema » et « SchemaMigrationPlan ». Déclarations de schémas versionnés, définitions d’étapes de migration, et le constructeur ModelContainer qui prend un plan de migration. 

  5. Apple Developer, « Defining data relationships with enumerations and model classes » et « Schema.Relationship ». La macro @Relationship, les options de deleteRule (.cascade, .nullify, .deny, .noAction), et le rôle du paramètre inverse: dans la maintenance de relations bidirectionnelles. 

  6. 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 flux de travail multi-processus. 

Articles connexes

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 lecture

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 lecture

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 lecture