「会員登録なしで使えて、データは端末の中だけにある」。アプリのレビューを読むと、この性質はそれだけで選ばれる理由になっています。そして開発者にとっても、サーバーを持たないことは運用が楽になるように見えます。障害対応も、DB のバックアップも、月額の請求も無いからです。
ただし実際に作ってみると、サーバーがやっていた仕事は消えずに、全部端末に移ってきます。スキーマの移行は利用者の iPhone の上で走り、失敗しても誰も手で直せません。機種変更の引き継ぎは自分で用意するしかなく、購入の検証を任せるサーバーもありません。
この記事では、私が一人で開発し App Store で配信している iOS アプリ 御朱印帳アプリ『あとから御朱印帳』 の実コードを使って、Expo でサーバーもログインも持たないアプリを本番で動かすための設計を解説します。アプリの企画から設計判断までの経緯は /labs の制作記 にまとめています。
扱うのは次の4つです。
- expo-sqlite のスキーマ移行を、端末の上で安全に流す
- 機種変更に備えた、暗号化バックアップの形式と実装
- RevenueCat を使わない StoreKit 2 の買い切り課金と、多重の課金ゲート
- 上の3つを、シミュレータ無しで Jest に通すための層構造
本記事の基準バージョン:Expo SDK 57(
expo~57.0.20)、React Native 0.86.3、expo-sqlite 57.0.2(同梱の SQLite は 3.50.3)、expo-iap 5.5.0。バージョンはアプリのapps/mobile/package.jsonと、expo-sqlite/ios/sqlite3.hのSQLITE_VERSIONで確認しました。Swift でネイティブモジュールを書く方法そのものは Expo × Swift ネイティブモジュール実装ガイド で扱っているので、ここでは「何をネイティブに寄せ、どう JS 側でテストするか」に絞ります。
0. 結論:サーバーの仕事は、端末のどこへ移るか
最初に全体像を表にしておきます。左の列は、サーバーがあれば当たり前にサーバーが引き受けていた仕事です。
| サーバーがあればそこでやる仕事 | サーバーなしで、端末のどこが引き受けるか | このアプリでの実装 |
|---|---|---|
| スキーマの移行 | 起動時に、画面を描く前に流す | PRAGMA user_version と版ごとのトランザクション |
| 同時書き込みの整合 | 1本の接続で、トランザクションを直列にする | 自前の FIFO キュー |
| バックアップと機種変更 | iOS 標準のバックアップと、暗号化した書き出しファイル | CryptoKit による独自の .goshuin 形式 |
| 購入の検証と権利の管理 | StoreKit 2 を唯一の根拠にする | expo-iap と EntitlementService |
| 有料機能の制御 | UI とサービス層の両方で判定する | 純粋関数と GateError |
| 本番に近い環境での結合テスト | ネイティブをポートの裏に隠し、Node で代わりを動かす | sql.js・Node の fs・node:crypto |
このアプリの設計判断は docs/ADR-*.md に40本の ADR(Architecture Decision Record)として残っています(ls docs/ADR-*.md | wc -l で40)。以下の説明で「ADR-0003」のように書いたものは、その番号の記録です。
この記事の実装を、案件として承ります
Expo / React Native アプリの iOS ネイティブ機能(Swift)を、設計から審査対応まで承ります
1. 層構造:Expo をポートの裏に隠し、合成ルートを1か所にする
先に層構造を説明します。後の3つの話が、どれもこの構造を前提にしているからです。
依存の向きは ADR-0001 に図で書かれています。
ui(画面・フック) → services(ユースケース) → domain(純粋関数・型)
↓ 依存はインターフェースだけ
ports(インターフェース) ← adapters(Expo の実装) ← src/composition/container.ts(合成ルート)
data(リポジトリ、SqlDriver)は services から使う。SqlDriver の実装だけが adapter(expo-sqlite / sql.js)
いわゆる ports & adapters(ヘキサゴナルアーキテクチャ)です。ポイントは3つあります。
1-1. ポートは「Jest で動かないもの」にだけ切る
ポートを増やしすぎると、インターフェースの保守がそれだけで仕事になります。このアプリの方針は「Jest がネイティブに実行できない能力にだけポートを切る」です(src/ports/index.ts)。合成ルートで差し込んでいるポートは19個で、SQLite 以外ではファイル、暗号、画像ピッカー、StoreKit、共有シート、OCR、位置情報、時計、ID 生成などです(src/composition/container.ts の ports: { … } の項目数)。
SQLite は、ポートを最小にしています。リポジトリが本物の SQL を書けるように、SQL を受け取るドライバだけを抽象化しました。
// apps/mobile/src/data/sqlDriver.ts(抜粋)
export interface SqlDriver {
execAsync(sql: string): Promise<void>;
runAsync(sql: string, params?: SqlParams): Promise<SqlRunResult>;
getAllAsync<T extends object>(sql: string, params?: SqlParams): Promise<T[]>;
getFirstAsync<T extends object>(sql: string, params?: SqlParams): Promise<T | null>;
/** 呼び出しは接続ごとに直列化される。タスクの中から呼ばないこと */
withTransactionAsync<T>(task: () => Promise<T>): Promise<T>;
closeAsync(): Promise<void>;
}
本番では expo-sqlite、テストでは sql.js(SQLite を WebAssembly にしたもの)がこれを実装します。リポジトリの SQL はモックされず、テストでも本物の SQLite で実行されます。
1-2. 依存の向きは ESLint で機械的に守る
「services から Expo を import しない」という約束は、書いておくだけでは守られません。このアプリは ESLint の no-restricted-imports で禁止しています。
// eslint.config.mjs(抜粋)
{
files: ['apps/mobile/src/{domain,data,services}/**/*.ts'],
rules: {
'no-restricted-imports': ['error', {
patterns: [
{ group: ['react', 'react-native', 'react-native/*'], message: 'Business logic must stay UI-free.' },
{ group: ['expo', 'expo-*', 'expo/*'], message: 'Use a port in src/ports instead of Expo modules.' },
{
// npm パッケージだけを禁じても、@/adapters を直接 import すれば依存逆転を迂回できる
group: ['@/adapters', '@/adapters/*', '@/composition', '@/composition/*'],
message: 'Depend on a port in src/ports; adapters are wired only in src/composition/container.ts.',
},
],
}],
},
}
3つ目のパターンが大事です。expo-* を禁じるだけでは、@/adapters/expoIap を import すれば同じことができてしまいます。
1-3. 合成ルートを src/app に置かない
アダプタを組み立てる場所は src/composition/container.ts の1か所だけです。ここで一つ、Expo 特有の落とし穴があります。src/app というディレクトリがあると、Expo Router はそれを画面のルートとして採用します(app/ より優先)。合成ルートを src/app/container.ts に置いたところ、テストや配線のファイルまで画面としてバンドルされました。このアプリでは ADR-0001 に「src/app/ は作らない」と書いて再発を防いでいます。
合成ルートの中身は、起動の順番そのものです。
// apps/mobile/src/composition/container.ts(抜粋)
export async function buildAppServices(): Promise<AppServices> {
const logger = createRingBufferLogger({ echoToConsole: __DEV__ });
const driver = await createExpoSqlDriver();
const migrated = await migrate(driver); // 画面を描く前にスキーマを移行する
logger.log('info', 'database ready', migrated);
const services = assembleServices({
config: readConfig(),
driver,
repos: createSqliteRepositories(driver),
ports: {
fs: createExpoFileSystem(/* App Group */),
crypto: createGoshuinCrypto(), // Swift / CryptoKit
iap: createIap(storeKitDouble()), // StoreKit 2(撮影用ビルドだけ代役)
// …ほか16個
},
});
await services.maintenance.sweepScratch(); // 強制終了で残った一時ファイルを消す
// …
return services;
}
テスト側の src/test/makeTestServices.ts は、同じ assembleServices に sql.js・一時ディレクトリの Node の fs・node:crypto の参照実装・スクリプトで操作できるフェイクを渡します。本番とテストで違うのはアダプタだけで、配線のコードは同じです。これが後の節のテストを成り立たせています。
2. expo-sqlite のスキーマ移行を、端末の上で安全に流す
サーバーの DB なら、移行が失敗してもエンジニアが直せます。利用者の iPhone で失敗した移行は、誰も直せません。しかも配信済みのアプリは、古い版の DB を持った端末がいつまでも残ります。
2-1. 最小の形:user_version と版ごとのトランザクション
SQLite には、DB ファイルのヘッダに整数を1つ持たせる PRAGMA user_version があります。SQLite 自身は使わない値なので、アプリがスキーマの版として使えます。Expo の公式ドキュメントも、この形の移行の例を載せています。
このアプリの移行関数は30行ほどです。
// apps/mobile/src/data/database.ts(抜粋)
export async function migrate(driver: SqlDriver) {
await driver.execAsync('PRAGMA journal_mode = WAL');
await driver.execAsync('PRAGMA foreign_keys = ON');
const from = await readSchemaVersion(driver); // PRAGMA user_version(新しい DB は 0)
let current = from;
for (const migration of MIGRATIONS) {
if (migration.version <= current) continue;
await driver.withTransactionAsync(async () => {
for (const statement of migration.statements) {
await driver.execAsync(statement);
}
await driver.execAsync(`PRAGMA user_version = ${String(migration.version)}`);
});
current = migration.version;
}
return { from, to: current };
}
版ごとに1つのトランザクションにしているので、途中で失敗してもその版は丸ごと無かったことになり、user_version も上がりません。次の起動で、同じ版からやり直します。SQLite では DDL(CREATE TABLE など)もトランザクションで巻き戻るので、この形が成り立ちます。
移行の定義は、版番号と SQL の配列だけです。今は6版あります(apps/mobile/src/data/migrations.ts の MIGRATIONS)。
// apps/mobile/src/data/migrations.ts(抜粋)
export const MIGRATIONS: readonly Migration[] = [
{ version: 1, statements: [/* CREATE TABLE IF NOT EXISTS … */] },
{
version: 2,
statements: [
// migrate は外部キーを有効にしてから流す。SQLite は REFERENCES 付きで足す列に
// NULL の既定値を要求する。既存の行もそう読めてほしいので、都合が良い
`ALTER TABLE import_queue ADD COLUMN temple_id TEXT REFERENCES temples(id) ON DELETE SET NULL`,
],
},
// … version 3〜6
];
ファイル冒頭のコメントに書いてある規則は3つです。追記だけ、配信済みの版は書き換えない、各版はトランザクションの中で流す。
2-2. ALTER TABLE の落とし穴3つ
実際に版を重ねると、SQLite の ALTER TABLE の制約にぶつかります。このアプリの移行コメントに残っているものを3つ紹介します。
① ADD COLUMN には IF NOT EXISTS が無い。 版1の CREATE TABLE IF NOT EXISTS は二度流しても安全ですが、ALTER TABLE … ADD COLUMN は二度目にエラーになります。二重実行を防いでいるのは user_version の判定だけで、その判定と版の刻印が同じトランザクションにあることが前提です。
② 外部キー付きの列を足すと、既定値は NULL でなければならない。 SQLite の公式ドキュメントには「外部キー制約が有効なとき、REFERENCES 句を持つ列を足すなら既定値は NULL でなければならない」とあります。版2はこれに合わせて、既存の行を NULL(未設定)として読ませています。
③ 表の作り直し(テーブルの再構築)が事実上できない。 SQLite は列の CHECK 制約を後から変えられないので、制約を変えたいときは表を作り直すしかありません。ところが PRAGMA foreign_keys はトランザクションの中では何もしない(SQLite 公式の記述)ので、移行のトランザクションの中で外部キーを切ることはできません。外部キーが有効なまま親の表を消すと、ON DELETE CASCADE で子の表の行まで消えます。
このアプリは③を踏まないために、版3で足した「印の種類」の列にわざと CHECK 制約を付けていません。代わりに、リポジトリは知らない値を既定値として読み、書き込む側は TypeScript の型で縛っています。DB の制約を諦めて型に任せるのは妥協ですが、「将来の再構築で写真の行が連鎖削除される」危険と比べて、こちらを選びました。
2-3. 絶対パスを DB に保存しない
版5は、設計の誤りを直した移行です。
-- apps/mobile/src/data/migrations.ts version 5(抜粋)
ALTER TABLE import_queue RENAME COLUMN temp_path TO staged_file_name;
UPDATE import_queue
SET staged_file_name = substr(
staged_file_name,
instr(staged_file_name, '/import-staging/') + length('/import-staging/')
)
WHERE instr(staged_file_name, '/import-staging/') > 0;
それまで、取り込んだ写真の行は …/Application/<UUID>/Documents/import-staging/<uuid>.jpg という絶対パスを持っていました。iOS のアプリ用コンテナのパスに含まれる UUID は、端末の復元で変わることがあります。そうなると、DB の行はあるのにファイルが見つからない状態になります。版5は、列をファイル名だけに改め、読むときに今の Documents と連結するようにしました。
ここにも注意点があります。
RENAME COLUMNは SQLite 3.25 からの機能です。expo-sqlite 57.0.2 が同梱しているのは 3.50.3 なので問題ありませんが、古い SQLite を使う環境では確認が必要です。- 書き換えるのは
/import-staging/を含む値だけで、含まない値は推測で直さずに残しています。移行の中で「たぶんこうだろう」とデータを書き換えると、間違ったときに元へ戻せません。
なお、この不具合は ADR-0002 の改訂で「コードからの推定で、実機では確かめていない」と明記されています。Apple は File System Programming Guide でコンテナの絶対パスを保存しないよう求めており、それに合わせて直した、という位置づけです。
2-4. テストは「過去の版が残した DB」から流す
移行のテストで一番効いているのは、空の DB からではなく、配信済みの各版の DB から最新版へ上げるテストです。
// apps/mobile/src/data/database.test.ts(抜粋)
/**
* 配信済みのアプリが残した DB を、版 `version` の時点のまま作る。端末にはこれが残っているので、
* 以降の移行は空のファイルからだけでなくここからも試す。配信したアプリの移行失敗は取り消せない。
*/
async function migrateThrough(driver: SqlDriver, version: number): Promise<void> {
const shipped = MIGRATIONS.filter((migration) => migration.version <= version);
await driver.execAsync('PRAGMA foreign_keys = ON');
for (const migration of shipped) {
for (const statement of migration.statements) await driver.execAsync(statement);
}
await driver.execAsync(`PRAGMA user_version = ${String(version)}`);
}
これで「版1の列の並びで書かれた行」を入れてから migrate を流し、行が残ることと、足した列が期待どおり NULL で読めることを確かめます。
失敗のテストもあります。特定の SQL で execAsync が失敗するドライバを被せ、「ディスクが一杯」の状態を再現します。
// apps/mobile/src/data/database.test.ts(抜粋)
await expect(migrate(flaky)).rejects.toThrow('disk full');
expect(await readSchemaVersion(driver)).toBe(0);
// SQLite では DDL もトランザクションで巻き戻る。失敗前に作った表も消えている
expect(await tableNames(driver)).toEqual([]);
// 接続はまだ使え、正常な再試行は成功する
expect(await migrate(driver)).toEqual({ from: 0, to: LATEST_SCHEMA_VERSION });
ドライバの抽象(1-1)があるので、こうした障害の注入が数行で書けます。
2-5. withTransactionAsync は排他ではない:トランザクションはキューで直列にする
移行とは別に、日々の書き込みでも一つ罠があります。expo-sqlite の withTransactionAsync のドキュメントには、「このトランザクションは排他ではなく、他の非同期クエリに割り込まれることがある」と書かれています(expo-sqlite 57.0.2 の src/SQLiteDatabase.ts のドキュメントコメントでも確認しました)。
JavaScript はシングルスレッドですが、await のたびに別の処理が割り込めます。たとえば、ある画面で写真をまとめて配置している最中に、別の画面から削除が走ったとします。入れ子の深さを数えるだけの素朴な実装だと、削除は「自分は入れ子だ」と判断して、配置のトランザクションの中で書き込みます。その後に配置がロールバックすると、削除の書き込みも一緒に消えます。削除を呼んだ側には、すでに成功が返っているのにです。
このアプリは、入れ子を判定するのをやめて、トランザクションを FIFO のキューに並べています。
// apps/mobile/src/data/transactionQueue.ts(抜粋)
export function createTransactionQueue(): Serializer {
// キューの末尾。必ず fulfilled で終わるので、1つの失敗がキューを止めない
let tail: Promise<unknown> = Promise.resolve();
return <T>(run: () => Promise<T>): Promise<T> => {
const result = tail.then(run);
tail = result.then(() => undefined, () => undefined);
return result;
};
}
expo-sqlite のアダプタは、すべての withTransactionAsync をこのキューに通します。
代わりに、本当に入れ子で呼ぶと自分の親を待ち続けて止まります。そこで src/test/architecture.test.ts が、services・data・ui・app のソースを読み、withTransactionAsync( の中にもう一つ withTransactionAsync( がある箇所を静的に探して、見つかったら失敗させています。
withExclusiveTransactionAsync を使わない理由もコメントに残っています。排他版は2本目のネイティブ接続を開き、渡された txn 経由の SQL だけがそのトランザクションに入ります。リポジトリは SqlDriver しか知らないので txn を使えず、migrate が接続ごとに設定した PRAGMA foreign_keys もその接続には効きません。
3. 機種変更に備えた暗号化バックアップ
サーバーが無いので、機種変更の引き継ぎも自分で用意します。このアプリの答えは二段構えです(ADR-0002・ADR-0004)。
- iOS 標準のバックアップ:DB と縮小した写真を Documents に置くので、iCloud バックアップやクイックスタートでそのまま移ります。
- 暗号化した書き出しファイル(
.goshuin):すべての記録と写真を1つのファイルにまとめ、共有シートの「"ファイル"に保存」や AirDrop で端末の外へ出します。アプリ自身はどこにもアップロードしません。
3-1. ファイル形式:45バイトのヘッダと、チャンクごとの AES-256-GCM
形式は apps/mobile/src/services/backupContainer.ts のコメントが正本です。
header(45 bytes)
magic "GSHN" 4 | version 1 | kdfIterations u32be 4 | salt 16 | nonceBase 8
| chunkSize u32be 4 | plaintextLength u64be 8
body
( u32be(len) ‖ ciphertext ‖ tag(16) )* チャンクごとの AES-256-GCM
平文は ZIP で、中身は manifest.json(zod のスキーマ付き)、表計算で開ける records.csv、写真です。設計の要点を挙げます。
| 論点 | 決めたこと | 理由 |
|---|---|---|
| 鍵の導出 | PBKDF2-HMAC-SHA256、既定600,000回 | OWASP の Password Storage Cheat Sheet の推奨値。書き出しは一回きりの操作なので時間を許容できる |
| チャンク | 平文4 MiBごとに暗号化 | 数百 MB のファイルでも、ネイティブ側の常駐メモリを一定にする |
| nonce | nonceBase ‖ u32be(チャンク番号) | salt と nonceBase は書き出しごとに乱数なので、同じパスフレーズでも鍵と nonce は再利用されない |
| AAD | ヘッダ45バイト全体を全チャンクに付ける | 反復回数やチャンク長を書き換えると認証に失敗する |
| 切り詰め | plaintextLength と照合する | 末尾のチャンクを切り落とされても検出できる |
| ヘッダの上限 | 反復1,000〜2,000,000回、チャンク1 KiB〜64 MiB | 認証の前に仕事量を決める値なので、細工した45バイトで CPU やメモリを占有させない |
| 購入情報 | manifest に入れない(スキーマの strict() で拒否) | 取り込みで有料機能が解除されてはならない |
「ヘッダの上限」は見落としやすい点です。AES-GCM の認証は、鍵を導出してチャンクを読んだ後で行われます。つまり反復回数とチャンク長は、認証されていない値のまま仕事量を決めます。このアプリは、TypeScript の decodeHeader、Swift のモジュール、Node の参照実装の3か所で同じ範囲を検査しています。
3-2. 暗号をネイティブ(CryptoKit)に寄せた理由
暗号化そのものは、Swift で書いたローカルの Expo モジュール(apps/mobile/modules/goshuin-crypto)が、CryptoKit の AES.GCM、CommonCrypto の CCKeyDerivationPBKDF、SecRandomCopyBytes をファイル単位で呼びます。JS 側には、ファイルのパスと小さな base64 の値しか渡しません。
// apps/mobile/modules/goshuin-crypto/ios/GoshuinCryptoModule.swift(抜粋)
AsyncFunction("sealFile") {
(inputPath: String, outputPath: String, keyBase64: String, headerBase64: String,
nonceBaseBase64: String, chunkSize: Int) throws in
let (key, header, nonceBase) = try Self.decodeMaterial(keyBase64, headerBase64, nonceBaseBase64)
guard chunkSize >= Self.minChunkSize, chunkSize <= Self.maxChunkSize else {
throw BadArgumentException("chunkSize")
}
let input = try FileHandle(forReadingFrom: Self.url(inputPath))
defer { try? input.close() }
let output = try Self.openForWriting(outputPath)
defer { try? output.close() }
try output.write(contentsOf: header)
var index: UInt32 = 0
while true {
let chunk = try input.read(upToCount: chunkSize) ?? Data()
if chunk.isEmpty { break }
let nonce = try AES.GCM.Nonce(data: nonceBase + Self.bigEndian(index))
let box = try AES.GCM.seal(chunk, using: key, nonce: nonce, authenticating: header)
let body = box.ciphertext + box.tag
try output.write(contentsOf: Self.bigEndian(UInt32(body.count)))
try output.write(contentsOf: body)
index &+= 1
}
}
ADR-0002 が挙げている理由は4つです。
- 輸出規制の申告:OS が提供する暗号だけを使うなら、Info.plist の
ITSAppUsesNonExemptEncryptionをfalseに固定でき、提出ごとの質問を省けます(最終的な判断は Apple のヘルプで確認する前提です)。 - メモリ:大きなファイルを JS の
Uint8Arrayで持つと、ブリッジを通るたびにコピーが起きます。ネイティブ側で4 MiBずつ流せば、常駐メモリは一定です。 - 監査のしやすさ:自前の AES-GCM や npm の純 JS 実装は、監査の手間が大きく、サイドチャネルへの保証もありません。
- テストのしやすさ:同じバイト形式を node:crypto で書いた参照実装(
src/test/nodeCryptoPort.ts)で再現し、Jest の中で本物の SQL・ファイル・暗号を使って書き出しと復元を往復させられます。
Swift のモジュールを Expo から呼ぶ仕組み(AsyncFunction の実行キュー、Exception とエラーコードの対応、podspec が無いと黙ってリンクされない問題など)は、Expo × Swift ネイティブモジュール実装ガイド で詳しく扱っています。
3-3. 正直な限界:Swift と Node の実装が同じバイトを出すことは、機械的には確かめていない
ここは書いておくべき弱点です。Jest で動くのは Node の参照実装で、Swift の実装は動きません。両者が同じバイト列を読み書きすることを、自動テストで突き合わせてはいません。確かめているのは次の2つです。
- Jest:Node の参照実装で、書き出しから復元までの往復(
src/services/backupService.test.ts) - Maestro:実機やシミュレータで、Swift の実装が自分の書いたファイルを復元できること(
.maestro/05-backup-roundtrip.yaml)
ただし、同じ端末の Swift 同士で往復が通っても、形式の仕様どおりかは保証されません。同じ誤りを書き出しと読み込みの両方でしていれば、往復は通ってしまうからです。より強くするなら、Swift で作った既知の平文・鍵のファイルをリポジトリに置き、Node の実装で復号する「既知解テスト」を足すのが次の一手です。
3-4. 壊れたファイルを「パスフレーズ違い」と誤診させない
形式とは別に、端末ならではの失敗への備えもあります(ADR-0002)。
- 書き出しは一時ファイルで行い、完了後に rename する。 封緘は
Library/Caches/backup-tmp/<name>.goshuin.partで行い、終わったら同じボリューム内の移動でDocuments/exports/へ移します。途中で強制終了しても、切り詰められた.goshuinが履歴に現れません。現れると、復元時に「パスフレーズが違う」と誤診されます。 - 強制終了で残った一時ファイルは、次の起動で消す。
finallyは強制終了では走らないからです。合成ルートのsweepScratch()がこれです。 - 復元の前に空き容量を確かめる。 アーカイブの2倍(復号した ZIP と展開した写真)が無ければ、時間のかかる鍵導出に入る前に
no_spaceで止めます。 - 取り込みは冪等なマージにする。 id が無ければ追加、
updatedAtが新しければ更新、それ以外は飛ばします。同じファイルを2回取り込んでも壊れません。 - ZIP の中のファイル名を制限する。 写真のエントリ名は
^[A-Za-z0-9._-]+$の平らなファイル名だけを受け付けます(zip-slip 対策)。manifest の中のファイル名はさらに狭く、後で削除処理に渡るので../SQLite/goshuin.dbのような値を持ち込ませません。
パスフレーズを忘れたら、誰にも復元できません。サーバーも鍵の預かりも無いので、これは仕様です。代わりにアプリは設定画面で「最後に書き出した日時」を表示し、記録が増えたら書き出しを促しています。
4. RevenueCat を使わない StoreKit 2 の買い切り
課金は、非消耗型の商品が1本だけです。表示名は「台帳フル」、LP の表示は ¥1,200(税込)です(packages/shared/src/product.ts の priceHintJpy と ADR-0003)。アプリ内では、StoreKit が返す displayPrice 以外の金額を表示しません。
4-1. RevenueCat を入れなかった理由
RevenueCat はサブスクリプションの状態管理や Webhook で大きな力を発揮します。サブスクリプションを RevenueCat で運用する話は、RevenueCat によるアプリ内サブスクリプションの本番運用ガイド にまとめました。このアプリで入れなかった理由は、ADR-0003 に書かれています。
- 商品が非消耗型の1本だけで、サブスクリプションの更新・猶予期間・解約といった状態管理が要らない
- 権利を共有する先(Android 版や Web 版、ログイン)が無い
- RevenueCat の SDK は購入履歴を RevenueCat のサーバーに送るため、App Privacy で購入履歴などの申告が要る(RevenueCat 自身の App Privacy の案内にも、Purchase History の項目があります)。「購入の情報は Apple とだけやり取りする」という方針と両立しない
判断の目安を表にするとこうなります。
| 条件 | StoreKit 2 を直接使う | RevenueCat などを使う |
|---|---|---|
| 商品 | 非消耗型が少数 | サブスクリプションが中心 |
| 権利を共有する先 | iOS アプリだけ | Android・Web・ログインとまたがる |
| サーバー側の処理 | 要らない | レシート検証、Webhook、CRM 連携が要る |
| 購入データの扱い | Apple とだけやり取りしたい | 分析のために集約したい |
4-2. StoreKit を唯一の根拠にし、キャッシュは表示用と割り切る
権利(購入済みかどうか)の判定は EntitlementService の1か所にまとめています。根拠は StoreKit だけで、端末の設定テーブルに置くキャッシュは「オフラインのときに UI が答えを持つため」のものです。
// apps/mobile/src/services/entitlementService.ts(抜粋)
/** キャッシュを読み、StoreKit で上書きする。StoreKit が失敗したらキャッシュの答えのまま */
async initialize(): Promise<EntitlementState> {
const cached = parseCache(await this.deps.settings.get(SETTING_KEYS.entitlementCache));
if (cached) {
await this.set({ entitled: cached.entitled, source: 'cache', checkedAt: cached.checkedAt }, false);
}
try {
const purchases = await this.bounded('entitlements', STORE_READ_TIMEOUT_MS, this.readEntitlements());
await this.set({
entitled: isEntitled(purchases, this.deps.productId),
source: 'storekit',
checkedAt: this.deps.clock.nowIso(),
});
} catch (error) {
// 届かなくても遅くても答えは同じ。上で出したキャッシュのままにする
this.deps.logger.log('warn', 'entitlement refresh failed; using cache', { message: messageOf(error) });
}
return this.state;
}
状態には source: 'storekit' | 'cache' | 'none' を持たせ、「今確かめた答え」と「前回の答え」を区別しています。再インストール直後にオフラインだと、キャッシュが無いので未購入として扱われます。購入も復元もネットワークが要るので、これは受け入れています。バックアップのファイルに購入情報を入れないのも、この「根拠は StoreKit だけ」の原則からです。
4-3. StoreKit の呼び出しには必ず時間の上限を置く
StoreKit に届かない端末(機内モード、Sandbox のアカウントが無い、Xcode の外で起動したシミュレータ)では、商品の取得がいつまでも終わらないことがあります。このアプリでは、App Store 用に撮ったペイウォールの画面が「価格を取得中」のまま止まっていたことで、これに気づきました(ADR-0003 改訂2)。
いまは3種類の上限を置いています。
| 呼び出し | 上限 | 理由(コードのコメントより) |
|---|---|---|
| 商品の取得・起動時の権利の読み込み | 10秒 | 画面には Apple の UI が何も出ていない。遅い回線の往復を見込んでも、シートを読んでいる間に決着する |
| 購入の復元 | 60秒 | Apple のサインイン画面が出て、パスワードの入力を待つことがある |
| 購入 | 5分 | 支払いシート、Face ID、承認と購入のリクエスト(Ask to Buy)を待つ |
上限は EntitlementService の中の1か所(bounded)に置きました。「ストアに届かなくてもアプリは使える」という約束はサービス層のものであり、将来アダプタを替えても上限が残るようにするためです。上限に達したときは StoreUnreachableError という型のあるエラーにして、「ストアが答えて、その商品が無い(null)」「端末が購入を許していない(PurchasesNotAllowedError)」と区別しています。ペイウォールはこの3つを別の表示にします。再試行で直るのは1つ目だけだからです。
4-4. expo-iap のアダプタ:購読を先に、終わっていない取引は必ず finish する
StoreKit 2 には、アプリの外で起きた取引(承認と購入のリクエストの承認、App Store でのコード利用など)や、終わっていない取引を受け取る Transaction.updates があります。Apple のドキュメントには、終わっていない取引は、アプリの起動直後に一度だけ updates に流れること、そのためアプリの起動と同時に購読を始めるべきことが書かれています。
expo-iap 経由でこれを正しく扱うために、アダプタは2つのことをしています。
// apps/mobile/src/adapters/expoIap.ts(抜粋)
/**
* 進行中の purchase() の外で届いた取引は、ここで finish する。
* 権利はこのイベントからではなく、サービスが currentEntitlements() で読み直す。
*/
const finishStray = (purchase: Purchase): void => {
if (inFlight.has(purchase.productId) || purchase.purchaseState === 'pending') return;
finishTransaction({ purchase, isConsumable: false }).catch((error: unknown) => {
console.warn('[iap] could not finish a replayed transaction', messageOf(error));
});
};
return {
async connect(): Promise<void> {
// initConnection を始めた時点で、終わっていない取引が流れてくる。
// expo-iap は登録済みのリスナーにしか転送しないので、先に購読する
replay ??= purchaseUpdatedListener(finishStray);
await initConnection();
},
// …
};
1つ目は、購読を initConnection() より先に登録することです。順番を逆にすると、接続の直後に流れてきた取引を取りこぼします。
2つ目は、進行中の購入に属さない取引も finish することです。Apple のドキュメントは、購入した内容を提供した後に finish() を呼ぶよう求めています。終わらせない取引は、次の起動でもまた流れてきます。一方で、進行中の purchase() に属する取引は、その呼び出しが自分で受け取って finish します。これを inFlight の集合で区別しています。
購入の結果は、更新イベントとリクエストの Promise の両方から、順不同で届きます。キャンセルもエラーイベントと Promise の拒否の両方から来ます。アダプタは「最初に届いた信号が勝つ」ようにして、二重に処理しないようにしています。
権利は、購入直後はその結果から、それ以外は getAvailablePurchases({ onlyIncludeActiveItemsIOS: true })(StoreKit 2 の現在の権利)からだけ更新します。App Store Review Guidelines 3.1.1 が求める「購入を復元」の操作は、設定画面とペイウォールの両方に置いています。
5. 課金ゲートは UI とサービス層の二重にする
有料機能は3つです(ADR-0003)。2冊目の作成、取り込んだ写真をまとめて冊に配置する操作、PDF と CSV の書き出しです。暗号化バックアップの書き出しと取り込みは、有料にしていません。復元の経路を課金で塞がないためです。
5-1. 判定は純粋関数に1か所
// apps/mobile/src/domain/entitlement.ts(抜粋)
export const FREE_BOOK_LIMIT = 1;
/** 2冊目以降は有料。閉じれば購入なしの範囲で続けられる */
export function canCreateBook(existingBookCount: number, entitled: boolean): GateDecision {
if (entitled || existingBookCount < FREE_BOOK_LIMIT) return ALLOW;
return { allowed: false, gate: 'multi_book' };
}
/** PDF/CSV 書き出しは有料。暗号化 ZIP の書き出し/取込は無料(復元経路を課金で塞がない) */
export function canExportPdfCsv(entitled: boolean): GateDecision {
return entitled ? ALLOW : { allowed: false, gate: 'export_pdf_csv' };
}
どの機能が有料かという知識は、ここにしかありません。React にも Expo にも依存しないので、テストは入力と出力を見るだけです。
5-2. サービス層でも投げ、UI はそれを捕まえてペイウォールへ送る
サービス層は、同じ関数で判定して GateError を投げます。
// apps/mobile/src/services/ledgerService.ts(抜粋)
async createBook(input: NewBookInput, entitled: boolean): Promise<Book> {
const count = await repos.books.count();
if (!canCreateBook(count, entitled).allowed) throw new GateError('multi_book');
// …
}
画面は、事前に判定してペイウォールへ送ることもできますが、最終的にはサービスの GateError を捕まえて送ります。
// apps/mobile/src/ui/screens/BookNewScreen.tsx(抜粋)
try {
await services.ledger.createBook({ title: form.title /* … */ }, entitled);
// 作れたあとに数える。2冊目はゲートで GateError になるので、呼ぶ前に数えると
// ペイウォールに当たった回数だけ「冊を作った」が増える
services.analytics.record({ name: 'book_created', entitled });
// …
} catch (error) {
if (error instanceof GateError) {
router.push(paywallHref(error.gate));
return;
}
// …
}
こうしておくと、判定を書き忘れた画面や、将来足す別の入口からサービスを呼んでも、課金を素通りしません。入力中のフォームは下書きとして自動保存されているので、ペイウォールを閉じて戻っても内容は残ります(「ソフト」なペイウォール)。
5-3. この設計の限界
二重にしていても、完全ではありません。正直に書いておきます。
entitledは呼び出し側が渡している。 サービスは購入状態を自分で読まず、UI がuseEntitlement()から得た値を引数で受け取ります。判定の書き忘れは防げますが、画面が誤ってtrueを渡すバグは防げません。より固くするなら、EntitlementServiceをサービスに注入して、サービス自身が状態を読む形にします。その代わり、テストで状態を用意する手間が増えます。- 判定はすべて端末の中で閉じている。 サーバーで検証しないので、改造された端末で判定を書き換えられれば迂回できます。¥1,200 の買い切りで、サーバーを持たないという方針を優先し、このリスクは受け入れています。高額な商品や、サーバー側の資源を消費する機能なら、サーバーでの検証(App Store Server API など)を検討すべきです。
6. シミュレータ無しで Jest に通し、残りを Maestro で押さえる
ここまでの設計は、テストのためでもあります。
6-1. Jest:本物の SQL・ファイル・暗号で、サービスを丸ごと動かす
makeTestServices() は、本番と同じ assembleServices に次のものを差し込みます。
| ポート | 本番 | Jest |
|---|---|---|
| SQL | expo-sqlite | sql.js(WebAssembly の SQLite) |
| ファイル | expo-file-system | Node の fs(一時ディレクトリ) |
| 暗号 | Swift / CryptoKit | node:crypto の参照実装 |
| StoreKit・ピッカー・共有シートなど | 各 Expo モジュール | スクリプトで操作できるフェイク |
これで、「写真を取り込み、記録を作り、暗号化して書き出し、空の DB に復元する」ような流れを、Jest の中で本物の SQL とファイルで往復させられます。移行、EntitlementService、バックアップのテストを手元で流すと、6ファイル125件が約5秒で通りました(npx jest src/data src/services/entitlementService.test.ts src/services/backupService.test.ts)。
jest.config.js は、src/domain・src/data・src/services に行・分岐・関数・文すべて100%のカバレッジ閾値を課しています。アダプタは Jest で動かないネイティブのラッパーなので、閾値の対象から外しています。
一つ注意があります。sql.js と expo-sqlite は、同梱する SQLite の版が同じとは限りません。このアプリの移行のコメントにも両者の版が並べて書かれています。新しい SQL の構文を使うときは、両方の版で使えるかを確かめる必要があります。
6-2. Maestro:Jest に通らないネイティブの部分
Jest に通らないのは、Swift の暗号モジュール、実際の StoreKit、ファイルの共有といったネイティブの部分です。これは Maestro の E2E で確かめます。番号付きのフローは11本あり(ls apps/mobile/.maestro/[0-9]*.yaml)、たとえば次のようなものです。
04-paywall-second-book.yaml:2冊目でペイウォールが出ること。金額は検証せず、シートの構成だけを見る(価格は StoreKit の設定か Sandbox で確かめる)05-backup-roundtrip.yaml:保存、検索、暗号化書き出し、削除、復元06-offline-smoke.yaml:05 と同じ手順を、Mac のネットワークを切って流す
06 には面白い制約があります。iOS シミュレータには機内モードが無く、Mac の回線を共有しています。Maestro の機内モード切り替えも Android 専用です。そのため、「Mac の Wi-Fi を切り、有線も抜いてから流す」という手順がフローのコメントに書かれています。
7. 「サーバーなし」でも、通信がゼロとは限らない
最後に、誤解されやすい点を書いておきます。このアプリは自前のサーバーを持ちませんが、外部との通信がゼロではありません。
- App Store との通信(商品の取得、購入、復元)
- 地図を表示したときの MapKit の地図タイル
- 不具合の報告(Sentry)と、アプリの使われ方の計測(PostHog)
3つ目は、はじめは既定でオフでしたが、ADR-0036 で既定でオンに変わりました。ADR には、EEA などでの同意の扱いについての反対意見を記録したうえでの決定だと書かれています。設定からオフにでき、オフの端末は送りません。送るのは許可リストを通したイベントだけで、記録や写真は送りません。ペイウォールの表示や購入の結果(成功・キャンセルなどの区分)はイベントとして送りますが、取引 ID は含みません。
このアプリのコード自体には fetch・XMLHttpRequest・WebSocket を書かず、architecture.test.ts が静的に検査しています。Sentry と PostHog の通信は、それぞれの SDK の中で起きます。「サーバーなし=通信なし」と書いてしまうと事実と違うので、プライバシーの説明ではこの区別をはっきり書く必要があります。
8. この設計が向いている場合、向いていない場合
| 向いている | 向いていない |
|---|---|
| データが一人の利用者の中で閉じている(記録、日記、コレクション) | 複数人での共有、複数端末のリアルタイム同期が要る |
| 会員登録をしないこと自体が価値になる | アカウントを前提にした機能(通知の配信、ランキングなど)がある |
| 課金が買い切りで、商品が少ない | サブスクリプションが中心、または Android や Web と権利を共有する |
| 運用の固定費と手間を最小にしたい | サーバー側での不正対策やデータ集計が事業の要 |
向いていない側に当てはまるなら、同期の仕組みやサーバーを持つ設計を選ぶべきです。オフラインでも書けて後から同期する構成なら、クライアントを信じない設計(オフライン同時編集で整合性と認可を PostgreSQL に寄せる) のような形が選択肢になります。Expo のリリース基盤(EAS、CNG、OTA 更新)は Expo 本番運用ガイド で扱っています。
このコードが動いているアプリ
この記事のコードはすべて、App Store で配信中の あとから御朱印帳(御朱印の記録アプリ) のリポジトリから抜粋しました。会員登録なしで、撮りためた御朱印の写真をまとめて取り込み、撮影日(EXIF)の順に並べ、和暦の日付を直しながら冊とページに整理するアプリです。写真から日付の候補を読む端末内の OCR もありますが、その読み取り精度はまだ測っていません。企画の背景や設計判断の経緯は /labs の制作記 に書いています。
同じ設計(サーバーを持たないモバイルアプリ、端末上のデータ移行、暗号化バックアップ、アプリ内課金、テストしやすい層構造)は、開発の依頼としてもお受けしています。既存の Expo アプリの設計の見直しからでも構いません。詳しくは サービス内容 をご覧ください。