API アーキテクチャ方針 — BFF 一本化 + SDUI Level 3 API architecture — BFF-first + Server-Driven UI Level 3
2026-04-23 に確定した Parky の API アーキテクチャ方針のまとめ。
全クライアント(モバイル / Web / 管理者・オーナー・マーケティングの各ポータル)は
Cloudflare Workers 上の BFF (/v1/*) だけを叩く。
モバイルは SDUI Level 3 に固定し、
OpenAPI 契約(OAS)はチャネル別に完全分離する。
The Parky API architecture locked on 2026-04-23. Every client (mobile / web / admin / owner / marketing portals)
talks only to the Cloudflare Workers BFF (/v1/*).
Mobile is pinned at SDUI Level 3, and the OpenAPI contracts (OAS) are split per channel.
- 全クライアントは Cloudflare Workers BFF (
/v1/*) のみ。Supabase DB / Edge Functions の直叩きは禁止。 - Every client hits only the Cloudflare Workers BFF (
/v1/*). Direct access to Supabase DB / Edge Functions is forbidden. - モバイルは SDUI Level 3 固定。
/v1/mobile/views/*がViewEnvelope、/v1/mobile/actions/*がActionEnvelope、/v1/mobile/telemetry/*がTelemetryAckを返す。UI コンポーネント構造(L4/L5)は返さない。 - Mobile is pinned to SDUI Level 3:
/v1/mobile/views/*returnsViewEnvelope,/v1/mobile/actions/*returnsActionEnvelope,/v1/mobile/telemetry/*returnsTelemetryAck. No UI component trees (L4/L5). - OAS はチャネル別に 5 ファイル(管理者 / オーナー / マーケティング / モバイル / Web アプリ)。
- OAS is split into 5 per-channel files (admin / owner / marketing / mobile / web app).
1. システム全景 (flow)1. System flow
Flutter] WEB[Web App
parky.co.jp] ADM[Admin Portal] OWN[Owner Portal] MKT[Marketing Portal] end subgraph Edge["Cloudflare Workers BFF (/v1/*)"] MOBSUR[/mobile/views
mobile/actions
mobile/telemetry/] WEBSUR[/public resource API
/v1/parking-lots etc./] ADMSUR[/admin/*/] OWNSUR[/owner/*/] MKTSUR[/marketing/*/] end subgraph Backend["Backend"] SB[(Supabase DB
+ RLS)] FCM[FCM / Resend / X API] end MOB -- ViewEnvelope/Action/Telemetry --> MOBSUR WEB --> WEBSUR ADM --> ADMSUR OWN --> OWNSUR MKT --> MKTSUR MOBSUR --> SB WEBSUR --> SB ADMSUR --> SB OWNSUR --> SB MKTSUR --> SB MOBSUR --> FCM ADMSUR --> FCM MKTSUR --> FCM MOB -. Realtime (Supabase client) .-> SB
Realtime 購読のみ、ROI の都合でモバイル Flutter から Supabase client 直接接続を許容する (BFF 経由 WebSocket 中継はコストに見合わない)。それ以外の読み書きはすべて BFF を経由する。
Realtime subscriptions are the single exception: mobile Flutter may open them directly via the Supabase client (proxying WebSockets through the BFF is not ROI-positive). Every other read/write goes through the BFF.
2. なぜ BFF 一本化か2. Why BFF-first
- セキュリティ境界が 1 箇所: Service Role Key / FCM 秘密 / X API Secret / Resend token の露出面を BFF に閉じ込められる。クライアントにシークレットを持たせない運用が機械的に成立する。
- Single security boundary: Service Role Key / FCM secret / X API secret / Resend token exposure is confined to the BFF. Clients simply never hold secrets.
- DB を直接触らせない: RLS を頼りにしたスキーマ晒しをやめ、BFF が必要な形に aggregate してから返す。RLS は二重防御として残す(穴が開いても DB レベルで止まる)。
- Clients never touch the DB: No more relying on RLS to safely expose raw tables — the BFF aggregates first. RLS stays as defence-in-depth (a bug at the BFF still hits a DB-level stop).
- 契約変更がサーバに閉じる: 画面/テーブル変更を BFF で吸収。旧モバイル端末向けに互換レイヤーを BFF 側で維持できる。
- Contract change stays server-side: Schema/screen changes are absorbed inside the BFF; legacy-mobile compat layers can live there too.
- Edge runtime でレイテンシ最小: Cloudflare Workers で低レイテンシ + キャッシュ + Cron + R2 が 1 プラットフォームに揃う。
- Low latency at the edge: Cloudflare Workers give us low-latency routing, caching, cron, and R2 in one platform.
3. SDUI レベル定義と L3 採用理由3. SDUI level definitions and why L3
| Level | サーバーが返すもの | What the server returns | Parky 採否 | Parky adoption |
|---|---|---|---|---|
| L1 | 生の JSON(スキーマ任せ) | Raw JSON, schema-by-convention | —(旧世代) | — (legacy) |
| L2 | 正規化されたドメインデータ(REST / GraphQL) | Normalised domain data (REST / GraphQL) | Web アプリはほぼ L2 相当 | Web app is effectively L2 |
| L3 | 画面単位 aggregate(ViewEnvelope)。データ + 遷移指示 + バリデーション + UI Config。UI は各プラットフォーム実装 |
Per-screen aggregate (ViewEnvelope): data + navigation + validation + UI config. UI widgets remain platform-native |
モバイルで採用 | Adopted for mobile |
| L4 | コンポーネントツリー JSON(例: Airbnb GraphQL Driven UI) | Component-tree JSON (e.g. Airbnb GraphQL Driven UI) | ❌ 不採用 | ❌ Rejected |
| L5 | DSL + runtime(例: Airbnb Ghost Platform / Server Driven Widgets) | DSL + runtime (e.g. Airbnb Ghost Platform, Server Driven Widgets) | ❌ 不採用 | ❌ Rejected |
なぜ L3: サーバー主導で振る舞い(ルール・文言・遷移・フラグ)を動的に変えたい。 ただし UI の見た目は Flutter Material / React の強みを殺したくない。L4/L5 だと自前のレンダリングエンジンを持つ羽目になり、 アニメーション・ジェスチャー・Mapbox のようなネイティブコンポーネントと噛み合わない。L3 はその中間の「運用の機動力と描画の自由度が両立する」落としどころ。
Why L3: we want the server to drive behaviour (rules, copy, navigation, flags) without losing Flutter Material / React's native-widget advantage. L4/L5 forces us to maintain a custom render engine that fights animations, gestures, and Mapbox. L3 is the midpoint where operational agility coexists with native rendering freedom.
4. ViewEnvelope の構造4. ViewEnvelope structure
/v1/mobile/views/* はすべて以下のエンベロープを返す。data / meta / fallback_behavior は必須、
その他は画面の性質に応じて任意。
Every /v1/mobile/views/* endpoint returns this envelope. data, meta, and fallback_behavior are required;
the rest are optional depending on the screen.
{
"data": { /* screen-specific primary payload */ },
"ui_config": { /* messages / feature_flags / experiment_id / theme_hint */ },
"navigation": { "target": "...", "params": {...}, "strategy": "push|replace|pop_to_root" },
"validation": [ { "field": "...", "rule": "...", "param": ..., "message_code": "..." } ],
"states": { "error": [...], "empty": {...}, "skeleton": {...} },
"fallback_behavior": { "on_network_error": "...", "on_auth_error": "...", "on_version_mismatch": "...", "cache_ttl_seconds": 60 },
"meta": { "server_time": "...", "cache_key": "...", "min_app_version": "1.4.0", "sunset_date": null, "realtime": {...} }
}
| フィールド | Field | 責務 | Responsibility | 例 | Example | 必須? | Required? |
|---|---|---|---|---|---|---|---|
data |
data |
画面に表示する primary payload(画面固有型) | Primary payload for the screen (screen-specific type) | 駐車場詳細 → ParkingLotDetail |
Parking lot detail → ParkingLotDetail |
✅ 必須 | ✅ Required |
ui_config |
ui_config |
文言(messages)/ feature flag / A/B 実験 ID / theme hint | Messages / feature flags / A/B experiment id / theme hint | boot で client flag 一括配信 | Boot endpoint ships client flags once | 画面次第 | Per screen |
navigation |
navigation |
次画面遷移のヒント(deeplink / modal / pop) | Next-step hint (deeplink / modal / pop) | 予約成立後 → push parking_detail |
After booking → push parking_detail |
action 系のみ必須 | Required on actions |
validation |
validation |
入力系画面の制約(required / min_length / regex / numeric_range) | Input constraints (required / min_length / regex / numeric_range) | ナンバープレート = 8 桁 | Plate number = 8 chars | 入力画面のみ | Input screens only |
states |
states |
loading / empty / error 時のメッセージと fallback 指針(ERROR_CATALOG から自動生成) |
Loading / empty / error messages and fallback direction (auto-generated from ERROR_CATALOG) |
404 → empty illustration | 404 → empty illustration | 任意 | Optional |
fallback_behavior |
fallback_behavior |
ネット不通 / 認証エラー / バージョン不整合時の既定動作 | Defaults for network / auth / version-mismatch failures | on_version_mismatch: force_update |
on_version_mismatch: force_update |
✅ 必須 | ✅ Required |
meta |
meta |
ETag / cache-control / request_id / server_time / min_app_version / realtime ヒント | ETag / cache-control / request_id / server_time / min_app_version / realtime hint | min_app_version: "1.4.0" |
min_app_version: "1.4.0" |
✅ 必須 | ✅ Required |
実装ヘルパーは lib/view-envelope.ts の buildViewEnvelope() / buildErrorStatesFromCodes() / resolveClientFeatureFlags()。
states.error は ERROR_CATALOG + ERROR_MESSAGE_KEYS から自動派生させること(手書き禁止)。
The implementation lives in lib/view-envelope.ts: buildViewEnvelope(), buildErrorStatesFromCodes(), resolveClientFeatureFlags().
Never hand-write states.error; always derive it from ERROR_CATALOG + ERROR_MESSAGE_KEYS.
5. ActionEnvelope の構造5. ActionEnvelope structure
mutation endpoint(/v1/mobile/actions/*)が返すレスポンス契約。α 型: 成功時に「次に描画すべき View の data」を同梱することで、クライアントは再 fetch せずに即描画する。
Mutation endpoint (/v1/mobile/actions/*) response contract. α variant: the response already carries the next view's data so the client can re-render without an extra fetch.
{
"result": { /* 操作結果 / next-view data */ },
"navigation": { "target": "parking_detail|none", "params": {...}, "strategy": "push|replace|pop_to_root" },
"toast": { "kind": "success|error", "message_code": "..." },
"meta": { "request_id": "...", "server_time": "...", "mutation_id": "uuid" }
}
navigationは必須。同画面再描画はNAVIGATION_REFRESH_CURRENT({target:"none", strategy:"replace"})。navigationis required. Re-rendering the current screen usesNAVIGATION_REFRESH_CURRENT({target:"none", strategy:"replace"}).- Idempotency:
routes-manifestのidempotent: trueでIdempotency-Keyヘッダを必須化。meta.mutation_idはサーバー採番の ack key。 - Idempotency: set
idempotent: trueinroutes-manifestto require theIdempotency-Keyheader.meta.mutation_idis a server-issued ack key.
6. TelemetryAck の構造6. TelemetryAck structure
画面に紐付かない fire-and-forget 記録系(geofence 進入 / 位置テレメトリ / push 受信記録)のレスポンス契約。UI に影響しないので軽量 ack のみ。
Response contract for fire-and-forget telemetry endpoints (geofence entries / location telemetry / push receipt logs). UI-independent, so we ship only a lightweight ack.
{
"ack": true,
"meta": { "request_id": "...", "received_at": "...", "event_id": "uuid" }
}
クライアントは ack を待たず処理継続してよい。Idempotency-Key は必須(重複記録防止)。
The client may proceed without awaiting the ack. Idempotency-Key is mandatory to prevent duplicate records.
7. チャネル別 OAS と API surface 境界7. Per-channel OAS and API surface boundaries
OpenAPI はチャネル別に完全分離して 5 ファイルで運用する。1 本の巨大 OAS は誰のためにもならない。
OAS is fully split per channel into 5 files. One giant OAS serves nobody.
| チャネル | Channel | OAS ファイル | OAS file | 主な path | Key paths | クライアント | Client |
|---|---|---|---|---|---|---|---|
| モバイル | Mobile | docs/mobile-app/openapi.json |
docs/mobile-app/openapi.json |
/v1/mobile/{views,actions,telemetry}/* |
/v1/mobile/{views,actions,telemetry}/* |
Flutter | Flutter |
| Web アプリ | Web app | docs/web-app/openapi.json |
docs/web-app/openapi.json |
/v1/parking-lots, /v1/articles 等の公開 resource API |
/v1/parking-lots, /v1/articles, public resource endpoints |
parky.co.jp / dev.parky.co.jp | parky.co.jp / dev.parky.co.jp |
| 管理者 | Admin | docs/admin/openapi.json |
docs/admin/openapi.json |
/v1/admin/* |
/v1/admin/* |
admin.parky.co.jp | admin.parky.co.jp |
| オーナー | Owner | docs/owner/openapi.json |
docs/owner/openapi.json |
/v1/owner/* |
/v1/owner/* |
owner.parky.co.jp | owner.parky.co.jp |
| マーケティング | Marketing | docs/marketing/openapi.json |
docs/marketing/openapi.json |
/v1/marketing/* |
/v1/marketing/* |
marketing.parky.co.jp | marketing.parky.co.jp |
モバイルは resource API を叩かない。/v1/mobile/{views,actions,telemetry}/* の 3 surface だけで完結させる。
旧 /v1/mobile/boot / /v1/mobile/home-feed / /v1/mobile/lots/<id> 等の thin wrapper は Sunset ヘッダ付きで 2026-07-31 まで並走、その後 410 Gone。
Mobile does not hit resource APIs. It is closed under the 3 surfaces /v1/mobile/{views,actions,telemetry}/*.
Legacy paths (/v1/mobile/boot, /v1/mobile/home-feed, /v1/mobile/lots/<id>, …) run alongside with Sunset headers until 2026-07-31, then return 410 Gone.
8. 責任分界点8. Responsibility boundaries
| 責任 | Responsibility | BFF(サーバー) | BFF (server) | クライアント | Client |
|---|---|---|---|---|---|
| データ shape(JSON 契約) | Data shape (JSON contract) | ✅ | ✅ | 受信のみ | Receive only |
| UI スタイル(色・余白・タイポ) | UI style (colour / spacing / typography) | — | — | ✅ | ✅ |
| UI コンポーネント構造 | UI component structure | ❌ L4/L5 採用せず | ❌ No L4/L5 | ✅ | ✅ |
| 画面遷移 | Screen navigation | ✅ navigation で指示 | ✅ Instructed via navigation |
遵守(go_router 実装) | Follow (go_router) |
| バリデーションルール | Validation rules | ✅ validation で配信(最終判定も BFF) | ✅ Shipped via validation (final check on BFF) |
即時表示のみ | Realtime hint only |
| 状態遷移 (loading/empty/error) | States (loading / empty / error) | ✅ states / fallback_behavior | ✅ states / fallback_behavior |
レンダー | Render |
| ビジネスルール計算(料金・予約可否) | Business rule compute (fee / bookable) | ✅ | ✅ | — | — |
| ローカル永続化(キャッシュ・フォーム) | Local persistence (cache / form draft) | — | — | ✅ | ✅ |
| 認証トークン管理 | Auth token management | 検証のみ | Verification only | ✅(Supabase Auth SDK) | ✅ (Supabase Auth SDK) |
| アニメーション / ジェスチャー | Animations / gestures | — | — | ✅ | ✅ |
| テレメトリ送信 | Telemetry submission | endpoint 提供 | Endpoint provision | ✅ イベント発火 | ✅ Event firing |
| Push 通知送信 | Push delivery | ✅ FCM / Resend / X API | ✅ FCM / Resend / X API | 表示のみ | Display only |
| Realtime 購読 | Realtime subscription | meta.realtime でヒント提供 | Hint via meta.realtime |
✅ Supabase client で接続 | ✅ Supabase client |
| Deeplink / Push 初期ルーティング | Deeplink / push initial routing | — | — | ✅ | ✅ |
| 文言(i18n リソース) | Display strings (i18n) | message_code を配信 | Ship message_code |
✅ ローカルリソース優先 | ✅ Local resource first |
| Feature flag | Feature flag | ✅ ホワイトリスト経由で配信 | ✅ Via whitelist | boolean で分岐 | Branch on boolean |
| オフライン fallback | Offline fallback | ルール配信 | Rule distribution | ✅ ルールに従って描画 | ✅ Render per rule |
9. 契約バージョニング9. Contract versioning
X-App-Versionヘッダはすべての/v1/*で必須。セマンティックバージョン(MAJOR.MINOR.PATCH)。X-App-Versionis required on every/v1/*call. Semantic version (MAJOR.MINOR.PATCH).- BFF は
meta.min_app_versionと照合し、古ければfallback_behavior.on_version_mismatchで挙動指示:force_update/degrade/ignore。 - The BFF compares against
meta.min_app_versionand directs behaviour viafallback_behavior.on_version_mismatch:force_update/degrade/ignore. - 互換不可変更時は HTTP 426 Upgrade Required を返し、クライアントは強制アップデートモーダルを表示する。
- On incompatible changes the BFF returns HTTP 426 Upgrade Required and the client shows a force-update modal.
- OpenAPI の
info.versionはapi/package.jsonと同期させる(contracts ワークフローで lock)。 - OpenAPI
info.versionstays in sync withapi/package.json(locked by the contracts workflow). sunset_dateが設定された View はその日以降停止。クライアントは次回起動時にアップデート導線を提示する。- Views carrying
sunset_datestop being served after that date. Clients should surface an update prompt on next launch.