SwiftData in iOS 26: Guida Completa all'Ereditarietà dei Modelli, Query e Migrazione

Guida pratica all'ereditarietà dei modelli in SwiftData per iOS 26: gerarchie di classi, query polimorfiche, ottimizzazione fetch e migrazione schema con esempi di codice pronti all'uso.

Introduzione

Finalmente. Con iOS 26, presentato alla WWDC 2025, Apple ha introdotto una delle funzionalità che la community aspettava da tempo: l'ereditarietà dei modelli in SwiftData. E onestamente? Era ora.

Chi ha lavorato con SwiftData fino a iOS 18 lo sa bene: senza ereditarietà, ci si ritrovava a copiare e incollare proprietà comuni tra modelli diversi, con tutti i rischi che ne conseguono — inconsistenze, bug sottili, codice difficile da mantenere. Con iOS 26 tutto questo cambia. Possiamo finalmente definire classi base con proprietà condivise e specializzarle tramite sottoclassi, esattamente come faremmo in qualsiasi architettura orientata agli oggetti.

In questa guida vediamo come funziona nella pratica: dalla definizione delle gerarchie alle query polimorfiche, dall'ottimizzazione dei fetch alla migrazione dello schema. Il tutto con esempi di codice pronti da usare nei vostri progetti.

Requisiti e Configurazione del Progetto

Prima di partire, assicuriamoci di avere tutto il necessario:

  • Xcode 26 o versione successiva
  • macOS Tahoe (macOS 26) come ambiente di sviluppo
  • Target di deployment impostato su iOS 26+
  • Il framework SwiftData importato nel progetto

Attenzione a un dettaglio che può sembrare ovvio ma che è facile dimenticare: l'ereditarietà dei modelli funziona solo a partire da iOS 26. Ogni utilizzo di questa API deve essere annotato con @available(iOS 26, *). Se la vostra app deve supportare anche versioni precedenti, preparatevi a gestire la coesistenza con i classici check di disponibilità.

Definire una Gerarchia di Modelli

Il concetto di base è piuttosto semplice: si definisce una classe base con il macro @Model contenente le proprietà comuni, e poi si creano sottoclassi che aggiungono proprietà specifiche. Niente di rivoluzionario come pattern, ma averlo finalmente disponibile in SwiftData fa una differenza enorme.

Prendiamo un esempio concreto — un'app per la gestione di eventi:

import SwiftData

// Classe base: contiene le proprietà condivise da tutti gli eventi
@Model
class Evento {
    var titolo: String
    var luogo: String
    var dataInizio: Date
    var durata: TimeInterval

    init(titolo: String, luogo: String, dataInizio: Date, durata: TimeInterval) {
        self.titolo = titolo
        self.luogo = luogo
        self.dataInizio = dataInizio
        self.durata = durata
    }
}

Fin qui niente di nuovo. Ora creiamo due sottoclassi che specializzano il concetto di evento:

import SwiftData

// Sottoclasse per eventi di lavoro
@available(iOS 26, *)
@Model
class EventoLavoro: Evento {
    var budget: Decimal = 0.0
    var codiceDipartimento: String = ""

    init(titolo: String, luogo: String, dataInizio: Date,
         durata: TimeInterval, budget: Decimal, codiceDipartimento: String) {
        self.budget = budget
        self.codiceDipartimento = codiceDipartimento
        super.init(titolo: titolo, luogo: luogo,
                   dataInizio: dataInizio, durata: durata)
    }
}

// Sottoclasse per eventi sociali
@available(iOS 26, *)
@Model
class EventoSociale: Evento {
    enum Categoria: String, CaseIterable, Codable {
        case compleanno, matrimonio, festa, riunione
    }

    var categoria: Categoria = .festa
    var numeroInvitati: Int = 0

    init(titolo: String, luogo: String, dataInizio: Date,
         durata: TimeInterval, categoria: Categoria, numeroInvitati: Int) {
        self.categoria = categoria
        self.numeroInvitati = numeroInvitati
        super.init(titolo: titolo, luogo: luogo,
                   dataInizio: dataInizio, durata: durata)
    }
}

Ci sono alcuni punti da tenere a mente:

  • Il macro @Model va applicato sia alla classe base che alle sottoclassi
  • Le sottoclassi devono essere annotate con @available(iOS 26, *)
  • Le proprietà specifiche delle sottoclassi hanno valori di default — e questo è fondamentale per la migrazione (ci arriviamo tra poco)
  • L'inizializzatore della sottoclasse chiama super.init per impostare le proprietà ereditate

Configurazione del ModelContainer

Ecco un passaggio che è davvero facile dimenticare (e vi assicuro che il debug non è piacevole): tutte le classi della gerarchia devono essere registrate esplicitamente nel ModelContainer. Non basta registrare solo la classe base.

import SwiftUI
import SwiftData

@main
struct EventiApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [
            Evento.self,
            EventoLavoro.self,
            EventoSociale.self
        ])
    }
}

Se dimenticate di registrare una sottoclasse, SwiftData non sarà in grado di persistere o recuperare istanze di quel tipo. E il bello è che l'errore potrebbe non manifestarsi subito, rendendo il tutto ancora più frustrante da diagnosticare.

Ereditarietà vs Composizione: Quando Usare Cosa

Ora, prima di abbracciare l'ereditarietà ovunque (lo so, la tentazione è forte), è importante capire quando ha davvero senso. La regola base è la classica relazione "è-un" (is-a):

  • Un EventoLavoro è un Evento → ereditarietà appropriata
  • Un Evento ha un Luogo → composizione più appropriata (usate @Relationship)

L'ereditarietà funziona bene quando:

  • I modelli formano una gerarchia naturale con caratteristiche condivise
  • Avete bisogno di query polimorfiche (recuperare tutti i tipi insieme)
  • Le sottoclassi condividono molte proprietà dalla classe base

Meglio evitarla quando:

  • Le sottoclassi hanno attributi radicalmente diversi con poca sovrapposizione
  • Una sottoclasse ha ordini di grandezza più dati delle altre (spoiler: la tabella condivisa ne risente)
  • Non avete necessità di query unificate sul tipo padre

Query Polimorfiche e Filtraggio per Tipo

Questa è probabilmente la parte più interessante. Con l'ereditarietà in SwiftData potete eseguire query polimorfiche: interrogate la classe base e ottenete tutte le istanze, indipendentemente dal sottotipo. Oppure filtrate per tipo specifico. La flessibilità è notevole.

Query sulla classe base

import SwiftUI
import SwiftData

struct ListaEventiView: View {
    // Recupera TUTTI gli eventi, incluse le sottoclassi
    @Query(sort: \Evento.dataInizio) var tuttiGliEventi: [Evento]

    var body: some View {
        List(tuttiGliEventi, id: \.id) { evento in
            VStack(alignment: .leading) {
                Text(evento.titolo)
                    .font(.headline)
                Text(evento.luogo)
                    .foregroundStyle(.secondary)

                // Cast condizionale per accedere a proprietà specifiche
                if let eventoLavoro = evento as? EventoLavoro {
                    Text("Budget: \(eventoLavoro.budget, format: .currency(code: "EUR"))")
                        .foregroundStyle(.blue)
                } else if let eventoSociale = evento as? EventoSociale {
                    Text("Invitati: \(eventoSociale.numeroInvitati)")
                        .foregroundStyle(.green)
                }
            }
        }
    }
}

Filtraggio per sottotipo con Predicate

iOS 26 introduce l'operatore is all'interno di #Predicate, e questo semplifica enormemente il filtraggio per tipo:

import SwiftData

// Filtro per tipo usando l'operatore is
enum FiltroEvento: String, CaseIterable {
    case tutti, lavoro, sociale
}

func predicatoPerFiltro(_ filtro: FiltroEvento) -> Predicate<Evento>? {
    switch filtro {
    case .tutti:
        return nil
    case .lavoro:
        return #Predicate<Evento> { $0 is EventoLavoro }
    case .sociale:
        return #Predicate<Evento> { $0 is EventoSociale }
    }
}

Predicati combinati: tipo + ricerca

Nella pratica, molto spesso avrete bisogno di combinare il filtraggio per tipo con criteri di ricerca testuale. Ecco come fare:

import SwiftData

func predicatoCombinato(filtro: FiltroEvento,
                        testoRicerca: String) -> Predicate<Evento> {
    let predicatoRicerca = #Predicate<Evento> {
        testoRicerca.isEmpty ||
        $0.titolo.localizedStandardContains(testoRicerca) ||
        $0.luogo.localizedStandardContains(testoRicerca)
    }

    switch filtro {
    case .tutti:
        return predicatoRicerca
    case .lavoro:
        return #Predicate<Evento> {
            predicatoRicerca.evaluate($0) && $0 is EventoLavoro
        }
    case .sociale:
        return #Predicate<Evento> {
            predicatoRicerca.evaluate($0) && $0 is EventoSociale
        }
    }
}

Poche righe di codice per un sistema di ricerca e filtro piuttosto sofisticato. Non male.

Ottimizzazione delle Query con FetchDescriptor

Quando la gerarchia di modelli inizia a contenere migliaia di record, l'ottimizzazione dei fetch diventa cruciale. Per fortuna, SwiftData offre diversi strumenti per tenere le cose sotto controllo.

Fetch selettivo delle proprietà

Invece di caricare l'intero oggetto in memoria (cosa che con gerarchie complesse può pesare parecchio), potete specificare esattamente quali proprietà vi servono:

import SwiftData

func caricaAnteprimaEventi(context: ModelContext) throws -> [Evento] {
    var descriptor = FetchDescriptor<Evento>()
    // Carica solo titolo e data, non tutte le proprietà
    descriptor.propertiesToFetch = [\.titolo, \.dataInizio]
    descriptor.sortBy = [SortDescriptor(\.dataInizio)]
    descriptor.fetchLimit = 20

    return try context.fetch(descriptor)
}

Limitare i risultati

import SwiftData

func prossimiEventi(context: ModelContext) throws -> [Evento] {
    var descriptor = FetchDescriptor<Evento>(
        predicate: #Predicate { $0.dataInizio >= Date.now },
        sortBy: [SortDescriptor(\.dataInizio)]
    )
    descriptor.fetchLimit = 5
    return try context.fetch(descriptor)
}

Query diretta su sottoclasse

Se sapete già che vi servono solo eventi di un tipo specifico, potete interrogare direttamente la sottoclasse. È più efficiente che recuperare tutto e filtrare dopo — e il codice risulta anche più leggibile:

import SwiftUI
import SwiftData

struct EventiLavoroView: View {
    // Query direttamente sulla sottoclasse
    @Query(sort: \EventoLavoro.dataInizio)
    var eventiLavoro: [EventoLavoro]

    var body: some View {
        List(eventiLavoro, id: \.id) { evento in
            VStack(alignment: .leading) {
                Text(evento.titolo)
                Text("Dipartimento: \(evento.codiceDipartimento)")
                Text("Budget: \(evento.budget, format: .currency(code: "EUR"))")
            }
        }
    }
}

Migrazione dello Schema con Ereditarietà

Parliamo di migrazione, che è spesso la parte che mette più ansia agli sviluppatori (a ragione). Quando aggiungete sottoclassi a un'app già rilasciata, la migrazione dello schema è un passaggio obbligato. SwiftData usa il sistema di VersionedSchema e SchemaMigrationPlan per gestire il tutto.

Passo 1: Definire le versioni dello schema

Se avete già rilasciato versioni precedenti dell'app, dovreste avere almeno uno schema versionato. Aggiungete una nuova versione che includa le sottoclassi:

import SwiftData

// Schema originale (senza ereditarietà)
enum SchemaEventiV1: VersionedSchema {
    static var versionIdentifier: Schema.Version {
        Schema.Version(1, 0, 0)
    }
    static var models: [any PersistentModel.Type] {
        [Evento.self]
    }
}

// Nuovo schema con ereditarietà (iOS 26+)
@available(iOS 26, *)
enum SchemaEventiV2: VersionedSchema {
    static var versionIdentifier: Schema.Version {
        Schema.Version(2, 0, 0)
    }
    static var models: [any PersistentModel.Type] {
        [Evento.self, EventoLavoro.self, EventoSociale.self]
    }
}

Passo 2: Creare la fase di migrazione

La buona notizia: aggiungere sottoclassi è tipicamente una migrazione leggera (lightweight). State solo aggiungendo nuove colonne alla tabella esistente, senza toccare quelle precedenti:

import SwiftData

@available(iOS 26, *)
enum PianoMigrazioneEventi: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [SchemaEventiV1.self, SchemaEventiV2.self]
    }

    static var stages: [MigrationStage] {
        [migrazioneV1aV2]
    }

    static let migrazioneV1aV2 = MigrationStage.lightweight(
        fromVersion: SchemaEventiV1.self,
        toVersion: SchemaEventiV2.self
    )
}

Passo 3: Applicare il piano di migrazione

import SwiftUI
import SwiftData

@main
struct EventiApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(
            for: [Evento.self, EventoLavoro.self, EventoSociale.self],
            migrationPlan: PianoMigrazioneEventi.self
        )
    }
}

Un consiglio che mi sento di dare: iniziate sempre con uno schema versionato, anche prima di avere bisogno della migrazione. Se avete già rilasciato un'app senza schema versionato, il primo passo è incapsulare il modello attuale in un VersionedSchema, rilasciare un aggiornamento, aspettare che la maggior parte degli utenti aggiorni, e solo poi introdurre le modifiche con ereditarietà nella versione successiva. Lo so, richiede pazienza, ma vi risparmierà problemi.

Migrazione Personalizzata (Custom Migration)

A volte la migrazione leggera non basta. Per esempio, se volete convertire eventi esistenti in sottotipi specifici basandovi su qualche criterio, serve una migrazione personalizzata:

import SwiftData

@available(iOS 26, *)
static let migrazionePersonalizzataV1aV2 = MigrationStage.custom(
    fromVersion: SchemaEventiV1.self,
    toVersion: SchemaEventiV2.self
) { context in
    // Recuperare tutti gli eventi esistenti
    let descriptor = FetchDescriptor<Evento>()
    let eventiEsistenti = try context.fetch(descriptor)

    // Logica di conversione personalizzata
    for evento in eventiEsistenti {
        // Esempio: classificare in base al titolo
        if evento.titolo.localizedCaseInsensitiveContains("meeting") ||
           evento.titolo.localizedCaseInsensitiveContains("conferenza") {
            let eventoLavoro = EventoLavoro(
                titolo: evento.titolo,
                luogo: evento.luogo,
                dataInizio: evento.dataInizio,
                durata: evento.durata,
                budget: 0,
                codiceDipartimento: "GENERALE"
            )
            context.insert(eventoLavoro)
            context.delete(evento)
        }
    }

    try context.save()
}

Le migrazioni personalizzate vi danno pieno controllo, ma richiedono anche più attenzione. Testatele a fondo con dati reali prima del rilascio — è il tipo di bug che non volete scoprire dopo che l'app è sullo Store.

Tracciamento delle Modifiche con Persistent History

Un'altra novità interessante di iOS 26: i miglioramenti nel tracciamento della cronologia persistente. Il nuovo parametro sortBy su HistoryDescriptor permette di ordinare le transazioni, il che è particolarmente utile per la sincronizzazione remota.

import SwiftData

@available(iOS 26, *)
func controllaModificheRecenti(context: ModelContext) throws {
    var historyDescriptor = HistoryDescriptor<DefaultHistoryTransaction>()
    historyDescriptor.sortBy = [
        .init(\.transactionIdentifier, order: .reverse)
    ]
    historyDescriptor.fetchLimit = 10

    let transazioni = try context.fetchHistory(historyDescriptor)

    for transazione in transazioni {
        for modifica in transazione.changes {
            let nomeEntita = modifica.changedPersistentIdentifier.entityName
            print("Modifica rilevata su: \(nomeEntita)")

            // Gestire le modifiche in base al tipo
            if ["EventoLavoro", "EventoSociale"].contains(nomeEntita) {
                // Sincronizzare con il server remoto
            }
        }
    }
}

Considerazioni sulle Prestazioni

C'è un aspetto tecnico che vale la pena capire a fondo: SwiftData (e Core Data sotto il cofano) utilizza una strategia di Single Table Inheritance (STI). In parole povere, la classe base e tutte le sottoclassi finiscono nella stessa tabella SQLite.

Cosa significa nella pratica

  • Colonne sparse: le proprietà specifiche di una sottoclasse saranno NULL per gli oggetti delle altre sottoclassi. SQLite gestisce bene i valori NULL, ma con molte sottoclassi diverse la tabella può diventare piuttosto "larga"
  • Indici condivisi: tutti i sottotipi condividono gli stessi indici, e questo può rallentare inserimenti e aggiornamenti su dataset molto grandi
  • Query polimorfiche veloci: il lato positivo è che recuperare tutti gli eventi (indipendentemente dal tipo) è molto veloce, perché non servono join tra tabelle

Best practice per le prestazioni

  • Mantenete le gerarchie poco profonde — massimo 2-3 livelli di ereditarietà
  • Usate propertiesToFetch per caricare solo le proprietà necessarie
  • Impostate fetchLimit quando non vi servono tutti i risultati
  • Per dataset molto grandi (centinaia di migliaia di record) con sottoclassi eterogenee, valutate se la composizione non sia una scelta migliore

Esempio Completo: App di Gestione Eventi

Bene, mettiamo tutto insieme. Ecco un esempio completo che mostra ereditarietà, query polimorfiche e filtraggio in azione:

import SwiftUI
import SwiftData

struct GestioneEventiView: View {
    @Environment(\.modelContext) private var context
    @Query(sort: \Evento.dataInizio) var eventi: [Evento]
    @State private var filtroSelezionato: FiltroEvento = .tutti
    @State private var testoRicerca = ""

    var eventiFiltrati: [Evento] {
        eventi.filter { evento in
            // Filtro per tipo
            let corrispondeTipo: Bool
            switch filtroSelezionato {
            case .tutti: corrispondeTipo = true
            case .lavoro: corrispondeTipo = evento is EventoLavoro
            case .sociale: corrispondeTipo = evento is EventoSociale
            }

            // Filtro per testo
            let corrispondeTesto = testoRicerca.isEmpty ||
                evento.titolo.localizedCaseInsensitiveContains(testoRicerca) ||
                evento.luogo.localizedCaseInsensitiveContains(testoRicerca)

            return corrispondeTipo && corrispondeTesto
        }
    }

    var body: some View {
        NavigationStack {
            VStack {
                // Selettore filtro
                Picker("Filtro", selection: $filtroSelezionato) {
                    ForEach(FiltroEvento.allCases, id: \.self) { filtro in
                        Text(filtro.rawValue.capitalized).tag(filtro)
                    }
                }
                .pickerStyle(.segmented)
                .padding(.horizontal)

                // Lista eventi
                List(eventiFiltrati, id: \.id) { evento in
                    RigaEventoView(evento: evento)
                }
            }
            .searchable(text: $testoRicerca, prompt: "Cerca eventi...")
            .navigationTitle("I Miei Eventi")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Menu("Aggiungi") {
                        Button("Evento Generico") {
                            aggiungiEvento()
                        }
                        Button("Evento di Lavoro") {
                            aggiungiEventoLavoro()
                        }
                        Button("Evento Sociale") {
                            aggiungiEventoSociale()
                        }
                    }
                }
            }
        }
    }

    func aggiungiEvento() {
        let evento = Evento(
            titolo: "Nuovo Evento",
            luogo: "Milano",
            dataInizio: .now,
            durata: 3600
        )
        context.insert(evento)
    }

    func aggiungiEventoLavoro() {
        let evento = EventoLavoro(
            titolo: "Meeting Trimestrale",
            luogo: "Roma - Sede Centrale",
            dataInizio: .now.addingTimeInterval(86400),
            durata: 7200,
            budget: 5000,
            codiceDipartimento: "SALES"
        )
        context.insert(evento)
    }

    func aggiungiEventoSociale() {
        let evento = EventoSociale(
            titolo: "Festa di Compleanno",
            luogo: "Napoli",
            dataInizio: .now.addingTimeInterval(172800),
            durata: 14400,
            categoria: .compleanno,
            numeroInvitati: 30
        )
        context.insert(evento)
    }
}

struct RigaEventoView: View {
    let evento: Evento

    var body: some View {
        HStack {
            // Icona basata sul tipo
            Image(systemName: iconaPerTipo)
                .foregroundStyle(colorePerTipo)
                .frame(width: 30)

            VStack(alignment: .leading, spacing: 4) {
                Text(evento.titolo)
                    .font(.headline)
                Text(evento.luogo)
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
                Text(evento.dataInizio, style: .date)
                    .font(.caption)

                // Dettagli specifici del sottotipo
                if let lavoro = evento as? EventoLavoro {
                    Text("Budget: \(lavoro.budget, format: .currency(code: "EUR"))")
                        .font(.caption)
                        .foregroundStyle(.blue)
                } else if let sociale = evento as? EventoSociale {
                    Text("\(sociale.categoria.rawValue.capitalized) - \(sociale.numeroInvitati) invitati")
                        .font(.caption)
                        .foregroundStyle(.green)
                }
            }
        }
    }

    var iconaPerTipo: String {
        if evento is EventoLavoro { return "briefcase.fill" }
        if evento is EventoSociale { return "party.popper.fill" }
        return "calendar"
    }

    var colorePerTipo: Color {
        if evento is EventoLavoro { return .blue }
        if evento is EventoSociale { return .green }
        return .primary
    }
}

Bug Fix Importanti in iOS 26

Oltre all'ereditarietà, iOS 26 porta con sé alcune correzioni di bug critiche per SwiftData. Alcune sono retrocompatibili fino a iOS 17, il che è una bella sorpresa:

  • Aggiornamento viste con @ModelActor: risolto un bug che impediva alle viste SwiftUI di aggiornarsi quando i dati venivano modificati sotto un @ModelActor. Se avete avuto problemi con interfacce che non si aggiornano, questo fix potrebbe essere la soluzione
  • Proprietà Codable nei predicati: le proprietà dei modelli conformi a Codable possono ora essere utilizzate nei predicati senza crash a runtime — un problema che ha fatto perdere ore a molti sviluppatori

Anche se non avete intenzione di adottare subito l'ereditarietà, questi fix da soli rendono l'aggiornamento a Xcode 26 una scelta sensata.

Domande Frequenti (FAQ)

SwiftData supporta l'ereditarietà multipla?

No. Come Swift stesso, SwiftData supporta solo l'ereditarietà singola. Ogni modello può avere al massimo una superclasse. Se avete bisogno di condividere comportamenti tra classi non correlate, i protocolli o la composizione tramite @Relationship sono le alternative migliori.

Posso usare l'ereditarietà di SwiftData su iOS 17 o iOS 18?

Purtroppo no. L'ereditarietà dei modelli è una funzionalità esclusiva di iOS 26. Se dovete supportare versioni precedenti, potete usare la composizione come alternativa e predisporre il codice per la migrazione futura con check @available.

Come funziona SwiftData con l'ereditarietà a livello di database?

SwiftData usa la strategia Single Table Inheritance: tutti i modelli della gerarchia finiscono nella stessa tabella SQLite. Le colonne specifiche delle sottoclassi contengono NULL per le istanze di altri tipi. Il vantaggio è che le query polimorfiche sono velocissime; lo svantaggio è che con molte sottoclassi la tabella può diventare piuttosto ingombrante.

Devo usare @Model su ogni sottoclasse o solo sulla classe base?

Il macro @Model va applicato sia alla classe base che a ciascuna sottoclasse. Inoltre, ogni sottoclasse deve essere annotata con @available(iOS 26, *) e registrata esplicitamente nel ModelContainer. Sembra ridondante, ma è necessario.

SwiftData è meglio di Core Data per i nuovi progetti nel 2026?

Per la maggior parte dei nuovi progetti che targetizzano iOS 26+, SwiftData è la scelta consigliata. Offre una sintassi più moderna, integrazione nativa con SwiftUI e ora supporta finalmente l'ereditarietà. Core Data resta preferibile per app con requisiti avanzati di migrazione, dataset molto complessi o necessità di supportare versioni di iOS molto precedenti.

Sull'Autore Editorial Team

Our team of expert writers and editors.