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.

SSoT: SSoT: envelope の正規定義は api/src/lib/view/ (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

2. ViewEnvelope の構造 2. ViewEnvelope structure

全 View endpoint は以下の 7 ブロック構造を返します。Action endpoint は metamutation_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
}

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" }
]

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
}

サーバー側は 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_BOOTSplashSplashshow_error0
FALLBACK_FEED一覧 / mapList / mapshow_cached60
FALLBACK_DETAIL詳細画面Detail screensshow_cached300
FALLBACK_PERSONAL個人データ(profile / premium)Personal data (profile / premium)show_cached30

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.tsidempotent: 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_versionview_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.

将来(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"

どの挙動を取るかは 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

6. やってはいけないこと 6. What not to do

envelope を無視して独自描画する罠: Pitfall — bypassing the envelope contract:
  • 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_behavior on 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_version check — 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
contract を超えて勝手に通信する罠: Pitfall — going outside the contract:
  • 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 data already 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_DETAILhighlighted_fields300s cache, FALLBACK_DETAIL, highlighted_fields
mobile-boot api/src/bff/mobile/legacy/boot.ts FALLBACK_BOOTui_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

8. 関連ドキュメント 8. Related documents