Outboxアーキテクチャ:メッセージ取りこぼしゼロの設計
2026年2月13日
課題:送信即クリアなのにメッセージを失わない
Obsidian連携シンプルメモでは、ユーザーが送信ボタンをタップした瞬間にUIがクリアされ、すぐに次のメモを書き始められる。これがCaptioスタイルの根幹をなすUXだ。
しかし現実のネットワークは300ms〜2秒のレイテンシがあり、失敗もするし、そもそもオフラインかもしれない。多くのアプリはネットワーク応答を待ってからUIをクリアする。安全だが、UXは悪い。逆に即座にクリアすると、メッセージを失うリスクがある。
Outboxパターンは、この相反する要件を同時に解決する。送信タップの瞬間にメッセージをローカルの暗号化ストレージ(Outbox)に永続保存し、UIを即座にクリアし、バックグラウンドでネットワーク送信を行う。送信に成功したらOutboxから削除し、失敗したら自動リトライする。これによりメッセージ取りこぼしゼロを実現しながら、体感レイテンシはゼロに近づく。
Outboxパターンの設計フロー
以下が、送信ボタンをタップしてからメッセージが確実に送信されるまでの全フローだ。
- 送信タップ → まずOutboxにAES-GCM暗号化して永続保存
ユーザーが送信ボタンをタップした瞬間、メッセージはAES-GCM 256bitで暗号化され、デバイスのローカルストレージに永続保存される。このステップが完了するまで、UIクリアは行わない。つまり、Outbox保存が「取りこぼしゼロ」の最初の防波堤になる。 - UIを即座にクリア(ユーザーは次のメモを書き始められる)
Outbox保存が成功した時点で、テキストビューをクリアし、送信アニメーションを再生する。ユーザーから見れば「送信した」と同義。ネットワーク応答を一切待っていない。Send-to-Resetの目標はワンタップ(アニメーション込みで0.25秒)。 - バックグラウンドでRelay API経由でメール送信
UIクリアと並行して、SendManagerがCloudflare Workers上のRelay APIへHTTPリクエストを送信する。この処理はメインスレッドをブロックしない完全非同期。 - 成功したらOutboxから削除
Relay APIから200レスポンスを受信したら、該当メッセージをOutboxから安全に削除する。これでメッセージのライフサイクルが完了する。 - 失敗したら指数バックオフで自動リトライ
ネットワークエラーやサーバーエラーの場合、指数バックオフ(1秒→2秒→4秒→8秒…)で自動リトライする。BGTaskSchedulerを使い、アプリがバックグラウンドでも確実にリトライを実行する。 - オフラインなら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 を使い、暗号化に関しては外部 SDK を一切介さない実装にしている。
- Apple CryptoKit — iOS 13以降で利用可能。暗号化処理に外部 SDK を介さない(Apple 純正のみ)。
- 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はデバイスのセキュアエンクレーブに紐づいており、他のアプリやバックアップからはアクセスできない。
データパスを Apple 純正のみで構成する意味
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()
}
}
}
performSendAnimationとSendManager.shared.sendは完全に独立して実行される。アニメーションは0.25秒で完了し、テキストビューはクリアされる。ネットワーク送信はバックグラウンドで非同期に進行し、UIを一切ブロックしない。
Send-to-Resetの目標はワンタップ。アニメーション込みでも0.25秒。ユーザーは送信ボタンをタップした直後に、次のメモを書き始められる。これがCaptioスタイルのUXだ。
よくある質問
Q. オフラインで送信したメモはどうなりますか?
Outboxに暗号化保存され、回線復帰時にNWPathMonitorが検知して自動再送します。ユーザーが何もしなくても、メッセージは確実に届きます。
Q. アプリを閉じてもメモは失われませんか?
Outboxは永続ストレージです。アプリ終了・iPhone再起動しても失われません。次回アプリ起動時、またはバックグラウンドタスクにより自動的にリトライされます。
Q. なぜAES-GCMを選んだのですか?
Apple CryptoKitがネイティブサポートしており、認証付き暗号化(Authenticated Encryption)により改ざん検知も可能です。外部ライブラリ依存がゼロになる点も大きな利点です。
Q. 送信失敗時のリトライ回数は?
指数バックオフで自動リトライします。BGTaskSchedulerでバックグラウンドタスクとしてスケジュールされ、確実に送信完了するまで続けます。ユーザーのメモを絶対に取りこぼさない設計です。
関連記事
参考文献
- Apple Developer — AES.GCM (CryptoKit) — Outboxデータの暗号化に使用したAES-GCM認証付き暗号化の公式リファレンス
- Apple Developer — NWPathMonitor (Network Framework) — オフライン検知とネットワーク状態監視に使用
- Apple Developer — BGTaskScheduler — バックグラウンドでの再送処理スケジューリングに使用
- Wikipedia — Transactional Outbox Pattern — メッセージの信頼性保証を実現するトランザクショナルOutboxパターンの解説