← Todos os Posts

O verdadeiro custo do SwiftData é a disciplina de schema

Gênero: shipped-code. O post documenta as decisões de schema do SwiftData entre Get Bananas, Return e Reps: três apps em que o schema ou sobreviveu a uma migração limpa ou pagou um imposto por não planejar a migração. O ShoppingItem do Get Bananas é o exemplo canônico. O schema original não incluía um timestamp lastModified; adicioná-lo depois exigiu um formato específico de migração porque os dados existentes já estavam em disco, e o campo foi tornado opcional especificamente para corrigir um crash de migração que ocorreu quando ele foi adicionado pela primeira vez como não-opcional.1

A API do SwiftData são duas macros. @Model em uma classe a torna um tipo persistente. @Attribute(.unique) em uma propriedade dá a ela uma restrição de unicidade. O framework esconde o gerenciamento da stack do Core Data, a dança dos value-transformers e o boilerplate do NSManagedObjectContext. O que o framework não esconde é a migração de schema; ele apenas torna a migração declarativa em vez de imperativa. O custo de não prestar atenção às migrações é o bug que apaga os dados de um usuário em uma atualização de rotina.

A tese: SwiftData é barato para começar e caro para migrar de forma desleixada. A disciplina é nomeação, opcionalidade e VersionedSchema desde o primeiro dia, não no dia em que você percebe que deveria ter feito.

TL;DR

  • A macro @Model transforma uma classe em um tipo persistente do SwiftData. O framework gera o schema a partir das declarações de propriedade em tempo de compilação.
  • Adicionar uma nova propriedade opcional é uma migração no-op: a migração leve do SwiftData lida com isso. Adicionar uma propriedade não-opcional a um schema existente exige um VersionedSchema mais um MigrationPlan que diga ao framework como popular o novo campo para as linhas existentes.
  • O custo de pular o VersionedSchema desde o primeiro dia é que qualquer mudança não trivial de schema na v2 corre o risco de derrubar o banco de dados de um usuário, porque o caminho leve é conservador e desiste quando não consegue inferir a migração.
  • @Attribute(.unique) é a ferramenta certa para chaves naturais (um UUID que você gerou, um ID externo que você importou). @Relationship é a ferramenta certa para referências pai/filho. Ambas são macros que geram o encanamento correto do Core Data por baixo dos panos.2

O que o @Model realmente faz

Um tipo do SwiftData é uma classe Swift com a macro @Model aplicada. O ShoppingItem do Get Bananas é o formato canônico:

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()
    }
}

Três detalhes sobre esse formato que a API esconde.

@Model não exige uma declaração separada de schema do persistent-store. O SwiftData lê a definição da classe em tempo de compilação e sintetiza o schema. As propriedades da classe se tornam os atributos do modelo; seus tipos Swift se tornam os tipos das colunas. Não há arquivo .xcdatamodeld para manter (embora o NSManagedObjectModel subjacente do Core Data ainda exista e seja o que sustenta o schema em tempo de execução).2

@Attribute(.unique) é uma restrição em uma única coluna, não uma declaração de PRIMARY KEY. A identidade persistente do SwiftData é o PersistentIdentifier, gerado automaticamente por linha. A declaração @Attribute(.unique) diz ao framework “esta coluna armazena no máximo uma linha por valor.” Quando você insere um modelo com um valor .unique que já existe, o SwiftData realiza um upsert: a linha existente é atualizada em vez de rejeitada. A semântica importa para o código de produto: .unique não é uma validação no nível da UI que impede duplicatas de serem submetidas; é uma garantia de armazenamento de no-máximo-um que mescla silenciosamente. O padrão id: UUID acima é o recomendado para sincronização entre processos (onde você quer um identificador estável que sobreviva ao desaparecimento do PersistentIdentifier em processo), e o comportamento de upsert é exatamente o que você quer quando o mesmo UUID chega de dois caminhos de sync.

Classes @Model são tipos por referência, não tipos por valor. Mutar uma propriedade em uma instância ShoppingItem dispara o rastreamento de mudanças do SwiftData; o framework registra a mudança e a persiste no próximo save do contexto. A integração com SwiftUI por meio de @Query re-renderiza qualquer view que observe o predicado correspondente. O padrão é semelhante ao @Observable (abordado em What SwiftUI Is Made Of), com persistência sobreposta.

Campos opcionais são a migração barata

O campo lastModified: Date? em ShoppingItem é opcional, e a opcionalidade é estrutural. O campo foi adicionado depois que a v1 foi publicada para suportar sincronização entre dispositivos e resolução de conflitos; linhas existentes em dispositivos de usuários não tinham nenhum valor lastModified. Um campo opcional sem valor padrão permite que a migração leve do SwiftData lide com a adição sem escrever nenhum código de migração: linhas existentes recebem nil; novas linhas recebem o que quer que o init defina.3

O caminho de migração leve é o caminho educado do framework. O SwiftData inspeciona o novo schema e o persistent store, infere a menor mudança compatível e a aplica. A migração é automática; o usuário não vê nada; o app inicia normalmente sobre os dados existentes. Os casos que o caminho leve trata de forma limpa:

  • Adicionar uma propriedade opcional
  • Remover uma propriedade (os dados são descartados; leituras existentes não veem mais a coluna)
  • Renomear um atributo que o framework consegue casar via dica (usando @Attribute(originalName: ...))
  • Renomear uma classe @Model que o framework consegue casar (usando @Model.originalName ou uma dica)

Os casos em que o caminho leve desiste:

  • Adicionar uma propriedade não-opcional sem valor padrão a um schema existente (linhas existentes não têm valor para popular)
  • Mudar o tipo de uma propriedade (por exemplo, IntString)
  • Dividir um modelo em dois modelos, ou mesclar dois em um
  • Qualquer coisa que exija lógica customizada para migrar

Quando o caminho leve desiste, o comportamento seguro é falhar a migração. O comportamento inseguro seria derrubar o banco de dados e começar do zero; o framework é conservador e se recusa a fazer isso silenciosamente. O usuário vê o app crashar na inicialização com um erro de migração; o desenvolvedor vê um stack trace apontando para a incompatibilidade de schema; ninguém perde dados, mas todo mundo perde a confiança.

O custo de pular o VersionedSchema desde o primeiro dia aparece na fronteira v2 → v3, quando você adiciona o terceiro recurso cuja mudança de schema excede o que o caminho leve consegue lidar.

VersionedSchema e MigrationPlan: a disciplina do primeiro dia

VersionedSchema declara uma versão específica do schema do modelo. MigrationPlan declara como migrar de uma versão para a próxima.4 O formato:

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)
    ]
}

As próprias classes de modelo passam para o namespace do versioned-schema:

extension SchemaV1 {
    @Model
    final class ShoppingItemV1 { /* v1 fields */ }
}

extension SchemaV2 {
    @Model
    final class ShoppingItemV2 { /* v2 fields, including lastModified */ }
}

O ModelContainer é construído com o plano de migração:

let container = try ModelContainer(
    for: ShoppingItemV2.self,
    migrationPlan: AppMigrationPlan.self,
    configurations: ModelConfiguration("ShoppingList")
)

O plano de migração dá ao framework um grafo tipado de como o schema evolui. Quando o app que envia a v2 inicia contra um banco v1, o framework percorre o plano de migração, aplica os estágios nomeados e leva o banco para v2. Quando você publica v3, adiciona SchemaV3.self a schemas e um novo MigrationStage entre v2 e v3.

A disciplina é publicar VersionedSchema na v1, mesmo quando há apenas uma versão. O custo de fazer isso é um arquivo extra e uma declaração enum extra. O custo de não fazer é que a primeira mudança de schema não trivial da v2 exige envolver retroativamente a v1 em um VersionedSchema, o que é factível, mas exige cuidado para casar o formato exato da v1 para que o framework consiga identificar os dados existentes como SchemaV1. O futuro-você trabalhando na v2 vai pagar o imposto; o presente-você pode pagar uma vez e esquecer.

MigrationStage customizado para os casos difíceis

Migrações leves cobrem a maioria das mudanças aditivas. Mudanças de tipo, splits, merges e populações condicionais precisam de um 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()
        }
    )
]

As duas closures disparam antes e depois de o framework aplicar a migração estrutural. willMigrate roda contra o schema v1; didMigrate roda contra o schema v2. O corpo da closure é código SwiftData normal (fetch descriptors, saves de model context, as mesmas APIs usadas no app em execução), operando contra um contexto transitório de migração.

O padrão que sobrevive em produção é manter willMigrate vazio e colocar toda a lógica de população em didMigrate. Ler dados v1 dentro de willMigrate é permitido, mas o schema v2 ainda não existe da perspectiva do framework, então qualquer computação tem que ser staged em um armazenamento transitório que a closure didMigrate consiga ler. A regra mais simples: migrações estruturais são tarefa do framework; popular campos exclusivos da v2 em linhas existentes é tarefa do didMigrate.

Quando @Attribute e @Relationship justificam seus nomes

Duas macros fazem a maior parte do trabalho de decoração de schema em classes @Model.

@Attribute decora uma única propriedade com uma restrição ou dica:

  • @Attribute(.unique) impõe unicidade, como em ShoppingItem.id
  • @Attribute(.externalStorage) armazena blobs grandes de Data fora do banco (dados de imagem, buffers de áudio)
  • @Attribute(originalName: "old_field_name") casa uma propriedade com uma coluna renomeada durante a migração
  • @Attribute(.transformable(by: ...)) aplica um ValueTransformer a um tipo não-Codable

A disciplina certa: use .unique para campos que genuinamente devem ser únicos (um UUID que você gerou, um ID externo), use .externalStorage para qualquer blob acima de alguns KB, use originalName quando uma renomeação na v2 de uma propriedade caso contrário perderia os dados da v1.

@Relationship decora uma propriedade que aponta para outra classe @Model ou uma coleção delas:

@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?
}

O deleteRule: .cascade significa que apagar a List pai apaga todas as linhas filhas ShoppingItem. O parâmetro inverse: diz ao framework qual propriedade no filho aponta de volta para o pai; o framework o usa para manutenção bidirecional previsível. O SwiftData às vezes consegue inferir o inverso automaticamente, e inverse: nil é suportado para relacionamentos explicitamente unidirecionais, mas o padrão seguro é declarar inverse: sempre que a inferência seria ambígua.5

A disciplina certa: declare relacionamentos com deleteRule explícito (o padrão é .nullify, que raramente é o que você quer) e declare inverse: sempre que o relacionamento for bidirecional (em vez de depender da inferência do framework). Os padrões implícitos geralmente estão errados; a forma explícita é um parâmetro extra e um bug salvo para sempre.

O que eu construiria diferente

Três padrões que os apps no cluster ou enviam ou queriam ter enviado.

Envie VersionedSchema desde a v1. Toda classe @Model em produção deveria viver dentro de um VersionedSchema desde o primeiro dia. O custo é um enum envolvente por versão de schema. O benefício é que a primeira mudança não trivial da v2 é uma adição de uma linha em MigrationPlan.schemas em vez de um refactor retroativo de dois dias.

Faça todo timestamp ser opcional. Campos como lastModified, createdAt e updatedAt que existem para sincronização entre dispositivos ou resolução de conflitos devem ser opcionais na v1 se o produto v1 não precisar deles. A opcionalidade mantém barata a migração para v2 (quando você precisar deles). Preenchê-los em linhas existentes durante didMigrate é um loop; torná-los não-opcionais desde a v1 é uma restrição que pode quebrar o backfill nos dados do usuário.

Use UUIDs como a chave natural, não o PersistentIdentifier. O PersistentIdentifier do SwiftData é em processo. Sincronização entre dispositivos, integração com MCP (abordada em Two Agent Ecosystems, One Shopping List) e qualquer referência fora do processo precisam de um identificador estável. Um UUID com @Attribute(.unique) é o formato certo; o PersistentIdentifier em processo é o formato errado para qualquer coisa que cruze o limite de um processo.

Quando @Model é a resposta errada

Três casos em que o SwiftData não é a ferramenta certa:

Estado chave/valor de registro único. Configurações do app, idioma selecionado pelo usuário, timestamp do último sync. Use UserDefaults ou NSUbiquitousKeyValueStore (abordado em Five Apple Platforms, Three Shared Files). O overhead do SwiftData para uma única linha é cerimônia desperdiçada; armazenamentos chave-valor são o substrato certo.

Dados autoritativos no servidor sem escritas offline. Uma lista buscada de uma API REST e exibida somente leitura. O SwiftData é exagero se a fonte da verdade é o servidor e o cache local é apenas um cache. Um snapshot Codable simples em Documents/ mais um array em cache de memória é suficiente; o imposto de migração do SwiftData não vale a pena pagar se os dados não sobrevivem a um hard reset.

Coordenação multi-processo. O SwiftData opera dentro de um processo. Um servidor MCP rodando fora do app iOS não consegue ler ou escrever no container SwiftData do app. Estado entre processos precisa de outro formato: um arquivo JSON no iCloud Drive, um container compartilhado de App Group ou uma camada de sincronização explícita que ligue os processos. (Get Bananas pareia SwiftData com JSON do iCloud Drive exatamente por essa razão.)6

Os dados são blobs grandes que mudam raramente. Um arquivo de áudio de 10MB, um conjunto de imagens de 50MB. Use @Attribute(.externalStorage) se os blobs estão dentro de linhas SwiftData; caso contrário, use o sistema de arquivos diretamente com metadados no SwiftData apontando para URLs de arquivo.

O que o padrão significa para apps publicados em iOS 26+

Três conclusões.

  1. As macros são a parte fácil. As migrações são o custo. @Model e @Attribute são declarações de duas linhas que escondem muito do encanamento do Core Data. Disciplina de migração é o que você realmente paga ao longo da vida do app; projete a v1 com a v2 em mente.

  2. VersionedSchema desde o primeiro dia é não-negociável para apps em produção. O enum envolvente é um arquivo extra. O custo retroativo de adicioná-lo depois é muito maior.

  3. Campos opcionais e relacionamentos explícitos são o seguro barato. Timestamps opcionais para metadados de sincronização, deleteRule e inverse: explícitos em relacionamentos. Ambos são declarações minúsculas que compram muita flexibilidade na v2.

O cluster Apple Ecosystem completo: App Intents tipados para Apple Intelligence; servidores MCP para agentes entre LLM; a questão de roteamento entre eles; Foundation Models para LLM on-device e o protocolo Tool; Live Activities para a máquina de estados da Lock Screen no iOS; o contrato do runtime do watchOS no Apple Watch; SwiftUI internals para o substrato do framework; o modelo mental espacial do RealityKit para cenas visionOS; padrões Liquid Glass para a camada visual; publicação multi-plataforma para alcance entre dispositivos. O hub está na Apple Ecosystem Series. Para um contexto mais amplo de iOS-com-agentes-de-IA, veja o iOS Agent Development guide.

FAQ

Qual é a diferença entre @Model e o NSManagedObject do Core Data?

@Model é uma macro Swift que gera o encanamento de NSManagedObject por baixo dos panos. O SwiftData usa Core Data como seu backing store, então o modelo em runtime é o mesmo; a diferença é a superfície. @Model remove o arquivo .xcdatamodeld, a cerimônia dos value-transformers e o gerenciamento de ciclo de vida do NSManagedObjectContext. Você obtém o mesmo persistent store com uma API no formato Swift.

Eu preciso de VersionedSchema se nunca planejo mudar o schema?

Se o seu app pode publicar uma v2, sim. Se for uma demo única, não. O custo do VersionedSchema desde a v1 é uma declaração enum extra. O custo de adicioná-lo retroativamente na v2 é casar exatamente o formato do schema v1 para que o framework reconheça os dados existentes, o que é factível mas propenso a erros. A maioria dos apps em produção eventualmente precisará de uma mudança de schema; orce isso na v1.

Quando devo usar @Attribute(.unique)?

Quando o campo é uma chave natural para a linha: um UUID que você gerou, um ID externo que você importou, um slug que você atribuiu. O SwiftData trata .unique como upsert: se você inserir um modelo cujo valor .unique já existe, a linha existente é atualizada em vez de uma nova linha ser anexada. Essa semântica é o que torna seguros os caminhos de sync no estilo upsert (o mesmo UUID vindo de dois dispositivos); também é por isso que .unique é a ferramenta errada em campos de nome de exibição como title, porque dois usuários digitando o mesmo título mesclariam silenciosamente suas linhas em vez de produzir dois registros distintos.

Como lido com um campo não-opcional adicionado a um schema existente?

Use um MigrationStage.custom com uma closure didMigrate que popula o campo nas linhas existentes. Ou, mais fácil: declare o campo como opcional na nova versão do schema e preencha-o preguiçosamente no acesso. Opcionalidade é a migração mais barata; adições não-opcionais precisam de lógica de população explícita.

O que é PersistentIdentifier versus o meu próprio UUID?

PersistentIdentifier é o ID de linha em-processo do SwiftData; ele é gerado automaticamente e sobrevive ao tempo de vida do processo em execução. Seu próprio UUID com @Attribute(.unique) é um identificador estável entre processos e entre dispositivos. Use PersistentIdentifier para referências em-processo dentro do app. Use um UUID para qualquer coisa que cruze o limite de um processo (sincronização entre dispositivos, integrações externas, ferramentas MCP, chamadas de rede).

Referências


  1. Get Bananas do autor, um app de lista de compras em SwiftUI que pareia SwiftData com sync via JSON do iCloud Drive e um servidor MCP. O modelo ShoppingItem evoluiu ao longo do ciclo inicial de desenvolvimento; o campo lastModified: Date? foi adicionado depois do schema inicial (commit 268a00d em 01/12/2025, “Make lastModified optional to fix migration crash”) porque torná-lo não-opcional quebrou a migração quando linhas existentes não tinham valor para popular. 

  2. Apple Developer, “SwiftData” e “Adding and editing persistent data in your app”. A macro @Model, a superfície de restrição @Attribute e a relação com o NSManagedObjectModel do Core Data. 

  3. Apple Developer, “Preserving your app’s model data across launches” e “Adopting SwiftData for a Core Data app”. Semântica de migração leve e o que dispara o framework a desistir. 

  4. Apple Developer, “VersionedSchema” e “SchemaMigrationPlan”. Declarações de schema versionado, definições de estágios de migração e o construtor do ModelContainer que aceita um plano de migração. 

  5. Apple Developer, “Defining data relationships with enumerations and model classes” e “Schema.Relationship”. A macro @Relationship, opções de deleteRule (.cascade, .nullify, .deny, .noAction) e o papel do parâmetro inverse: na manutenção de relacionamentos bidirecionais. 

  6. Análise do autor em Two Agent Ecosystems, One Shopping List, 29 de abril de 2026, e Five Apple Platforms, Three Shared Files. Os padrões de sincronização entre processos e entre dispositivos do Get Bananas + Return que complementam (e às vezes substituem) o SwiftData dentro de um workflow multi-processo. 

Artigos relacionados

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 leitura

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 leitura

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 leitura