“朝だけ動くSNS” 開発記録 #3 〜CRUD処理を実装する 〜

Swift

「朝の実(Asanomi)」では、投稿データを CloudKit 上に保存し、タイムラインとして取得します。そのため、アプリから CloudKit を安全かつ分かりやすく扱うための「データアクセス層(Repository)」 を作成します。

この記事では、CloudKit を使った CRUD(Create / Read / Update / Delete)処理をどのように設計・実装したかを整理します。

今回作成したファイルは以下の6つです。

Models/
└ Post.swift                     (投稿データのモデル)

Repository/
├ CloudKitRepositoryError.swift  (CloudKitまわりのエラー定義)
├ PostRecordKey.swift            (CloudKitフィールド名の一元管理)
├ PostRecordMapper.swift         (Post ⇄ CKRecord の変換)
├ PostRepository.swift           (Repositoryのインターフェース)
└ CloudKitPostRepository.swift   (CloudKit実装)

この 6 ファイルにより、ViewModel や View からは PostRepository のメソッドを呼ぶだけで、CloudKit の細かい API を意識せずに投稿データを扱えるようになります。
また、後々 CloudKit を Firebase に置き換える際にも修正を最小限に抑えられるメリットがあります。


1. Post.swift(投稿データのモデル)

まずは、アプリ内で扱う投稿データの「型」を定義します。CloudKit に依存しない純粋なモデルとして Post 構造体を用意します。

//
//  Post.swift
//  Asanomi
//
import Foundation
/// 1件の投稿を表すモデル。
/// ViewModel や View は CloudKit の CKRecord ではなく、この Post 型を扱います。
struct Post: Identifiable, Hashable {
    // MARK: - Properties
    /// 投稿を一意に識別する ID(UUID 文字列)。
    let id: String
    /// 投稿者を識別する ID(将来的に User モデルと紐づける想定)。
    let authorId: String
    /// 投稿本文。
    let text: String
    /// 投稿日時。タイムラインの並び順や検索条件に使用します。
    let createdAt: Date
    /// いいね数(cheer 数)。
    var cheerCount: Int
    /// 論理削除フラグ。true の場合は通常のタイムライン表示では非表示扱いとします。
    var isDeleted: Bool
    /// 投稿時の端末タイムゾーン識別子(例: "Asia/Tokyo")。監査・集計用のメタ情報です。
    let clientTimeZone: String
}

ポイントは以下の通りです。

  • Identifiable に準拠しているため、SwiftUI の List にそのまま渡せる
  • Hashable に準拠しているため、Post を高速に比較でき、重複排除・差分更新などの処理が容易  
  • CloudKit 固有の型(CKRecord など)は一切登場しない(インフラ非依存)
  • 投稿に必要な情報(本文・作成日時・いいね数・論理削除フラグなど)を1つにまとめている

このモデルを中心に、Repository や ViewModel が連携していきます。


2. CloudKitRepositoryError.swift(CloudKit まわりのエラー定義)

CloudKit のエラーはそのまま扱うと種類が多く分かりにくいため、Repository 層では独自のエラー型にまとめています。

//
//  CloudKitRepositoryError.swift
//  Asanomi
//
import Foundation
/// CloudKit を利用した永続化処理で発生し得るエラー種別。
/// ViewModel や UseCase からは、この型をもとにエラーの内容を判定します。
enum CloudKitRepositoryError: Error {
    /// CKRecord → Post 変換時に必須フィールド欠如や型不整合が発生した場合。
    case decodingError
    /// 指定した ID に対応するレコードが存在しない場合。
    case recordNotFound
    /// CloudKit SDK から返却された生のエラーをラップしたもの。
    /// 詳細な原因が必要な場合は、associated value から参照します。
    case underlying(Error)
}

これにより、上位層(ViewModel など)は例えば次のように扱えます。

do {
    let posts = try await repository.fetchLatest(limit: 20)
} catch let error as CloudKitRepositoryError {
    switch error {
    case .decodingError:
        // データ不整合向けのエラー表示
    case .recordNotFound:
        // 「投稿が見つかりませんでした」など
    case .underlying(let original):
        // original をログに出すなど
    }
}

このように、「CloudKit 由来のさまざまなエラー」を一度ラップすることで、扱いやすくしています。


3. PostRecordKey.swift(CloudKit フィールド名の一元管理)

CloudKit のレコードには、フィールド名を文字列で指定して値を格納します。しかし、文字列の直書きはタイプミスを生みやすく、保守性も低くなります。そこで、全フィールド名を一箇所に集約します。

//
//  PostRecordKey.swift
//  Asanomi
//
import Foundation
/// CloudKit 上の Post レコードに対応する Record Type 名およびフィールドキーを定義した enum。
/// 文字列のタイポによるバグを防ぎ、スキーマ変更時の修正箇所を集約する役割を持ちます。
enum PostRecordKey {
    // MARK: - Record Type
    /// CloudKit の Record Type 名(RDB で言うテーブル名に相当)。
    static let type = "Post"
    // MARK: - Fields
    /// 投稿ID(UUID 文字列)を保持するフィールド。
    static let id = "id"
    /// 投稿者識別子を保持するフィールド。
    static let authorId = "authorId"
    /// 投稿本文を保持するフィールド。
    static let text = "text"
    /// 投稿日時を保持するフィールド。タイムラインの並び替えに使用します。
    static let createdAt = "createdAt"
    /// いいね数(cheer 数)を保持するフィールド。
    static let cheerCount = "cheerCount"
    /// 論理削除フラグ(Int: 0/1)を保持するフィールド。
    static let isDeleted = "isDeleted"
    /// 投稿時のクライアントタイムゾーン(例: "Asia/Tokyo")を保持するフィールド。
    static let clientTimeZone = "clientTimeZone"
}

以降、CloudKit にアクセスするコードでは "text" のような生文字列は書かず、必ず PostRecordKey.text を経由するようにします。


4. PostRecordMapper.swift(Post ⇄ CKRecord の変換)

CloudKit は CKRecord という「フィールド名 → 値」のオブジェクトでデータを扱います。一方、アプリ内は Post 構造体で扱いたい。このギャップを埋めるのが Mapper です。

//
//  PostRecordMapper.swift
//  Asanomi
//
import Foundation
import CloudKit
extension Post {
// MARK: - CKRecord から Post への変換
    /// CloudKit の CKRecord から Post 構造体を生成します。
    ///
    /// 必須フィールドが欠けている場合や型が一致しない場合は `CloudKitRepositoryError.decodingError`
    /// をスローします。
    ///
    /// - Parameter record: CloudKit から取得した CKRecord。
    /// - Throws: `CloudKitRepositoryError.decodingError`
    /// - Returns: 変換された Post モデル。
    init(record: CKRecord) throws {
        guard
            let id = record[PostRecordKey.id] as? String,
            let authorId = record[PostRecordKey.authorId] as? String,
            let text = record[PostRecordKey.text] as? String,
            let createdAt = record[PostRecordKey.createdAt] as? Date,
            let cheerCount = record[PostRecordKey.cheerCount] as? Int,
            let isDeletedInt = record[PostRecordKey.isDeleted] as? Int,
            let clientTimeZone = record[PostRecordKey.clientTimeZone] as? String
        else {
            throw CloudKitRepositoryError.decodingError
        }
        self.id = id
        self.authorId = authorId
        self.text = text
        self.createdAt = createdAt
        self.cheerCount = cheerCount
        self.isDeleted = (isDeletedInt != 0)
        self.clientTimeZone = clientTimeZone
    }
// MARK: - Post から CKRecord への変換
    /// Post 構造体から CloudKit の CKRecord を生成または更新します。
    ///
    /// - Parameter existing:
    /// 既存レコードを更新したい場合に渡します。新規作成時は `nil` を指定します。
    /// - Returns: CloudKit に保存可能な CKRecord。
    func toRecord(existing: CKRecord? = nil) -> CKRecord {
        let record: CKRecord
        if let existing {
            // 更新処理の場合:既存の CKRecord をベースに上書きします。
            record = existing
        } else {
            // 新規作成の場合:recordName に id を使って CKRecord.ID を生成します。
            let recordID = CKRecord.ID(recordName: id)
            record = CKRecord(recordType: PostRecordKey.type, recordID: recordID)
        }
        // 各フィールドを CloudKit 用の値としてセット。
        record[PostRecordKey.id] = id as CKRecordValue
        record[PostRecordKey.authorId] = authorId as CKRecordValue
        record[PostRecordKey.text] = text as CKRecordValue
        record[PostRecordKey.createdAt] = createdAt as CKRecordValue
        record[PostRecordKey.cheerCount] = cheerCount as CKRecordValue
        record[PostRecordKey.isDeleted] = (isDeleted ? 1 : 0) as CKRecordValue
        record[PostRecordKey.clientTimeZone] = clientTimeZone as CKRecordValue
        return record
    }
}

4.1. CKRecord → Post への変換

guard let を使い、必須フィールドが欠けている/型が違う場合は decodingError として早期にエラーとしています。

4.2. Post → CKRecord への変換

新規作成の場合は CKRecord.ID(recordName: id) でレコードIDを作り、更新場合は既存レコードを引数で受け取って上書きするようにしています。
CloudKit 側に合わせて BoolInt(0/1) として保存している点も、このレイヤーで吸収しています。


5. PostRepository.swift(Repository のインターフェース)

次に、「投稿データに対してどのような操作が必要か」を整理したインターフェース(プロトコル)です。ViewModel はこのプロトコルだけを見て実装します。

//
//  PostRepository.swift
//  Asanomi
//
import Foundation
/// 投稿データに対する永続化操作を表現するプロトコル。
/// ViewModel や UseCase は、このプロトコルにのみ依存し、
/// 具体的なインフラ実装(CloudKit など)には依存しないように設計します。
protocol PostRepository {
    // MARK: - Read
    /// 最新の投稿を createdAt の降順で取得します。
    ///
    /// - Parameter limit: 取得する最大件数。
    /// - Returns: createdAt の新しい順に並んだ Post の配列。
    func fetchLatest(limit: Int) async throws -> [Post]
    // MARK: - Create
    /// 新しい投稿を作成し、永続化します。
    ///
    /// - Parameters:
    ///   - text: 投稿本文。
    ///   - authorId: 投稿者を識別する ID。
    ///   - timeZoneIdentifier: 投稿端末のタイムゾーン識別子(例: "Asia/Tokyo")。
    /// - Returns: 永続化が完了した Post。
    func create(
        text: String,
        authorId: String,
        timeZoneIdentifier: String
    ) async throws -> Post
    // MARK: - Update
    /// 指定した投稿の cheerCount を +1 します。
    ///
    /// - Parameter postID: 対象となる投稿の ID(Post.id)。
    /// - Returns: 更新後の Post。
    func incrementCheer(for postID: String) async throws -> Post
    // MARK: - Delete (Soft)
    /// 指定した投稿を論理削除します(isDeleted = true として扱う)。
    ///
    /// - Parameter postID: 対象となる投稿の ID(Post.id)。
    func softDelete(postID: String) async throws
}

このプロトコルにより、CloudKit 版・Firebase 版などバックエンドの実装を自由に差し替えられるようになります。また、テスト時には MockPostRepository を用意して注入することも可能です。


6. CloudKitPostRepository.swift(CloudKit を使った実装)

最後に、実際に CloudKit を呼び出すクラスです。ここに CloudKit 固有の実装を閉じ込め、他の層に漏らさないようにします。

//
//  CloudKitPostRepository.swift
//  Asanomi
//
import Foundation
import CloudKit
/// CloudKit をバックエンドに用いた PostRepository の実装。
/// publicCloudDatabase を利用し、全ユーザーで共有されるタイムラインを構成します。
final class CloudKitPostRepository: PostRepository {
    // MARK: - Properties
    /// 利用する CloudKit データベース。
    /// 朝の実では「みんなで共有するタイムライン」のため publicCloudDatabase を使用します。
    private let database: CKDatabase
    // MARK: - Initializer
    /// CloudKitPostRepository の初期化。
    ///
    /// - Parameter container:
    ///   通常は `.default()` を指定します。UnitTest 等で別コンテナを使いたい場合は差し替えます。
    init(container: CKContainer = .default()) {
        self.database = container.publicCloudDatabase
    }
    // MARK: - Create(新規投稿の作成)
    /// 新しい投稿を CloudKit 上に作成し、永続化します。
    ///
    /// - Parameters:
    ///   - text: 投稿本文。
    ///   - authorId: 投稿者を識別する ID。
    ///   - timeZoneIdentifier: 投稿端末のタイムゾーン識別子。
    /// - Throws: CloudKitRepositoryError.underlying など。
    /// - Returns: 保存済みの Post モデル。
    func create(
        text: String,
        authorId: String,
        timeZoneIdentifier: String
    ) async throws -> Post {
        let now = Date()
        let id = UUID().uuidString
        // アプリ側の Post モデルを組み立てる。
        let post = Post(
            id: id,
            authorId: authorId,
            text: text,
            createdAt: now,
            cheerCount: 0,
            isDeleted: false,
            clientTimeZone: timeZoneIdentifier
        )
        // モデル → CKRecord に変換。
        let record = post.toRecord()
        do {
            // CloudKit に保存。
            let savedRecord = try await database.save(record)
            // 保存後の CKRecord から改めて Post を生成。
            return try Post(record: savedRecord)
        } catch {
            throw CloudKitRepositoryError.underlying(error)
        }
    }
    // MARK: - Read(タイムライン取得)
    /// createdAt の降順(新しい投稿が先頭)で、最新の投稿一覧を取得します。
    ///
    /// - Parameter limit: 取得する最大件数。
    /// - Throws: CloudKitRepositoryError.underlying など。
    /// - Returns: createdAt 降順にソートされた Post の配列。
    func fetchLatest(limit: Int) async throws -> [Post] {
        // isDeleted == 0 のレコードのみを対象とする Predicate。
        let predicate = NSPredicate(format: "\(PostRecordKey.isDeleted) == 0")
        // Post レコードを対象としたクエリを作成。
        let query = CKQuery(recordType: PostRecordKey.type, predicate: predicate)
        // createdAt の降順(新しい順)でソート。
        query.sortDescriptors = [
            NSSortDescriptor(key: PostRecordKey.createdAt, ascending: false)
        ]
        // CKQueryOperation はコールバックベースのため、
        // async/await で使いやすいように continuation でラップします。
        return try await withCheckedThrowingContinuation { continuation in
            var posts: [Post] = []
            let operation = CKQueryOperation(query: query)
            operation.resultsLimit = limit
            // 1件レコードが見つかるごとに呼ばれるコールバック。
            operation.recordMatchedBlock = { _, result in
                switch result {
                case .success(let record):
                    // レコード -> Post 変換に成功したものだけ配列に追加。
                    if let post = try? Post(record: record) {
                        posts.append(post)
                    }
                case .failure(let error):
                    // 個別レコード取得エラー。ここではログにとどめ、全体は queryResultBlock で扱います。
                    print("recordMatched error: \(error)")
                }
            }
            // クエリ全体の終了時に呼ばれるコールバック。
            operation.queryResultBlock = { result in
                switch result {
                case .success:
                    continuation.resume(returning: posts)
                case .failure(let error):
                    continuation.resume(throwing: CloudKitRepositoryError.underlying(error))
                }
            }
            // 実際に Operation をデータベースに投げる。
            self.database.add(operation)
        }
    }
    // MARK: - Update(cheerCount を +1)
    /// 指定した投稿の cheerCount を +1 し、更新結果を返します。
    ///
    /// - Parameter postID: 対象となる投稿の ID(Post.id)。
    /// - Throws: CloudKitRepositoryError.underlying など。
    /// - Returns: 更新後の Post。
    func incrementCheer(for postID: String) async throws -> Post {
        let recordID = CKRecord.ID(recordName: postID)
        do {
            // 対象レコードを取得。
            let record = try await database.record(for: recordID)
            // 現在の cheerCount を取り出し、+1 して上書き。
            let current = (record[PostRecordKey.cheerCount] as? Int) ?? 0
            record[PostRecordKey.cheerCount] = (current + 1) as CKRecordValue
            // 保存して確定。
            let savedRecord = try await database.save(record)
            // 更新後の状態を Post として返却。
            return try Post(record: savedRecord)
        } catch {
            throw CloudKitRepositoryError.underlying(error)
        }
    }
    // MARK: - Delete(論理削除)
    /// 指定した投稿を論理削除します(isDeleted = true としてマークします)。
    ///
    /// - Parameter postID: 対象となる投稿の ID(Post.id)。
    /// - Throws: CloudKitRepositoryError.underlying など。
    func softDelete(postID: String) async throws {
        let recordID = CKRecord.ID(recordName: postID)
        do {
            // 対象レコードを取得。
            let record = try await database.record(for: recordID)
            // isDeleted を 1 にセット(Bool ではなく Int として保存)。
            record[PostRecordKey.isDeleted] = 1 as CKRecordValue
            // 保存するが、戻り値は特に利用しないため破棄します。
            _ = try await database.save(record)
        } catch {
            throw CloudKitRepositoryError.underlying(error)
        }
    }
}

このクラスの外側(ViewModel など)からは、CloudKit の存在を意識する必要はありません。PostRepository のメソッドを呼び出すだけで、投稿の作成・取得・更新・削除が行えます。


まとめ:6つのファイルで CloudKit を「安全にラップ」する

本記事では、以下 6 つのファイルを通して、CloudKit を使った投稿データの CRUD 処理をどのように実装したかを整理しました。

  • Post.swift:アプリ内で扱う投稿モデル
  • CloudKitRepositoryError.swift:CloudKit 用エラー定義
  • PostRecordKey.swift:CloudKit フィールド名の一元管理
  • PostRecordMapper.swift:Post ⇄ CKRecord の変換ロジック
  • PostRepository.swift:Repository インターフェース
  • CloudKitPostRepository.swift:CloudKit を用いた実装

これらを分割しておくことで、今後もし Firebase や別バックエンドに移行したい場合でも、PostRepository に準拠した別実装を用意するだけで差し替えが可能になります。

次のステップとしては、この Repository を利用する ViewModel(タイムライン表示・投稿画面など)を実装し、実際の画面とつなげていきます。