コンテンツにスキップ

GraphQL fragment 戦略(list / detail の責務分離)

packages/sdk/shopapps/storefront の GraphQL fragment を「list 用 / detail 用」で分離するための運用ルール。

背景

一覧(list)と詳細(detail)で同じ fat fragment を使い回すと、

  • list で 1 ページ分(最大 50 件など)× 各 Order × 各 OrderLine の deep nest(productVariant.product.{assets[], facetValues[], collections[], description, customFields, consentConfig}bundleItemspurchaseLimitRuleStatespayments[].metadatafulfillmentstaxSummarypromotionscustomer.addresses 等)が一括で返り、JSON が 1〜数 MB 単位になる。
  • 受信後の deserialize / React 描画コストも増え、体感速度が悪化する。

これは「画面ごと」の問題ではなく、「fragment 設計レイヤ」の欠落として扱う。

ルール

  1. list 用と detail 用を別 fragment にする。命名規則:
  2. 詳細: Order, Product, Customer 等の素の名前。
  3. 一覧: OrderListItem, ProductListItem のように ListItem サフィックスを付ける。
  4. list 用 fragment では productVariant.product.* の deep nest を避ける。最低限の id / name / slug だけ取得し、画像は OrderLine 側の featuredAsset { preview, source }(Vendure native)から取る。
  5. 画像 / 商品名 / SKU / slug は order line snapshot を正本にするapps/storefront/src/lib/order-line-snapshot.ts の helper を参照)。snapshot が無い場合のみ live productVariant にフォールバックする。
  6. list クエリではサーバ側 filter / pagination を使う。クライアント側で「最初に全件取って絞り込む」運用は、take を超えるユーザでサイレント欠落の原因になる。例: 期間フィルタは OrderListOptions.filter.createdAt: { after } に乗せる。
  7. list 用に詳細を流用したくなったら、まず list 用 fragment を新設してから流用先を切り替える。逆方向(list 用を detail に流用)は OK(detail のクエリで補強する)。

実例

  • packages/sdk/shop/src/fragments/common.graphql
  • OrderListItem / OrderLineListItem — 一覧 (GetCustomerOrders) 専用。
  • Order / OrderLine — 詳細 (GetOrderDetail, GetOrderByCode) 用。
  • apps/storefront/src/components/account/account-order-list.tsx — 一覧で OrderListItemFragment を使う。
  • apps/storefront/src/components/pages/order-detail-page-content.tsx — 詳細で OrderFragment を使う。relatedOrders(list 由来)は OrderListItem の subset として受け入れる。

list クエリの take 上限(2 段 + clamp + 静的検査)

shop-api の standard list queryListQueryBuilder 経由。collections(options: { take }), facets(options: {...}), products 等)は take が Vendure の shopListQueryLimit を超えると runtime で初めて error.list-query-limit-exceeded (USER_INPUT_ERROR) になる。 runtime まで発覚しないのを避けるため、上限を SSOT 定数で管理し、静的検査と clamp の両方で守る。

SSOT 定数(@ritsubi/config

  • SHOP_LIST_QUERY_LIMIT (= 200): サーバ上限。Vendure apiOptions.shopListQueryLimit に設定する(apps/vendure-server/src/config/api-options.ts)。
  • SHOP_LIST_SAFE_TAKE (= 100): storefront が list query で要求してよい運用上限。
  • clampShopListTake(n): 動的に算出した take を SHOP_LIST_SAFE_TAKE 以内へ丸めるヘルパー。

サーバ上限 (200) > クライアント上限 (100) の 2 段構成。想定外の take でも buffer 内なら 500 を出さず graceful degradation する余裕を持たせている。

2 つの上限を同時に満たす

list query の take は次の 2 つを 両方満たす必要がある。片方だけ見ると今回のように事故る。

  1. HardenPlugin の complexity 上限。production は maxQueryComplexity=2500 の strict 運用を正本にする。複雑度は childComplexity + round(log(childComplexity) * take)。take 省略時は default 1000 が適用され超過するため明示必須。
  2. shopListQueryLimit(上記サーバ上限)。

bug クラス別の防御

  • リテラル take(例: collections(options: { take: 400 }))→ pre-runtime の静的検査で捕捉。 apps/storefront/src/lib/shop-list-take-contract.test.tsoptions: { take: N } の N が SHOP_LIST_SAFE_TAKE を超えたら test で落とす。query には literal を直書きせず ${SHOP_LIST_SAFE_TAKE} を参照する。
  • 動的 take(実行時計算で variable 経由)→ 静的解析では原理的に捕捉できないため clampShopListTake を呼び口に通す。 例: visibility-shop.resolver.ts は client 供給の input.takeSHOP_LIST_QUERY_LIMIT で頭打ちにする。
  • Storefront 内の raw GraphQL querygraphql-codegen 管理外の template string)→ scripts/ops/storefront-shop-api-operation-registry.mjs への登録を必須にし、registry 駆動の static guard と live preflight で捕捉する。just storefront-graphql-complexity-preflight stagingpreflight: true の query を対象 Vendure Shop API へ送信し、HardenPlugin の Query is too complex と schema mismatch (Cannot query field 等) を deploy 前に fail させる。

Storefront Shop API operation registry

Storefront から Vendure Shop API へ投げる raw GraphQL operationscripts/ops/storefront-shop-api-operation-registry.mjs に登録する。これは HardenPlugin 複雑度事故を「新しい query を追加したが preflight 対象に入れ忘れた」という形で再発させないための境界。

登録には次を必須にする。

  • operationName 相当の label
  • kind: list | detail | mutation
  • fileoperationConst
  • fixtureVariables
  • allowedFragments
  • preflight(mutation は false)

pnpm run lint:storefront-shop-graphql-boundary は次を fail させる。

  • registry 未登録の raw Shop API GraphQL operation
  • vendureFetch / vendureQuery への inline raw query 渡し
  • list operation の detail fragment spread (...Product / ...ProductVariant)
  • children { children { ... } } の nested collection tree
  • activeCustomer.orders -> lines -> productVariant -> product を 1 query で取る形

Storefront GraphQL complexity preflight

Storefront の Cloudflare deploy (just cloudflare-deploy-storefront <env>, just deploy-storefront-staging) は、 build / upload 前に次を自動実行する。

just storefront-graphql-complexity-preflight staging

この check は 実環境の Vendure Shop API を使う。HardenPlugin の complexity 計算は schema / field resolver 側の 定義に依存するため、AST の静的検査だけでは正確に再現しない。新しい raw query を追加した場合は、次をセットで行う。

  1. list / home / browse / cart recommendation など、複数件を返す query は詳細用 fragment を流用しない。
  2. scripts/ops/storefront-shop-api-operation-registry.mjs に query と fixtureVariables を追加する。 preflight: true の operation は自動的に live preflight 対象になる。
  3. node --test scripts/ops/storefront-graphql-complexity-preflight.test.mjsjust storefront-graphql-complexity-preflight staging を通す。

2026-06 の home incident では、activeCustomer.orders -> lines -> productVariant -> product を 1 query で取ったため、 take:1 でも HardenPlugin に拒否された。再購入リストは「注文履歴から variant id だけ取る query」と 「productVariantsWithVisibility で商品カード情報を取る query」の 2 段に分ける。

対象外

  • SearchInput.takevisibleProductsForBrowse 等の custom search resolver)は DefaultSearchPlugin の search 経路で ListQueryBuilder を介さず shopListQueryLimit の対象外。visibility browse の内部 overfetch (PRODUCTS_BROWSE_MAX_BATCH_SIZE=400) は意図的な先読みなので clamp しない。静的検査も options: { take } 形に限定し SearchInput.take を誤検知しない。

codegen

  • packages/sdk/shop の SDK は pnpm run codegen:shared で再生成。
  • apps/storefront__generated__/*apps/storefront/codegen.tspnpm exec nx run ritsubi-storefront:codegen で再生成。SSOT 編集後は両方を回す。

横展開チェックリスト

list クエリ全てに対して以下を確認:

  • GetCustomerOrdersOrderListItem 適用済み
  • GetActiveOrderContext(カート)→ 適用候補(別 PR)
  • GetFavoritesProductListItem 化候補(別 PR)
  • GetRecentlyViewedProducts → 同上
  • GetHomeContent → 同上