Core Data vs SwiftData en Swift : implémentation technique détaillée

Core Data et SwiftData sont les deux grandes solutions d’Apple pour la persistance de données sur iOS / macOS. Core Data est l’outil historique, très puissant et très configurable. SwiftData est la nouvelle couche, construite pour Swift moderne et SwiftUI.

Cet article est volontairement plus technique que la version “non-technique” :

  • on voit comment implémenter Core Data et SwiftData,
  • on détaille les options possibles les plus courantes,
  • on illustre avec des exemples de code concrets en Swift.

1. Core Data : architecture, stack et options

1.1 Rappels : à quoi sert Core Data ?

Core Data est un framework d’object graph management qui fournit :

  • un modèle d’objets (entités, attributs, relations) ;
  • un store (SQLite, in-memory, binaire) pour persister ces objets ;
  • les outils pour créer / lire / mettre à jour / supprimer (CRUD) ;
  • des options avancées : faulting, undo, batch updates, migration, contraintes

1.2 Modèle de données Core Data (entités, attributs, relations)

Le modèle est généralement défini dans un fichier .xcdatamodeld via Xcode, mais depuis quelques années, on peut aussi utiliser des classes Swift pures avec NSManagedObject.

Concepts principaux :

  • Entity : un type d’objet (ex : Note, User).
  • Attribute : un champ (ex : title: String, createdAt: Date).
  • Relationship : lien entre entités (ex : un user a plusieurs notes).

Options importantes sur les attributs :

  • Type : String, Integer, Boolean, Date, Binary, Transformable…
  • Optional : autorise les valeurs nulles (nil).
  • Default Value : valeur par défaut.
  • Indexed : index pour accélérer certaines recherches.
  • Transformable : pour stocker un type custom (codable, etc.).

Options importantes sur les relations :

  • To-One / To-Many : relation 1–1, 1–N…
  • Inverse : relation inverse (par ex. Note.user <–> User.notes).
  • Delete Rule :
    • Nullify : met l’inverse à nil sans supprimer l’objet. (Exemple : On a un user : user1, on a deux notes : noteA et noteB, noteA.user = user1, noteB.user = user1, si on supprime user1, noteA.user et noteB.user deviennent nil)
    • Cascade : supprime les objets liés.
    • Deny : empêche la suppression si des objets liés existent.
    • No Action : ne fait rien (risque d’orphelins si mal utilisé).

1.3 Stack Core Data moderne avec NSPersistentContainer

La manière actuelle recommandée de configurer Core Data passe par NSPersistentContainer.


import CoreData

final class PersistenceController {

    static let shared = PersistenceController()

    let container: NSPersistentContainer

    init(inMemory: Bool = false) {
        container = NSPersistentContainer(name: "MyModel") // nom du .xcdatamodeld

        if inMemory {
            let description = NSPersistentStoreDescription()
            description.type = NSInMemoryStoreType
            container.persistentStoreDescriptions = [description]
        } else {
            // Options de migration légère
            let description = container.persistentStoreDescriptions.first
            description?.shouldMigrateStoreAutomatically = true
            description?.shouldInferMappingModelAutomatically = true
        }

        container.loadPersistentStores { description, error in
            if let error = error {
                fatalError("Erreur Core Data : \(error)")
            }
        }

        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
        container.viewContext.automaticallyMergesChangesFromParent = true
    }

    var viewContext: NSManagedObjectContext {
        container.viewContext
    }

    func newBackgroundContext() -> NSManagedObjectContext {
        let context = container.newBackgroundContext()
        context.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
        return context
    }
}

Options importantes :

  • NSInMemoryStoreType vs NSSQLiteStoreType (par défaut) vs binaire (deprecated).
  • shouldMigrateStoreAutomatically + shouldInferMappingModelAutomatically pour la migration légère.
  • mergePolicy : définit comment résoudre les conflits (store vs mémoire).
  • automaticallyMergesChangesFromParent : fusionne automatiquement les changements venant des contextes enfants.

1.4 Exemple d’entité en code : Note

On va supposer une entité Note définie dans le modèle.


class Note: NSManagedObject {
    @NSManaged var title: String
    @NSManaged var content: String?
    @NSManaged var createdAt: Date
}

Optionnel : on peut ajouter des computed properties non persistées (ex : un résumé du contenu).


1.5 CRUD avec Core Data

Création


func createNote(title: String, content: String?, context: NSManagedObjectContext) throws {
    let note = Note(context: context)
    note.title = title
    note.content = content
    note.createdAt = Date()

    try context.save()
}

Lecture (fetch)


func fetchNotes(context: NSManagedObjectContext) throws -> [Note] {
    let request: NSFetchRequest<Note> = Note.fetchRequest() as! NSFetchRequest<Note>
    request.sortDescriptors = [
        NSSortDescriptor(key: #keyPath(Note.createdAt), ascending: false)
    ]
    // Options possibles :
    // request.fetchLimit = 50
    // request.fetchOffset = 0
    // request.returnsObjectsAsFaults = true
    // request.includesPendingChanges = true

    return try context.fetch(request)
}

Mise à jour

On récupère une entité, on modifie ses propriétés, puis save().


func updateNote(_ note: Note, newTitle: String, context: NSManagedObjectContext) throws {
    note.title = newTitle
    try context.save()
}

Suppression


func deleteNote(_ note: Note, context: NSManagedObjectContext) throws {
    context.delete(note)
    try context.save()
}

1.6 Fetch avancé : prédicats, batch, faulting

Filtrer avec NSPredicate


// Notes dont le titre contient un mot
request.predicate = NSPredicate(format: "title CONTAINS[cd] %@", "swift")

Exemples d’opérateurs :

  • ==, >, <, BETWEEN
  • CONTAINS, BEGINSWITH, ENDSWITH
  • IN, ANY, ALL

Batch size, fetch limit


request.fetchBatchSize = 20   // charge par "pages"
request.fetchLimit = 100      // max 100 résultats

Faulting (optimisation mémoire)

Core Data peut ne pas charger immédiatement toutes les propriétés (faults). Cela améliore les perfs, surtout pour de gros jeux de données.


request.returnsObjectsAsFaults = true // par défaut true, on peut désactiver pour tout charger

1.7 Concurrence et contextes

Core Data n’est pas thread-safe par défaut. D’où les types de contextes :

  • .mainQueueConcurrencyType : pour l’UI.
  • .privateQueueConcurrencyType : pour le traitement en arrière-plan.

Avec NSPersistentContainer :

  • viewContext : main queue, pour l’UI.
  • newBackgroundContext() : contexte en arrière-plan.

Pattern courant :


let backgroundContext = PersistenceController.shared.newBackgroundContext()

backgroundContext.perform {
    do {
        let notes = try fetchNotes(context: backgroundContext)
        // Traitement lourd ici
        // ...
        try backgroundContext.save()
    } catch {
        print(error)
    }
}

1.8 Batch updates / deletes

Pour modifier ou supprimer un grand nombre d’objets sans tout charger en mémoire :

Batch Update


let batch = NSBatchUpdateRequest(entityName: "Note")
batch.propertiesToUpdate = ["title": "Titre par défaut"]
batch.resultType = .updatedObjectsCountResultType

let result = try context.execute(batch) as? NSBatchUpdateResult
print("Mises à jour : \(result?.result ?? 0)")

Batch Delete


let fetchRequest: NSFetchRequest<NSFetchRequestResult> = Note.fetchRequest()
let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)

try context.execute(deleteRequest)

Attention : il faut généralement merge les changements dans le contexte pour que l’UI se mette à jour.


1.9 Migration et versioning

Quand on change le modèle (ajout d’attribut, renommage…), il faut gérer la migration.

Core Data propose :

  • Migration légère :
    • ajout d’attribut optionnel,
    • renommage mappé,
    • certains changements simples.
  • Migration personnalisée :
    • nécessite un mapping model et éventuellement un NSEntityMigrationPolicy.

Les options vues plus haut (shouldMigrateStoreAutomatically et shouldInferMappingModelAutomatically) activent la migration légère automatique.


2. SwiftData : modèle, container, @Query, relations

2.1 Philosophie SwiftData

SwiftData est conçu pour :

  • déclarer les modèles directement en Swift avec @Model ;
  • intégrer naturellement SwiftUI (@Query, modelContext) ;
  • simplifier la configuration par rapport à Core Data.

2.2 Modèle SwiftData avec @Model

Exemple Note :


import SwiftData

@Model
class Note {
    @Attribute(.unique) var id: UUID
    var title: String
    var content: String
    var createdAt: Date

    init(title: String, content: String) {
        self.id = UUID()
        self.title = title
        self.content = content
        self.createdAt = .now
    }
}

Options possibles (principales) sur les propriétés :

  • @Attribute(.unique) : contrainte d’unicité.
  • @Attribute(.externalStorage) : stockage externe pour les gros blobs.
  • @Relationship (selon la version) : pour configurer les relations (inverse, deleteRule).
  • @Transient (selon API) : pour des propriétés non persistées.

Les relations se déclarent avec des types Swift classiques :


@Model
class User {
    var name: String
    @Relationship(deleteRule: .cascade) var notes: [Note] = []

    init(name: String) {
        self.name = name
    }
}

2.3 Configurer SwiftData dans l’app

On utilise généralement .modelContainer(for:) dans le point d’entrée SwiftUI.


import SwiftUI
import SwiftData

@main
struct NotesApp: App {

    var body: some Scene {
        WindowGroup {
            NotesListView()
        }
        .modelContainer(for: [Note.self, User.self])
    }
}

.modelContainer(for:) peut aussi recevoir une ModelConfiguration pour définir :

  • le type de store (par défaut, SQLite),
  • le nom du fichier,
  • des options de migration, etc. (API évolutive).

2.4 modelContext : le “contexte” SwiftData

SwiftData expose un ModelContext, concept similaire au NSManagedObjectContext de Core Data.

Dans SwiftUI :


struct NotesListView: View {
    @Environment(\.modelContext) private var context

    // ...
}

Avec ce contexte, on peut :

  • insert des objets,
  • delete,
  • save (ou laisser SwiftData gérer selon configuration).

func addNote(title: String, content: String) {
    let note = Note(title: title, content: content)
    context.insert(note)
    try? context.save()
}

2.5 @Query : requêtes réactives dans SwiftUI

@Query permet de lier une requête SwiftData à une vue SwiftUI. Quand les données changent, la vue se met à jour automatiquement.


struct NotesListView: View {

    @Environment(\.modelContext) private var context

    @Query(sort: \Note.createdAt, order: .reverse)
    private var notes: [Note]

    var body: some View {
        List {
            ForEach(notes) { note in
                Text(note.title)
            }
            .onDelete(perform: delete)
        }
        .toolbar {
            Button(action: addSampleNote) {
                Image(systemName: "plus")
            }
        }
    }

    private func addSampleNote() {
        let note = Note(title: "Nouvelle note", content: "Contenu")
        context.insert(note)
        try? context.save()
    }

    private func delete(at offsets: IndexSet) {
        for index in offsets {
            context.delete(notes[index])
        }
        try? context.save()
    }
}

Options de @Query (principales) :

  • sort: : tri par une propriété (ou plusieurs).
  • order: : .forward / .reverse.
  • Filtrage possible via prédicats (selon version de l’API) avec filter: ou closure.

2.6 Requêtes manuelles avec FetchDescriptor

En dehors de @Query, on peut utiliser FetchDescriptor pour des requêtes plus ciblées.


import SwiftData

func fetchNotesContaining(_ text: String, context: ModelContext) throws -> [Note] {
    var descriptor = FetchDescriptor<Note>(
        predicate: #Predicate { $0.title.contains(text) },
        sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
    )
    descriptor.fetchLimit = 50
    return try context.fetch(descriptor)
}

Ici, #Predicate permet d’écrire des prédicats fortement typés en Swift.


2.7 Relations et delete rules en SwiftData

On peut configurer des relations à la manière de Core Data :


@Model
class User {
    var name: String
    @Relationship(deleteRule: .cascade, inverse: \Note.owner)
    var notes: [Note] = []

    init(name: String) {
        self.name = name
    }
}

@Model
class Note {
    var title: String
    var content: String
    @Relationship(inverse: \User.notes)
    var owner: User?

    init(title: String, content: String, owner: User? = nil) {
        self.title = title
        self.content = content
        self.owner = owner
    }
}

Delete rules disponibles (parallèles à Core Data) : .cascade, .nullify, .deny, etc.


2.8 Migration et configuration SwiftData (vue d’ensemble)

SwiftData gère l’évolution du schéma via :

  • l’analyse de vos modèles @Model,
  • la configuration du ModelContainer et des ModelConfiguration.

De manière générale, l’idée est similaire à la migration légère de Core Data : les changements simples (ajout de propriété optionnelle, etc.) sont gérés automatiquement, pour les cas plus complexes, il faudra des stratégies plus avancées (et l’API évolue).


3. Core Data vs SwiftData : implémentation et choix

3.1 Quand privilégier Core Data ?

  • App existante en production avec Core Data déjà en place.
  • Support de versions iOS plus anciennes non compatibles SwiftData.
  • Besoins avancés très précis déjà couverts par votre stack Core Data.

3.2 Quand privilégier SwiftData ?

  • Nouvelle app en SwiftUI ciblant des OS récents (iOS 17+ selon le SDK).
  • Envie de réduire la “plomberie” des modèles et du stack.
  • Architecture moderne (MVI / MVVM + SwiftUI) avec logique réactive.

3.3 Coexistence ?

En pratique, on choisit généralement l’un ou l’autre pour un projet. On peut théoriquement faire cohabiter les deux, mais cela complique la maintenance (deux stacks de persistance à gérer et synchroniser).


4. Exemple de mise en place end-to-end (SwiftData dans un mini projet SwiftUI)

Pour finir, un exemple d’architecture simple complète avec SwiftData :

4.1 Modèle


@Model
class TaskItem {
    var title: String
    var isDone: Bool
    var createdAt: Date

    init(title: String, isDone: Bool = false) {
        self.title = title
        self.isDone = isDone
        self.createdAt = .now
    }
}

4.2 Point d’entrée de l’app


@main
struct TasksApp: App {
    var body: some Scene {
        WindowGroup {
            TaskListView()
        }
        .modelContainer(for: TaskItem.self)
    }
}

4.3 Vue principale


struct TaskListView: View {
    @Environment(\.modelContext) private var context

    @Query(sort: \TaskItem.createdAt, order: .reverse)
    private var tasks: [TaskItem]

    @State private var newTitle: String = ""

    var body: some View {
        NavigationStack {
            VStack {
                HStack {
                    TextField("Nouvelle tâche", text: $newTitle)
                    Button("Ajouter") {
                        addTask()
                    }
                    .disabled(newTitle.isEmpty)
                }
                .padding()

                List {
                    ForEach(tasks) { task in
                        HStack {
                            Image(systemName: task.isDone ? "checkmark.circle.fill" : "circle")
                                .onTapGesture {
                                    toggle(task)
                                }
                            Text(task.title)
                        }
                    }
                    .onDelete(perform: delete)
                }
            }
            .navigationTitle("Mes tâches")
        }
    }

    private func addTask() {
        let item = TaskItem(title: newTitle)
        context.insert(item)
        try? context.save()
        newTitle = ""
    }

    private func toggle(_ task: TaskItem) {
        task.isDone.toggle()
        try? context.save()
    }

    private func delete(at offsets: IndexSet) {
        for index in offsets {
            context.delete(tasks[index])
        }
        try? context.save()
    }
}

En quelques dizaines de lignes, on a :

  • un modèle persistant (@Model),
  • un container de données (.modelContainer),
  • des requêtes réactives (@Query),
  • des opérations de persistance simples (insert, delete, save).

Conclusion

Core Data et SwiftData offrent deux manières de gérer la persistance dans une app Swift :

  • Core Data : très complet, mature, configurable, idéal pour les apps existantes et les besoins complexes ;
  • SwiftData : moderne, concis, conçu pour SwiftUI, idéal pour les nouveaux projets ciblant des OS récents.

En comprenant :

  • comment construire le modèle (entités, attributs, relations),
  • comment configurer la stack (NSPersistentContainer vs ModelContainer),
  • comment écrire des requêtes (NSFetchRequest/NSPredicate vs @Query/FetchDescriptor),
  • et quelles options sont disponibles (delete rules, batch operations, @Attribute, @Relationship),

tu peux choisir et implémenter une solution de persistance adaptée à ton application, tout en gardant un code structuré, testable et évolutif.