ViewEnvelope ガイド(SDUI Level 3) ViewEnvelope guide (SDUI Level 3)
Parky の Mobile BFF が 全 View / Action endpoint で返す共通 JSON 構造(ViewEnvelope) の業者向け解説です。 SDUI Level 3 という設計レベルの位置付け、各セクションの役割、ETag / version の意味、 Flutter 側の実装パターン、よくある罠を実例とともに整理しています。
A vendor-oriented guide to ViewEnvelope — the shared JSON shape returned by every Mobile BFF View/Action endpoint. Covers the SDUI Level 3 design, the role of each section, the meaning of ETag and version fields, Flutter-side implementation patterns, and common pitfalls — with concrete examples.
navigation.ts / ui-config.ts / validation.ts / states.ts / fallback.ts / meta.ts / action.ts)。
Flutter 側の receiver は prototype/flutter/lib/core/bff/view_envelope.dart。
The canonical envelope definition lives in api/src/lib/view/
(navigation.ts / ui-config.ts / validation.ts / states.ts / fallback.ts / meta.ts / action.ts).
The Flutter-side receiver is prototype/flutter/lib/core/bff/view_envelope.dart.
1. SDUI Level とは 1. What is SDUI Level?
Server-Driven UI(SDUI)は「UI の何を / どこまで サーバーが駆動するか」のスペクトルです。 Parky は Level 3 で固定運用 しており、これより下も上も採用しません。
Server-Driven UI (SDUI) is a spectrum: how much of the UI does the server drive? Parky locks in at Level 3 and does not adopt levels below or above.
| LevelLevel | サーバーが返すものWhat the server returns | クライアントの責務Client responsibility | Parky 採用?Adopted by Parky? |
|---|---|---|---|
| L0 | 生 entity の RESTRaw entities via REST | UI / 遷移 / バリデーション 全て持つOwns UI, navigation, validation | — |
| L1 | 画面別 ViewModelPer-screen view models | UI / 遷移 / バリデーションUI, navigation, validation | — |
| L2 | ViewModel + 軽い UI hintView model + light UI hints | UI / 遷移 / バリデーションUI, navigation, validation | — |
| L3 | data + UI 駆動メタ情報(navigation / validation / states / fallback / meta)data + UI-driving metadata (navigation / validation / states / fallback / meta) | Widget ツリーは Flutter 側、振る舞い契約はサーバー側Widget tree on Flutter, behavior contract on server | ✅ |
| L4 | UI コンポーネントツリー(JSON DSL)UI component tree (JSON DSL) | DSL を解釈してネイティブ Widget に変換Interpret the DSL into native widgets | — |
| L5 | UI + ロジック(JSX 相当)UI plus logic (JSX-equivalent) | サーバー定義のロジックも実行Execute server-defined logic too | — |
なぜ L3 か Why L3
- L0〜L2 は弱い: A/B テスト・段階的廃止・experiment 切替・error 振る舞い変更を全部 Flutter リリース待ちにする必要があり、リリース前期間でも判断速度が落ちる
- L0–L2 are too weak: A/B tests, deprecation, experiment toggles, error-behavior changes all wait on Flutter release cadence — slowing decisions even pre-launch
- L4〜L5 は重い: DSL を作って維持するコストが Flutter 1 名体制では割に合わない。レイアウト/インタラクションの自由度も DSL で削れてしまう
- L4–L5 are too heavy: building and maintaining a DSL isn't worth it for a 1-Flutter-dev org, and the DSL itself caps layout/interaction freedom
- L3 は中庸: Widget ツリーは Flutter で自由に書ける一方、A/B / experiment / error 規約 / バリデーション / 遷移 / 廃止予告はサーバーから動かせる
- L3 is the sweet spot: free to write any widget tree in Flutter, while server still drives A/B, experiments, error contract, validation, navigation, deprecation
2. ViewEnvelope の構造 2. ViewEnvelope structure
全 View endpoint は以下の 7 ブロック構造を返します。Action endpoint は meta が
mutation_id を含む ActionMeta に拡張され、navigation が必須になる以外は同じ構造です。
Every View endpoint returns the 7-block structure below. Action endpoints use the same shape but with
meta extended to ActionMeta (adds mutation_id) and navigation required.
2.1 全体構造(TypeScript 型) 2.1 Overall shape (TypeScript)
// api/src/lib/view/meta.ts
export interface ViewEnvelope<D> {
data: D; // 画面固有の payload
ui_config?: UiConfig; // server-side UI override
navigation?: NavigationHint; // 遷移指示
validation?: ValidationRule[]; // フォーム field rule
states?: States; // error / empty / skeleton
fallback_behavior: FallbackBehavior; // 通信エラー / auth / version の規約 (必須)
meta: ViewMeta; // server_time / cache_key / min_app_version / view_spec_ref ...
}
2.2 JSON 例(mobile-home-feed) 2.2 JSON example (mobile-home-feed)
GET /v1/mobile/views/home-feed の実装は api/src/bff/mobile/legacy/home-feed.ts
(routes-manifest 上で stability: 'deprecated', sunsetDate: '2026-07-31'。後継 view への分割を予定)。
サンプルレスポンスは以下のような形になります。
Implementation: api/src/bff/mobile/legacy/home-feed.ts
(marked stability: 'deprecated' with sunsetDate: '2026-07-31' in routes-manifest; will be split into follow-up views).
A sample response looks like this:
{
"data": {
"lots": [
{ "id": "5fe4...", "name": "○○タワー駐車場", "lat": 35.6586, "lng": 139.7454,
"distance_m": 120, "status": "available" }
],
"sponsors": [
{ "id": "ad9c...", "name": "△△カフェ", "lat": 35.6588, "lng": 139.7460, "distance_m": 95 }
],
"active_session": null,
"banners": [
{ "id": "b1a2...", "slot_key": "home_top", "title": "プレミアム 7 日無料",
"subtitle": null, "image_url": "https://r2.../banner.png",
"link_type": "deep", "link_url": "parky://premium", "display_order": 0 }
],
"gamification": {
"slot_type": "tutorial_mission",
"missions": [{ "badge_id": "...", "name": "初回駐車", "icon": "park",
"description": "...", "progress_percent": 0, "threshold": 1 }],
"earned_count": 0, "total_tutorial_count": 5
}
},
"ui_config": {
"experiment_id": "membership_arm_b",
"highlighted_fields": ["pricing"]
},
"states": {
"skeleton": { "layout": "map" },
"error": [
{ "code": "unauthorized", "message_code": "common.error.unauthorized",
"retryable": false, "fallback": "block" },
{ "code": "service_unavailable", "message_code": "common.error.unavailable",
"retryable": true, "fallback": "cached" },
{ "code": "internal_error", "message_code": "common.error.internal",
"retryable": true, "fallback": "cached" },
{ "code": "timeout", "message_code": "common.error.timeout",
"retryable": true, "fallback": "cached" },
{ "code": "bad_request", "message_code": "common.error.bad_request",
"retryable": false, "fallback": "block" },
{ "code": "network_unreachable", "message_code": "common.error.network_unreachable",
"retryable": true, "fallback": "cached" }
]
},
"fallback_behavior": {
"on_network_error": "show_cached",
"on_auth_error": "redirect_login",
"on_version_mismatch": "degrade",
"cache_ttl_seconds": 60
},
"meta": {
"server_time": "2026-04-30T10:00:00.000Z",
"cache_key": "home.banners_a1b2c3d4.codes_def.feed_ef01.i18n_ghi.ui_jkl",
"min_app_version": "1.0.0",
"sunset_date": null,
"expected_ui_version": "ui_2026_04_26_1547",
"view_spec_ref": "home_feed_v1"
}
}
3. 各セクション解説 3. Section-by-section reference
3.1 data
3.1 data
画面固有のドメインデータ。形は endpoint ごとに違い、api/src/schema/view/ 配下の Zod schema が SSoT。
Flutter 側は ViewEnvelope<T>.fromJson(json, T.fromJson) で T の decoder を渡して受け取ります。
Screen-specific domain data. Shape varies per endpoint; SSoT is the Zod schema under api/src/schema/view/.
Flutter receives it via ViewEnvelope<T>.fromJson(json, T.fromJson), supplying a decoder for T.
3.2 navigation — 遷移制御
3.2 navigation — transition control
{
"target": "lot_detail", // 画面識別子("none" で「現画面留まる」)
"params": { "id": "uuid..." }, // target に渡すパラメタ
"strategy": "push" // push / replace / pop_to_root
}
- View envelope: optional。指定があれば「読み込み完了後に遷移」
- View envelope: optional. If present, "navigate after load completes"
- Action envelope: 必須。Action 成功後の画面遷移を駆動
- Action envelope: required. Drives the post-action transition
- "refresh current screen" は
{ target: "none", strategy: "replace" }(NAVIGATION_REFRESH_CURRENT定数) - "Refresh the current screen" is
{ target: "none", strategy: "replace" }(theNAVIGATION_REFRESH_CURRENTconstant)
3.3 ui_config — UI パラメータ
3.3 ui_config — UI parameters
{
"messages": { "home.title": "今日のおすすめ" }, // i18n の緊急 override(A/B 用)
"feature_flags": { "show_map_clusters": true },
"experiment_id": "membership_arm_b",
"theme_hint": "auto",
"highlighted_fields": ["pricing", "roof"] // 検索条件にマッチした項目をハイライト
}
サーバー側で A/B テストや experiment 切り替えを動かすための層。
highlighted_fields は検索条件(料金・屋根あり 等)に合致したフィールドを Flutter 側でハイライト表示するためのヒント。
The layer the server uses to drive A/B tests and experiment toggles.
highlighted_fields tells Flutter which fields to highlight (e.g. pricing, roofed) when they match the user's search criteria.
3.4 validation — フォーム field rule
3.4 validation — form field rules
[
{ "field": "email", "rule": "required", "message_code": "common.error.required" },
{ "field": "email", "rule": "regex", "param": "^[^@]+@[^@]+$",
"message_code": "auth.error.invalid_email" },
{ "field": "password", "rule": "min_length", "param": 8,
"message_code": "auth.error.password_too_short" }
]
- rule の種類:
required/min_length/max_length/regex/numeric_range - Rule kinds:
required,min_length,max_length,regex,numeric_range - Flutter は rule をローカル先行チェックに使い、
message_codeを i18n 辞書で resolve - Flutter runs these rules locally first, then resolves
message_codethrough the i18n dictionary - サーバー側でも同じ rule を二重に検査するので、client は UX 改善目的の先行検査だけ
- The server validates the same rules again — the client check is only there for UX speed
3.5 states — 画面状態
3.5 states — screen states
3.5.1 states.skeleton
3.5.1 states.skeleton
{ "layout": "map", "item_count": 5 } // layout: list / detail / map / grid / splash
loading 中に表示する shimmer skeleton のレイアウトヒント。item_count は list/grid 時の件数。
Layout hint for the shimmer skeleton shown while loading. item_count applies to list/grid layouts.
3.5.2 states.empty
3.5.2 states.empty
{
"illustration": "empty_search",
"title_code": "search.empty.title",
"body_code": "search.empty.body",
"cta": { "label_code": "search.empty.cta", "action": "open_filters" }
}
0 件結果時の empty view。CTA は null 可能。illustration は asset key(実 URL ではない)。
Empty view for zero results. CTA can be null. illustration is an asset key, not a URL.
3.5.3 states.error[]
3.5.3 states.error[]
この endpoint で 起こり得るエラー種別を事前に列挙 する層。 現在エラーが起きているわけではなく、Flutter 側が「もしこの code が来たらこう振る舞う」を準備するための定義です。
Up-front enumeration of error kinds this endpoint may emit. It's not a "current error" — it's the contract Flutter uses to prepare per-code behavior.
{
"code": "unauthorized",
"message_code": "common.error.unauthorized",
"retryable": false, // 自動 retry 可否
"fallback": "block" // cached / empty / block
}
fallback: "cached"— 最後の cache を表示しつつ retry。一時障害(5xx / timeout)で使用fallback: "cached"— show last cache and retry. For transient failures (5xx / timeout)fallback: "empty"— 空 view を表示。404 系で使用fallback: "empty"— show empty view. For 404-style casesfallback: "block"— エラー画面で操作 block。auth / 入力エラー / upgrade 必要 等で使用fallback: "block"— show error screen and block interaction. For auth / input / upgrade-required cases
サーバー側は buildErrorStatesFromCodes([...COMMON_ERROR_CODES, "bad_request"]) のように
ERROR_CATALOG から自動生成。NETWORK_UNREACHABLE_STATE は HTTP より前に検知される擬似 code として extra で追加。
Server-side, this is generated from ERROR_CATALOG via buildErrorStatesFromCodes([...COMMON_ERROR_CODES, "bad_request"]).
NETWORK_UNREACHABLE_STATE is a synthetic code (detected before HTTP returns) added as extra.
3.6 fallback_behavior — エラー時の規約
3.6 fallback_behavior — failure-mode contract
{
"on_network_error": "show_cached", // show_cached / show_empty / show_error
"on_auth_error": "redirect_login",// redirect_login / show_error
"on_version_mismatch": "degrade", // force_update / degrade / ignore
"cache_ttl_seconds": 60 // 0 = cache しない
}
画面性格別にプリセットがあり、route 側で選ぶ運用です。
There are presets per screen kind; the route picks one.
| プリセットPreset | 用途Used for | on_network_error |
cache_ttl_seconds |
|---|---|---|---|
FALLBACK_BOOT | SplashSplash | show_error | 0 |
FALLBACK_FEED | 一覧 / mapList / map | show_cached | 60 |
FALLBACK_DETAIL | 詳細画面Detail screens | show_cached | 300 |
FALLBACK_PERSONAL | 個人データ(profile / premium)Personal data (profile / premium) | show_cached | 30 |
3.7 meta — 画面メタ情報
3.7 meta — envelope metadata
{
"server_time": "2026-04-30T10:00:00.000Z",
"cache_key": "home.banners_a1b2c3d4.codes_def.feed_ef01.i18n_ghi.ui_jkl",
"min_app_version": "1.0.0",
"sunset_date": null,
"expected_ui_version": "ui_2026_04_26_1547",
"view_spec_ref": "home_feed_v1",
"realtime": { // optional, mobile では原則使わない
"channel": "...",
"event_types": ["INSERT"],
"fallback_poll_seconds": 0
}
}
3.8 Action envelope の追加要素 3.8 Additional Action-envelope fields
// ActionMeta = ViewMeta + mutation_id
{
"server_time": "...",
"cache_key": null,
"min_app_version": "1.0.0",
"sunset_date": null,
"mutation_id": "9a3f5d10-..." // サーバー採番。client は Idempotency-Key と突合可
}
Action では navigation が必須。冪等性は routes-manifest.ts の idempotent: true + Idempotency-Key middleware で保証され、
mutation_id はクライアント向け ack(toast 表示・重複検知)として使います。
For Actions, navigation is required. Idempotency is enforced by idempotent: true in routes-manifest.ts
plus the Idempotency-Key middleware; mutation_id is a client-facing ack used for toast display / duplicate detection.
4. ETag / version の意味 4. ETag / version semantics
4.1 composite ETag 4.1 Composite ETag
Parky の主要 endpoint は composite ETag を採用しています。 単一の row hash ではなく、関連する複数の鮮度トークンを join した形:
Major Parky endpoints use a composite ETag — not a single row hash but a join of multiple freshness tokens:
"home.banners_a1b2c3d4.codes_def.feed_ef01.i18n_ghi.ui_jkl"
↑ ↑ ↑ ↑ ↑ ↑
prefix banners snapshot codes ver feed hash i18n ver ui_layer ver
client は前回の ETag を If-None-Match で送り、サーバ側 ETag と一致すれば 304 Not Modified を返します。
いずれか 1 つの token がズレれば 200 で新 envelope を返す。
The client sends the previous ETag in If-None-Match; if it matches the server's ETag, the server returns 304 Not Modified.
If any single token shifts, the server returns 200 with a fresh envelope.
4.2 expected_ui_version と view_spec_ref
4.2 expected_ui_version and view_spec_ref
SDUI cache layering: /boot 集約 endpoint が ui_layer catalog(ui_version, codes, i18n, view_specs)を返し、
各 view レスポンスは meta.expected_ui_version でその catalog 版数を相乗りします。
SDUI cache layering: a /boot aggregator returns the ui_layer catalog (ui_version, codes, i18n, view_specs),
and every view response carries that version through meta.expected_ui_version.
- client cache の
ui_versionと一致 → そのまま使う - If the client's cached
ui_versionmatches → use cache as-is - 不一致 → background で
/bootを再 fetch(BootRevalidator)。表示は止めない - If mismatch → refetch
/bootin background (BootRevalidator); rendering does not block view_spec_refは ui_layer 内のview_specs[id]への key。Flutter は spec を cache から resolve して validation/states/fallback_behavior を取得view_spec_refkeys intoview_specs[id]within the catalog. Flutter resolves the spec from cache to recover validation/states/fallback_behavior
将来(P4 以降)envelope の validation / states / fallback_behavior は client cache から resolve するため
envelope payload を slim 化する計画です(shouldSlimEnvelope 参照)。
From P4 onward, the plan is to slim the envelope by resolving validation / states / fallback_behavior
from the client cache (see shouldSlimEnvelope).
4.3 min_app_version による version mismatch 判定
4.3 Version-mismatch decisions via min_app_version
// api/src/lib/view/meta.ts
checkMinAppVersion(clientVersion, minRequired) → "ok" | "degraded" | "force_update"
- client >= required →
ok - client >= required →
ok - client < required かつ 1 major 以内 →
degraded(警告のみ、画面は出す) - client < required within 1 major →
degraded(warn only, still render) - 2 major 以上遅れ →
force_update(強制アップデート画面) - 2 majors behind or more →
force_update(force-update screen)
どの挙動を取るかは fallback_behavior.on_version_mismatch がさらに上書きします(force_update / degrade / ignore)。
The actual behavior is further overridden by fallback_behavior.on_version_mismatch (force_update / degrade / ignore).
5. Flutter 側の実装パターン 5. Flutter implementation pattern
5.1 envelope → Widget の流れ 5.1 envelope → Widget flow
sequenceDiagram participant W as Widget participant C as Controller (Riverpod AsyncNotifier) participant R as Repository participant B as ParkyBffClient (dio) participant API as Workers BFF W->>C: ref.watch(homeFeedProvider) C->>R: fetchHomeFeed(lat, lng) R->>B: getHomeFeedApi.v1MobileViewsHomeFeedGet(...) B->>API: GET /v1/mobile/views/home-feed (If-None-Match: prev ETag, X-App-Version) API-->>B: 200 / 304 (with composite ETag) B-->>R: dio Response R-->>R: ViewEnvelope.fromJson(json, HomeFeedData.fromJson) R-->>C: AsyncValue > C-->>W: render data + states + nav W->>W: NavigationResolver.resolve(envelope.navigation)
5.2 受信コード(Dart) 5.2 Receiving code (Dart)
// prototype/flutter/lib/core/bff/view_envelope.dart
final envelope = ViewEnvelope<HomeFeedData>.fromJson(
json,
(raw) => HomeFeedData.fromJson(raw as Map<String, dynamic>),
);
// data 描画
return HomeMapScreen(data: envelope.data);
// navigation
final nav = envelope.navigation;
if (nav != null && nav.target != 'none') {
context.read(navigationResolverProvider).resolve(nav);
}
// version mismatch / force update
final minVer = envelope.meta?.minAppVersion;
if (minVer != null && isOlder(currentAppVersion, minVer)) {
// fallback_behavior.on_version_mismatch を見て判断
}
5.3 既製 helper 5.3 Built-in helpers
core/bff/bff_client.dart— Authorization header 自動付与・ETag 管理core/bff/bff_client.dart— auto-injects Authorization header, manages ETagcore/bff/envelope_consumer.dart— envelope を受け取り states/error_toast を統一処理core/bff/envelope_consumer.dart— common handler for states / error toastcore/bff/navigation_resolver.dart—NavigationHintをルーターに変換core/bff/navigation_resolver.dart— convertsNavigationHintinto router actionscore/bff/envelope_inspector.dart— debug 時の envelope 可視化core/bff/envelope_inspector.dart— envelope visualization for debuggingcore/sdui/boot_revalidator.dart—expected_ui_versionズレ時に/boot再 fetchcore/sdui/boot_revalidator.dart— refetches/bootwhenexpected_ui_versiondriftscore/sdui/ui_layer_cache.dart—ui_layercatalog の永続 cachecore/sdui/ui_layer_cache.dart— persistent cache for theui_layercatalog
6. やってはいけないこと 6. What not to do
states.errorを見ずに「全エラーを 1 個の toast」で済ます → A/B / experiment / 段階的廃止が動かなくなる- Lumping all errors into one toast without inspecting
states.error— kills A/B / experiments / staged deprecation navigation.targetを見ずに「toggle-save 後は必ず lot_detail に戻る」とハードコード → サーバー側で Action 後の遷移を切替えられなくなる- Hard-coding "after toggle-save, always pop back to lot_detail" instead of reading
navigation.target— locks server out of post-action navigation control fallback_behaviorを Flutter 側で書き換えて「ネットワークエラーは常に空表示」にする → endpoint 性格別の方針(feed=cached, boot=error 等)を骨抜き- Overwriting
fallback_behavioron the Flutter side ("always show empty on network error") — neutralizes the per-screen contract (feed=cached, boot=error, etc.) min_app_version検査をスキップ → リリース後のサーバー側 schema 進化で旧 client が壊れる- Skipping the
min_app_versioncheck — server-side schema evolution post-launch will break old clients - composite ETag を保存・送信せず毎回 200 を取りに行く → 不要な転送・KV ヒット率低下
- Failing to persist and send the composite ETag — wastes bandwidth and tanks the KV cache hit rate
- BFF にない RPC を Supabase 直叩きで実装 → ESLint + CI grep gate で block されるが、回避方法を探さないこと
- Implementing an RPC the BFF lacks via direct Supabase calls — blocked by ESLint + CI grep gate; do not look for a workaround
Action成功後にサーバー無視で再 fetch → ActionEnvelope のdataをそのまま使えば済む話- Refetching after a successful Action while ignoring the server — use the
dataalready returned in the ActionEnvelope
7. 参考実装(envelope の典型例) 7. Reference implementations (envelope examples)
| endpointEndpoint | ファイルFile | 特徴Notable feature |
|---|---|---|
| mobile-home-feed | api/src/bff/mobile/legacy/home-feed.ts | composite ETag、KV 60s SWR、empty-state、map skeletonComposite ETag, 60s KV SWR, empty-state, map skeleton |
| mobile-lot-detail | api/src/bff/mobile/legacy/lot-detail.ts | 300s cache、FALLBACK_DETAIL、highlighted_fields300s cache, FALLBACK_DETAIL, highlighted_fields |
| mobile-boot | api/src/bff/mobile/legacy/boot.ts | FALLBACK_BOOT、ui_layer catalog 配信FALLBACK_BOOT, ships the ui_layer catalog |
| mobile-premium-view | api/src/bff/mobile/legacy/premium-view.ts | FALLBACK_PERSONAL、auth 必須FALLBACK_PERSONAL, auth required |
| mobile-profile | api/src/bff/mobile/views/profile.ts | FALLBACK_PERSONAL、validation 入りFALLBACK_PERSONAL, validation included |