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
SwiftDataimportato 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
@Modelva 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.initper 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è unEvento→ ereditarietà appropriata - Un
Eventoha unLuogo→ 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
NULLper 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
propertiesToFetchper caricare solo le proprietà necessarie - Impostate
fetchLimitquando 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
Codablepossono 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.