Outboxアーキテクチャ:メッセージ取りこぼしゼロの設計 Outbox Architecture: Zero Message Loss Design

課題:送信即クリアなのにメッセージを失わない

Captio式シンプルメモでは、ユーザーが送信ボタンをタップした瞬間にUIがクリアされ、すぐに次のメモを書き始められる。これがCaptioスタイルの根幹をなすUXだ。

しかし現実のネットワークは300ms〜2秒のレイテンシがあり、失敗もするし、そもそもオフラインかもしれない。多くのアプリはネットワーク応答を待ってからUIをクリアする。安全だが、UXは悪い。逆に即座にクリアすると、メッセージを失うリスクがある。

Outboxパターンは、この相反する要件を同時に解決する。送信タップの瞬間にメッセージをローカルの暗号化ストレージ(Outbox)に永続保存し、UIを即座にクリアし、バックグラウンドでネットワーク送信を行う。送信に成功したらOutboxから削除し、失敗したら自動リトライする。これによりメッセージ取りこぼしゼロを実現しながら、体感レイテンシはゼロに近づく。

Outboxパターンの設計フロー

以下が、送信ボタンをタップしてからメッセージが確実に送信されるまでの全フローだ。

  1. 送信タップ → まずOutboxにAES-GCM暗号化して永続保存
    ユーザーが送信ボタンをタップした瞬間、メッセージはAES-GCM 256bitで暗号化され、デバイスのローカルストレージに永続保存される。このステップが完了するまで、UIクリアは行わない。つまり、Outbox保存が「取りこぼしゼロ」の最初の防波堤になる。
  2. UIを即座にクリア(ユーザーは次のメモを書き始められる)
    Outbox保存が成功した時点で、テキストビューをクリアし、送信アニメーションを再生する。ユーザーから見れば「送信した」と同義。ネットワーク応答を一切待っていない。Send-to-Resetの目標は150ms(アニメーション込みで0.25秒)。
  3. バックグラウンドでRelay API経由でメール送信
    UIクリアと並行して、SendManagerがCloudflare Workers上のRelay APIへHTTPリクエストを送信する。この処理はメインスレッドをブロックしない完全非同期。
  4. 成功したらOutboxから削除
    Relay APIから200レスポンスを受信したら、該当メッセージをOutboxから安全に削除する。これでメッセージのライフサイクルが完了する。
  5. 失敗したら指数バックオフで自動リトライ
    ネットワークエラーやサーバーエラーの場合、指数バックオフ(1秒→2秒→4秒→8秒…)で自動リトライする。BGTaskSchedulerを使い、アプリがバックグラウンドでも確実にリトライを実行する。
  6. オフラインならNWPathMonitorで回線復帰を検知して自動再送
    送信時点でオフラインの場合、Network.frameworkのNWPathMonitorが回線状態を監視。Wi-Fiやセルラーが復帰した瞬間に自動的にOutbox内の全未送信メッセージを再送する。

SendManagerのコード

SendManagerのsend()関数は、Outboxパターンのエントリーポイントだ。まずOutboxに保存し、ネットワーク接続がなければキューイングし、接続があれば即座に送信を実行する。

func send(message: String, completion: @escaping (SendResult) -> Void) {
    // まずOutboxに保存(取りこぼしゼロを保証)
    let outboxMessage: OutboxMessage
    do {
        outboxMessage = try OutboxManager.shared.add(body: message)
    } catch {
        DispatchQueue.main.async { completion(.failure(error)) }
        return
    }

    // ネットワーク接続がない場合はキューに入れたことを通知
    guard NetworkMonitor.shared.isConnected else {
        BackgroundTaskManager.shared.scheduleRetryTask()
        DispatchQueue.main.async { completion(.queued) }
        return
    }

    // 送信処理を実行
    performSend(message: message, outboxId: outboxMessage.id, ...)
}

重要なポイントは、OutboxManager.shared.add(body:)が同期的に永続ストレージに書き込むこと。この呼び出しが成功した時点で、メッセージの安全性が保証される。ネットワーク送信がどのような結果になっても、メッセージはOutboxに残り続ける。

AES-GCM暗号化の実装

Outboxに保存されるメッセージは、ユーザーのプライベートなメモだ。ローカルストレージとはいえ、平文で保存するわけにはいかない。Apple CryptoKitのAES-GCMを使い、外部ライブラリ依存ゼロで暗号化を実装した。

  • Apple CryptoKit — iOS 13以降で利用可能。外部依存なし。
  • 256-bit対称鍵 — Keychainに保存され、デバイスに紐づく。
  • 認証付き暗号化 — AES-GCMはデータの機密性と完全性を同時に保証。改ざんされたデータは復号時に検知される。
enum OutboxEncryption {
    private static var encryptionKey: SymmetricKey {
        if let existingKey = KeychainHelper.load(key: "outbox_encryption_key") {
            return SymmetricKey(data: existingKey)
        }
        let newKey = SymmetricKey(size: .bits256)
        let keyData = newKey.withUnsafeBytes { Data($0) }
        KeychainHelper.save(key: "outbox_encryption_key", data: keyData)
        return newKey
    }

    static func encrypt(_ plainText: String) throws -> Data {
        guard let data = plainText.data(using: .utf8) else {
            throw OutboxError.encryptionFailed
        }
        let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
        guard let combined = sealedBox.combined else {
            throw OutboxError.encryptionFailed
        }
        return combined
    }
}

鍵はアプリ初回起動時に自動生成され、Keychainに安全に保存される。Keychainはデバイスのセキュアエンクレーブに紐づいており、他のアプリやバックアップからはアクセスできない。

外部ライブラリ依存ゼロの意味

Outboxアーキテクチャで使用しているフレームワークはすべてAppleネイティブだ。

  • CryptoKit — AES-GCM暗号化。サードパーティの暗号化ライブラリは不要。
  • Network.framework — NWPathMonitorによる回線状態監視。Reachabilityライブラリは不要。
  • BackgroundTasks — BGTaskSchedulerによるバックグラウンドリトライ。

外部依存がゼロであることの意味は大きい。

  • 破壊的変更のリスクがない — OSアップデート時にサードパーティライブラリが壊れることがない。
  • セキュリティ — ユーザーのメモデータに触れるコードパスに、第三者のコードが一切含まれない。
  • アプリバイナリの軽量化 — 不要なフレームワークを含まないため、ダウンロードサイズが最小限。
  • ビルドの高速化 — 依存関係の解決が不要で、CIが速い。

UIクリアとネットワーク送信の分離

Outboxパターンの真の価値は、UIレイヤーとネットワークレイヤーの完全な分離にある。送信ボタンのタップハンドラを見てみよう。

@objc private func sendButtonTapped() {
    PerformanceLogger.shared.beginSendToReset()
    let message = textView.text ?? ""
    isSending = true
    sendBarButton.isEnabled = false

    // Animation → UI clear (does NOT wait for network!)
    performSendAnimation {
        self.clearTextView()
        PerformanceLogger.shared.endSendToReset()
    }

    // Send is async. Does not block UI.
    SendManager.shared.send(message: message) { [weak self] result in
        self?.isSending = false
        self?.updateSendButtonState()
        switch result {
        case .success:
            PerformanceLogger.shared.logEvent(name: "SendSuccess")
        case .failure(let error):
            self?.handleSendError(error)
        case .queued:
            self?.showQueuedFeedback()
        }
    }
}

performSendAnimationSendManager.shared.sendは完全に独立して実行される。アニメーションは0.25秒で完了し、テキストビューはクリアされる。ネットワーク送信はバックグラウンドで非同期に進行し、UIを一切ブロックしない。

Send-to-Resetの目標は150ms。アニメーション込みでも0.25秒。ユーザーは送信ボタンをタップした直後に、次のメモを書き始められる。これがCaptioスタイルのUXだ。

よくある質問

Q. オフラインで送信したメモはどうなりますか?

Outboxに暗号化保存され、回線復帰時にNWPathMonitorが検知して自動再送します。ユーザーが何もしなくても、メッセージは確実に届きます。

Q. アプリを閉じてもメモは失われませんか?

Outboxは永続ストレージです。アプリ終了・iPhone再起動しても失われません。次回アプリ起動時、またはバックグラウンドタスクにより自動的にリトライされます。

Q. なぜAES-GCMを選んだのですか?

Apple CryptoKitがネイティブサポートしており、認証付き暗号化(Authenticated Encryption)により改ざん検知も可能です。外部ライブラリ依存がゼロになる点も大きな利点です。

Q. 送信失敗時のリトライ回数は?

指数バックオフで自動リトライします。BGTaskSchedulerでバックグラウンドタスクとしてスケジュールされ、確実に送信完了するまで続けます。ユーザーのメモを絶対に取りこぼさない設計です。

関連記事

The Challenge: Instant UI Clear Without Losing Messages

In Simple Memo (Captio-style), the UI clears the instant you tap send, letting you immediately start writing your next memo. This is the core of the Captio-style UX.

But real-world networks have 300ms-2s latency. They fail. They go offline entirely. Most apps wait for a network response before clearing the UI. Safe, but terrible UX. Clear immediately? You risk losing the message.

The Outbox pattern solves both simultaneously. The moment you tap send, the message is persisted to encrypted local storage (the Outbox), the UI clears instantly, and background network delivery begins. On success, the message is removed from the Outbox. On failure, automatic retry kicks in. The result: zero message loss with near-zero perceived latency.

Outbox Pattern Design Flow

Here is the complete flow from tapping send to guaranteed message delivery.

  1. Tap Send → Persist to Outbox with AES-GCM Encryption
    The instant the user taps send, the message is encrypted with AES-GCM 256-bit and persisted to local storage. The UI does not clear until this step completes. Outbox persistence is the first line of defense for zero message loss.
  2. Clear UI Immediately (User Can Start Writing Next Memo)
    Once Outbox persistence succeeds, the text view clears and the send animation plays. From the user's perspective, the message has been "sent." No network response has been waited for. Send-to-Reset target: 150ms (0.25s with animation).
  3. Background Send via Relay API
    In parallel with the UI clear, SendManager fires an HTTP request to the Relay API on Cloudflare Workers. This is fully asynchronous and never blocks the main thread.
  4. On Success: Remove from Outbox
    When the Relay API returns a 200 response, the message is safely deleted from the Outbox. The message lifecycle is complete.
  5. On Failure: Automatic Retry with Exponential Backoff
    For network or server errors, automatic retry with exponential backoff (1s → 2s → 4s → 8s...) kicks in. BGTaskScheduler ensures retries happen even when the app is in the background.
  6. If Offline: NWPathMonitor Detects Connectivity and Auto-Resends
    If the device is offline at send time, Network.framework's NWPathMonitor watches for connectivity changes. The moment Wi-Fi or cellular returns, all unsent messages in the Outbox are automatically resent.

SendManager Code

The SendManager's send() function is the entry point for the Outbox pattern. It persists to the Outbox first, queues if offline, and sends immediately if connected.

func send(message: String, completion: @escaping (SendResult) -> Void) {
    // Persist to Outbox first (guarantees zero message loss)
    let outboxMessage: OutboxMessage
    do {
        outboxMessage = try OutboxManager.shared.add(body: message)
    } catch {
        DispatchQueue.main.async { completion(.failure(error)) }
        return
    }

    // If no network, notify that message has been queued
    guard NetworkMonitor.shared.isConnected else {
        BackgroundTaskManager.shared.scheduleRetryTask()
        DispatchQueue.main.async { completion(.queued) }
        return
    }

    // Execute the send
    performSend(message: message, outboxId: outboxMessage.id, ...)
}

The critical point: OutboxManager.shared.add(body:) writes synchronously to persistent storage. Once this call succeeds, message safety is guaranteed. Regardless of what happens with the network send, the message remains in the Outbox until explicitly deleted after confirmed delivery.

AES-GCM Encryption Implementation

Messages stored in the Outbox are private user memos. Even on local storage, plaintext is unacceptable. We implemented encryption using Apple CryptoKit's AES-GCM with zero external dependencies.

  • Apple CryptoKit — Available from iOS 13+. No external dependencies.
  • 256-bit Symmetric Key — Stored in Keychain, bound to the device.
  • Authenticated Encryption — AES-GCM guarantees both confidentiality and integrity. Tampered data is detected on decryption.
enum OutboxEncryption {
    private static var encryptionKey: SymmetricKey {
        if let existingKey = KeychainHelper.load(key: "outbox_encryption_key") {
            return SymmetricKey(data: existingKey)
        }
        let newKey = SymmetricKey(size: .bits256)
        let keyData = newKey.withUnsafeBytes { Data($0) }
        KeychainHelper.save(key: "outbox_encryption_key", data: keyData)
        return newKey
    }

    static func encrypt(_ plainText: String) throws -> Data {
        guard let data = plainText.data(using: .utf8) else {
            throw OutboxError.encryptionFailed
        }
        let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
        guard let combined = sealedBox.combined else {
            throw OutboxError.encryptionFailed
        }
        return combined
    }
}

The key is auto-generated on first launch and securely stored in Keychain. Keychain is tied to the device's Secure Enclave, making it inaccessible to other apps or backups.

Zero External Dependencies

Every framework used in the Outbox architecture is Apple-native.

  • CryptoKit — AES-GCM encryption. No third-party crypto libraries needed.
  • Network.framework — NWPathMonitor for connectivity monitoring. No Reachability library needed.
  • BackgroundTasks — BGTaskScheduler for background retry.

Zero external dependencies has significant implications:

  • No Breaking Changes — Third-party libraries won't break on OS updates.
  • Security — No third-party code touches the user's memo data. The entire code path handling user content is first-party.
  • Small Binary — No unnecessary frameworks means minimal download size.
  • Faster Builds — No dependency resolution overhead. CI stays fast.

Decoupling UI Clear from Network Send

The true value of the Outbox pattern is the complete separation of the UI layer and the network layer. Let's look at the send button tap handler.

@objc private func sendButtonTapped() {
    PerformanceLogger.shared.beginSendToReset()
    let message = textView.text ?? ""
    isSending = true
    sendBarButton.isEnabled = false

    // Animation → UI clear (does NOT wait for network!)
    performSendAnimation {
        self.clearTextView()
        PerformanceLogger.shared.endSendToReset()
    }

    // Send is async. Does not block UI.
    SendManager.shared.send(message: message) { [weak self] result in
        self?.isSending = false
        self?.updateSendButtonState()
        switch result {
        case .success:
            PerformanceLogger.shared.logEvent(name: "SendSuccess")
        case .failure(let error):
            self?.handleSendError(error)
        case .queued:
            self?.showQueuedFeedback()
        }
    }
}

performSendAnimation and SendManager.shared.send execute completely independently. The animation completes in 0.25 seconds and the text view clears. The network send proceeds asynchronously in the background, never blocking the UI.

Send-to-Reset target: 150ms. Even with animation, 0.25 seconds. The user can start writing their next memo immediately after tapping send. This is the Captio-style UX.

FAQ

Q. What happens to memos sent offline?

They are encrypted and stored in the Outbox. When connectivity returns, NWPathMonitor detects it and automatically resends all pending messages. No user action required.

Q. Are memos lost if I close the app?

No. The Outbox uses persistent storage. Messages survive app termination and iPhone restarts. They are automatically retried on next app launch or via background tasks.

Q. Why AES-GCM?

Apple CryptoKit provides native AES-GCM support, offering authenticated encryption that detects data tampering during decryption. It requires zero external dependencies, keeping the security code path entirely first-party.

Q. How many retries on failure?

Retries use exponential backoff and are scheduled as background tasks via BGTaskScheduler. They continue until the message is successfully delivered. The system is designed to never drop a user's memo.

Related Articles

参考文献 References