0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SwiftData × Claude Code で永続化層を設計する — @Model設計からマイグレーションまで実務で詰まらないための完全ガイド

0
Posted at

自己紹介

株式会社Good Labでエンジニアをしている コータロー です。
日々、Java・SQL・Gitなどの技術情報や、新人エンジニア向けの学習ノウハウ、
AI活用についての情報を発信しています。

Good Labについて気になった方は、コーポレートサイトもぜひご覧ください。
コーポレートサイト

はじめに

SwiftData は iOS 17 から登場した、Core Data の後継となる宣言的な永続化フレームワークです。
@Model マクロひとつで永続化対応のクラスが定義でき、SwiftUI との相性も抜群。とても便利です。

ですが——実務でアプリをリリースしてから気付くのが、「SwiftDataは初期設計をミスると後がしんどい」 ということ。

  • デフォルト値を設定し忘れて、追加プロパティのマイグレーションで詰む
  • リレーションの deleteRule を考えずに作って、親削除で子が孤立する
  • スキーマ変更時に「VersionedSchema を使ってない」ことに気付いて愕然とする

本記事では、Claude Code を使って SwiftData の永続化層を設計する際の「伝え方」と「設計の勘所」 を、実際のコード例とプロンプト例とセットで解説します。

  • 想定読者:SwiftUI + SwiftData を触り始めた iOS エンジニア
  • 環境:iOS 17.0+ / Swift 5.9+ / Xcode 15+(記事内のコードは iOS 18 SDK で検証)

補足:本記事は Claude Code(CLI)を使った開発を前提にしていますが、Cursor / Copilot Chat 等でも同様のプロンプトテクニックが使えます。


1. SwiftDataは「設計ミスが後から痛い」

SwiftData は手軽な反面、以下のような「あるある」が起きやすいです。

ありがちなミス 後から起きる問題
プロパティにデフォルト値を設定しない スキーマ変更時にマイグレーション失敗(既存データが nil 不可フィールドで読めない)
リレーションに deleteRule を指定しない 親削除で子が孤立、再取得時にクラッシュ
VersionedSchema を使わない バージョン2以降のスキーマ変更で詰む
@Attribute(.unique) の付け忘れ 重複データが入り込む
ModelContainer をView側で生成 テストやプレビューで状態が分散する

これらは 「最初に設計するときに気を付ければ防げる」 ものばかりです。
そして、Claude Code に伝えるべき情報も、まさにこの観点に沿っています。


2. Claude Code に @Model を設計させる前に準備すること

Claude Code に「SwiftDataのモデル作って」と雑に投げると、デフォルト値も deleteRule もないコードが出てくることがあります。
良い設計を引き出すには、こちらから「制約」を渡すこと が重要です。

2-1. CLAUDE.md にコーディング規約を書いておく

プロジェクトルートの CLAUDE.md に以下のような規約を書いておくと、Claude Code は毎回それに従って出力します。

# SwiftData コーディング規約

- @Model クラスには必ずデフォルト値を設定する(マイグレーション対応のため)
- 1対多リレーションには `@Relationship(deleteRule: .cascade)` を明示する
- スキーマ変更が予想されるアプリは `VersionedSchema` で実装する
- ViewModel は `@Observable @MainActor final class` で定義
- force unwrap(`!`)・`try!``as!` 禁止
- デバッグは `Logger`(os.log)使用、`print()` 禁止

2-2. ドメインを言語化する

「ToDoアプリ」「読書記録アプリ」など、何のデータを永続化するのか を最初に伝えます。
このとき、以下を整理しておくと精度が上がります。

  • エンティティ(例:Book, ReadingNote
  • 各エンティティの属性と型
  • リレーション(1対多 / 多対多)
  • 一意性制約(@Attribute(.unique)
  • 削除ルール(親削除時に子をどうするか)

3. 基本的な @Model の設計(デフォルト値・バリデーション)

まずは最小構成の @Model クラスです。すべてのプロパティにデフォルト値 を入れている点に注目してください。

import Foundation
import SwiftData

@Model
final class Book {
    /// 一意なID(ユーザーには見せない内部ID)
    @Attribute(.unique) var id: UUID = UUID()

    /// 書籍タイトル
    var title: String = ""

    /// 著者名
    var author: String = ""

    /// 読了フラグ
    var isFinished: Bool = false

    /// 作成日時
    var createdAt: Date = Date()

    /// 最終更新日時
    var updatedAt: Date = Date()

    init(
        id: UUID = UUID(),
        title: String = "",
        author: String = "",
        isFinished: Bool = false,
        createdAt: Date = Date(),
        updatedAt: Date = Date()
    ) {
        self.id = id
        self.title = title
        self.author = author
        self.isFinished = isFinished
        self.createdAt = createdAt
        self.updatedAt = updatedAt
    }
}

なぜデフォルト値が必須なのか?

SwiftData は 「軽量マイグレーション」 が可能ですが、新しいプロパティに デフォルト値が無い場合、自動マイグレーションが失敗 します。
Apple 公式の Preserving your app's model data across launches でも、互換性のためにデフォルト値の指定が推奨されています。

「あとから足したい」と思った時のために、最初からデフォルト値ありで書く癖をつけましょう。


4. リレーション設計(1対多・多対多)

4-1. 1対多リレーション

Book が複数の ReadingNote(読書メモ)を持つ例です。

import Foundation
import SwiftData

@Model
final class Book {
    @Attribute(.unique) var id: UUID = UUID()
    var title: String = ""
    var author: String = ""
    var createdAt: Date = Date()

    /// 親が削除されたら子(メモ)も連鎖削除
    @Relationship(deleteRule: .cascade, inverse: \ReadingNote.book)
    var notes: [ReadingNote] = []

    init(
        id: UUID = UUID(),
        title: String = "",
        author: String = "",
        createdAt: Date = Date(),
        notes: [ReadingNote] = []
    ) {
        self.id = id
        self.title = title
        self.author = author
        self.createdAt = createdAt
        self.notes = notes
    }
}

@Model
final class ReadingNote {
    @Attribute(.unique) var id: UUID = UUID()
    var content: String = ""
    var page: Int = 0
    var createdAt: Date = Date()

    /// 親 Book への逆参照(オプショナル必須)
    var book: Book?

    init(
        id: UUID = UUID(),
        content: String = "",
        page: Int = 0,
        createdAt: Date = Date(),
        book: Book? = nil
    ) {
        self.id = id
        self.content = content
        self.page = page
        self.createdAt = createdAt
        self.book = book
    }
}

ポイント

  • @Relationship(deleteRule: .cascade, inverse: \ReadingNote.book) を親側で明示
  • inverse: を指定しないと、SwiftData が双方向の関係を推論できず、データ不整合が起きやすい
  • 逆参照側(ReadingNote.book)はオプショナル にする(Book?

deleteRule の選択肢

ルール 動作
.cascade 親削除時に子も削除(メモなど従属データに使う)
.nullify 親削除時に子の参照を nil に(独立した関連に使う)
.deny 子が存在する場合は親削除を拒否
.noAction 何もしない(手動で整合性を取る)

4-2. 多対多リレーション

BookTag の多対多の例です。

import Foundation
import SwiftData

@Model
final class Book {
    @Attribute(.unique) var id: UUID = UUID()
    var title: String = ""

    @Relationship(inverse: \Tag.books)
    var tags: [Tag] = []

    init(id: UUID = UUID(), title: String = "", tags: [Tag] = []) {
        self.id = id
        self.title = title
        self.tags = tags
    }
}

@Model
final class Tag {
    @Attribute(.unique) var id: UUID = UUID()
    var name: String = ""

    var books: [Book] = []

    init(id: UUID = UUID(), name: String = "", books: [Book] = []) {
        self.id = id
        self.name = name
        self.books = books
    }
}

多対多の場合、deleteRule は通常 .nullify 相当 で動きます(タグだけ消えても本は残る)。


5. マイグレーション対応(VersionedSchema)

スキーマを変更する可能性が少しでもあるアプリは、最初から VersionedSchema を使う ことを強く推奨します。

5-1. バージョン1のスキーマ定義

import Foundation
import SwiftData

enum BookSchemaV1: VersionedSchema {
    static var versionIdentifier: Schema.Version = Schema.Version(1, 0, 0)

    static var models: [any PersistentModel.Type] {
        [Book.self]
    }

    @Model
    final class Book {
        @Attribute(.unique) var id: UUID = UUID()
        var title: String = ""
        var author: String = ""
        var createdAt: Date = Date()

        init(
            id: UUID = UUID(),
            title: String = "",
            author: String = "",
            createdAt: Date = Date()
        ) {
            self.id = id
            self.title = title
            self.author = author
            self.createdAt = createdAt
        }
    }
}

5-2. バージョン2のスキーマ(プロパティ追加)

isFinished プロパティを追加した V2 を定義します。

import Foundation
import SwiftData

enum BookSchemaV2: VersionedSchema {
    static var versionIdentifier: Schema.Version = Schema.Version(2, 0, 0)

    static var models: [any PersistentModel.Type] {
        [Book.self]
    }

    @Model
    final class Book {
        @Attribute(.unique) var id: UUID = UUID()
        var title: String = ""
        var author: String = ""
        var createdAt: Date = Date()

        /// V2で追加:読了フラグ(デフォルト false)
        var isFinished: Bool = false

        init(
            id: UUID = UUID(),
            title: String = "",
            author: String = "",
            createdAt: Date = Date(),
            isFinished: Bool = false
        ) {
            self.id = id
            self.title = title
            self.author = author
            self.createdAt = createdAt
            self.isFinished = isFinished
        }
    }
}

5-3. マイグレーションプランの定義

import Foundation
import SwiftData

enum BookMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [BookSchemaV1.self, BookSchemaV2.self]
    }

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

    /// V1 → V2:軽量マイグレーション(デフォルト値で自動補完)
    static let migrateV1toV2 = MigrationStage.lightweight(
        fromVersion: BookSchemaV1.self,
        toVersion: BookSchemaV2.self
    )
}

5-4. ModelContainer に適用

import SwiftUI
import SwiftData

@main
struct BookSparkApp: App {
    let container: ModelContainer

    init() {
        do {
            self.container = try ModelContainer(
                for: BookSchemaV2.Book.self,
                migrationPlan: BookMigrationPlan.self
            )
        } catch {
            // 起動時にコンテナ生成が失敗するのは致命的なのでクラッシュさせる
            // (ただしユーザーには事前に「データリセットが必要」と通知するUXが望ましい)
            fatalError("ModelContainer の初期化に失敗: \(error)")
        }
    }

    var body: some Scene {
        WindowGroup {
            BookListView()
        }
        .modelContainer(container)
    }
}

lightweight と custom の使い分け

マイグレーション種別 用途
.lightweight プロパティ追加・削除・リネームなど、データ変換を伴わない場合
.custom 既存データを別の型に変換する、複数フィールドから新フィールドを計算するなど

.custom の場合は willMigrate / didMigrate クロージャでデータ変換を書きます。


6. ViewModel との接続(@Query・ModelContext)

6-1. View 層での @Query 利用

import SwiftUI
import SwiftData

struct BookListView: View {
    /// SwiftData から自動取得(並び順・フィルタも指定可)
    @Query(sort: \Book.createdAt, order: .reverse) private var books: [Book]

    @Environment(\.modelContext) private var modelContext
    @State private var viewModel: BookListViewModel?

    var body: some View {
        NavigationStack {
            List {
                ForEach(books) { book in
                    VStack(alignment: .leading) {
                        Text(book.title)
                            .font(.headline)
                        Text(book.author)
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                    }
                }
                .onDelete { indexSet in
                    viewModel?.deleteBooks(at: indexSet, from: books)
                }
            }
            .navigationTitle("本棚")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button {
                        viewModel?.addSampleBook()
                    } label: {
                        Image(systemName: "plus")
                    }
                }
            }
            .onAppear {
                if viewModel == nil {
                    viewModel = BookListViewModel(modelContext: modelContext)
                }
            }
        }
    }
}

#Preview {
    BookListView()
        .modelContainer(for: Book.self, inMemory: true)
}

6-2. ViewModel の実装(@Observable @MainActor

import Foundation
import SwiftData
import os

@Observable
@MainActor
final class BookListViewModel {
    private let modelContext: ModelContext
    private let logger = Logger(subsystem: "com.happyboy1002.BookSpark", category: "BookListViewModel")

    init(modelContext: ModelContext) {
        self.modelContext = modelContext
    }

    /// サンプルの本を追加
    func addSampleBook() {
        let book = Book(
            title: "リーダブルコード",
            author: "Dustin Boswell"
        )
        modelContext.insert(book)
        save()
    }

    /// 指定されたインデックスの本を削除
    func deleteBooks(at offsets: IndexSet, from books: [Book]) {
        for index in offsets {
            guard books.indices.contains(index) else { continue }
            modelContext.delete(books[index])
        }
        save()
    }

    /// 変更を永続化(失敗時はログのみ。UI通知は呼び出し元で)
    private func save() {
        do {
            try modelContext.save()
        } catch {
            logger.error("ModelContext の保存に失敗: \(error.localizedDescription, privacy: .public)")
        }
    }
}

ポイント

  • @Observable @MainActor final class で ViewModel を定義(CLAUDE.md 規約)
  • force unwrap 禁止guard books.indices.contains(index) else { continue } で安全にチェック
  • print() 禁止Logger を使用
  • try modelContext.save() の失敗時 → クラッシュさせず Logger.error でログ出力

7. Claude Code への効果的な指示テンプレート

実際に使えるプロンプトテンプレートを 2 種類紹介します。

7-1. 新規 @Model 設計のテンプレート

SwiftData の @Model クラスを作ってください。

## エンティティ
- 名前:Book
- 用途:読書記録アプリの書籍データ

## プロパティ
- id: UUID(@Attribute(.unique)、デフォルト UUID())
- title: String(デフォルト "")
- author: String(デフォルト "")
- isFinished: Bool(デフォルト false)
- createdAt: Date(デフォルト Date())

## リレーション
- ReadingNote と 1対多
- 親(Book)削除時は子(ReadingNote)も連鎖削除(.cascade)
- inverse 必須

## 制約
- すべてのプロパティにデフォルト値必須(マイグレーション対応)
- @Relationship に必ず deleteRule と inverse を指定
- import 文を含む完全なコードで出力
- force unwrap・try!・as! 禁止

7-2. マイグレーション追加のテンプレート

既存の SwiftData スキーマに新しいプロパティを追加します。

## 現状
- BookSchemaV1 の Book に { id, title, author, createdAt } が存在

## 追加したいこと
- isFinished: Bool(デフォルト false)プロパティを追加

## 出力してほしいもの
1. BookSchemaV2 の VersionedSchema 定義
2. BookMigrationPlan(V1→V2 は lightweight)
3. ModelContainer 初期化コードの修正例

## 制約
- VersionedSchema / SchemaMigrationPlan を使用
- versionIdentifier を必ずインクリメント
- 既存データを破壊しない設計
- import 文を含む完全なコードで出力

7-3. プロンプトのコツ

  • 「制約」を箇条書きで明示 する(曖昧な指示は曖昧なコードを生む)
  • 「import 文を含む完全なコードで」 と必ず添える
  • CLAUDE.md にコーディング規約 を書いておくと、毎回プロンプトに書かなくて済む
  • deleteRule・inverse・デフォルト値 の3点セットは必ず指定

8. まとめ

SwiftData の永続化層は、初期設計が9割 です。
リリース後にデータ構造を変更すると、ユーザーのデータを壊さないために細心の注意が必要になります。

Claude Code を使う場合のポイントを再掲します。

カテゴリ やること
規約共有 CLAUDE.md に「デフォルト値必須」「deleteRule 必須」等を書く
@Model 設計 すべてのプロパティにデフォルト値を入れる
リレーション @Relationship(deleteRule:, inverse:) を必ず指定
マイグレーション 最初から VersionedSchema で書く
ViewModel @Observable @MainActor final class + Logger
プロンプト 「制約」と「完全なコード」を明示する

特に「最初から VersionedSchema を使う」ことは、開発初期は面倒に感じても、長期運用するアプリでは必ず元が取れる投資 です。

Claude Code に正しい設計を引き出してもらうためにも、こちら側が「何を譲れないか」をはっきりと伝えていきましょう。


参考


@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?