cloudflare-backend について
cloudflare-backend は、Cloudflare Workers 上の TanStack Start アプリでバックエンドを構築・変更するためのコーディングスキルです。既存構成に沿って、アーキテクチャ、依存関係の構成、エントリーポイント、エラー処理を案内します。
主な機能
- 01ドメイン、アプリケーション、インフラ層の定義
- 02エントリーポイントを境界処理に限定
- 03app-deps.ts で依存関係を構成
- 04アプリケーションハンドラーでユースケースを調整
- 05トランスポート契約とエラーの処理
- 06システム境界をまたぐ処理フローの追跡
活用シーン
- 01新しいバックエンド機能の計画
- 02エントリーポイントやハンドラーのレビュー
- 03Worker 呼び出し向けの依存関係構成
- 04サーバー関数のエラー動作の変更
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ alexander-zuev/ agent-skills --skill cloudflare-backendインストール後、Claude Code で/cloudflare-backendを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/database-workflow.mdentrypoints-and-application.mderror-paths.mdmeasure-worker.mdoperations.mdsecurity.mdtesting.mdtransport-contracts.mdworkers-kv.mdscripts/measure-row.sh詳細
翻訳後に原文が更新されています。最新の内容は原文をご確認ください。
バックエンドアーキテクチャ — Cloudflare Workers でクリーンなバックエンドを構築
プロジェクトでは、依存関係の明確な方向性、明示的な境界、テスト可能性、単一のコンポジションルートを維持しますが、DDD やヘキサゴナルアーキテクチャを採用する場合もあります。既存のアーキテクチャを検出して踏襲し、ポートとアダプターのコードに DDD 用語を無理に当てはめないでください。[detect: …] と記されたセクションでは、編集前に確認すべき根拠を示しています。参照コードはリポジトリ内での新旧ではなく、その契約と責務に基づいて選んでください。既存の RPC エンベロープは互換性のためのケースであり、単純な値を返すサーバー関数のデフォルトではありません。
Effect が使われている場合、Context.Tag サービスはポート、Layer はアダプターのコンストラクターおよび依存関係の構成、Effect は型付けされたプログラムとして扱ってください。純粋なドメインロジックは純粋なままにし、コンポジションルートで Layer を提供し、エントリーポイント/ランタイムの境界でのみ Effect を実行します。隠れたグローバルランタイムを作成したり、エラーログを重複させたりしないでください。
バックエンドは TanStack Start アプリ内の apps/<app>/src/server/ に配置します。別個の Worker アプリは作成しません。
レイヤー構成
- Domain — エンティティ、値オブジェクト、純粋なビジネスロジック。他のレイヤーへの依存はなく、フレームワークに依存しません。
- Application — ユースケースとハンドラー。ドメインモデルとアウトバウンドポートを調整します。Domain と、Application が所有するポート契約に依存します。
- Infrastructure — DB、外部 API、キュー、ストレージ、サブプロセスのアダプター。ユースケースの処理順序は所有せず、アウトバウンドポートを実装します。
- Entrypoints — システム境界を越える外部入力または制御を受け取り、Application の呼び出しに変換します。
server/
├── entrypoints/ # functions/ (server fns), queue/, scheduled/, middleware/ — transport only
├── application/ # message bus, handlers, registry
├── domain/ # messages/, models/, services/, errors/ — pure, framework-free
└── infrastructure/ # app-deps.ts, persistence/, auth/, resilience/, external clients
依存関係は内側に向かいます。Domain は、永続化スキーマの Db* 行型も含め、他のレイヤーから何もインポートしません。
標準ではありますが、まだ完全には実践されていません。リポジトリーインターフェースは domain に配置します(古いコードでは
infrastructure/persistence/repositories/interfaces.tsに置かれています)。新しいコードには適用しますが、求められていない既存ファイルの大幅な変更は避けてください。
システムのエントリーポイントを特定する
エントリーポイントとは、定義されたシステム境界を越えて外部入力または制御が入る場所です。
境界、外部の呼び出し元またはランタイム、受け取り側の操作を明示してください。 呼び出し契約からエントリーポイントを導き出し、コードを使って特定してください。
例:Worker の fetch → API ルート/サーバー関数、scheduled、queue。Durable Object の RPC、fetch、alarm。Workflow の run と外部イベント。ローカル CLI の入力。
Webhook には API ルートを使用します。 内部 SDK コールバックと Workflow のステップコールバックは、別のシステムエントリーポイントにはなりません。
依存関係を初期化する
コードエントリーポイントは、server.ts やクラスコンストラクターのように、システムを初期化または登録します。
運用エントリーポイントは、外部からの呼び出しを受け取ります。
依存関係は、そのライフタイムを所有するスコープ(呼び出し、インスタンス、またはトランザクション)で構築します。 コンテキストまたは明示的な引数を介して渡してください。
たとえば、src/server.ts は各 HTTP、キュー、スケジュール実行の呼び出しに対して createAppDeps(env, ctx) を呼び出します。
infrastructure/app-deps.ts は依存関係グラフを所有します。
構築と Application のポリシーは分離してください。 内側のサービスは依存関係を受け取り、プロバイダーの構築やランタイムバインディングの取得は行いません。
DI — AppDeps コンポジションルート
アプリケーションの配線は infrastructure/app-deps.ts にまとめ、ブートストラップコードから実行時の入力を渡します。
createAppDeps(env, ctx)はリポジトリ、サービス、ゲートウェイを構築します。サービスはonce()で遅延メモ化されます。- ブートストラップからリクエストコンテキストまたはプロジェクトのコンポジションミドルウェアを通じて
AppDepsを渡します:context.deps.services.messageBus()。 - ハンドラーのシグネチャ:
(message, deps: AppDeps) => Promise<TResult>。 once()でメモ化するサービスは、トランザクションスコープのリポジトリをキャプチャしてはいけません。サービスはあらゆるトランザクションより長く存続します。- 各エントリーは、プロセス外の何かに接続するクライアントです。データベース、キュー、Durable Object、Workflow、外部 API などが該当します。次のように分類します:
services(自前のインターフェースでラップしたクライアント。例:queueClient(): IQueueClient)、clients(SDK の生のクライアント)、dos(Durable Object クライアント)、workflows。インターフェースに生のバインディング型を含めてはいけません。 - ルールは依存関係ではなく、データも依存関係ではありません。 ポリシーオブジェクト(
DestinationPolicy、RateTable、許可リスト、設定に基づく判定など)はドメイン内の純粋関数にします。ハンドラーはreadDbを通じてデータベースから入力を読み取り、その関数を呼び出します。メッセージの種類にちなんだ名前(commands()など)は常に誤りです。機能に基づいて命名してください。
この例では、アプリケーションが値をそのまま返し、既存の例外ベースの契約を使用するものとします。 内部の Result ハンドラーには、「Error Handling」で説明されている確立済みのアダプター変換を使用してください。
// The shared function middleware owns error conversion.
export const createProduct = createServerFn({ method: 'POST' })
.middleware([depsMiddleware, requireAuth])
.validator(createProductSchema)
.handler(async ({ data, context }) => {
const result = await context.deps.services.messageBus().handle(
createCommand('CreateProduct', { ...data, userId: context.user.id }),
)
return result
})
エラー処理
このスキルでは、トランスポート契約、エラー変換、ログ記録の責任、外部リトライを扱います。 内部 Result 型、スキーマ、一般的なモジュール設計には typescript-standards を使用してください。
サーバー関数またはスタブを編集する前に:
- 呼び出し元、共有契約、類似する既存の関数を読みます。
src/start.tsと、登録済みのエラー、認証、CSRF ミドルウェアを読みます。- エラーペイロードのモジュールと、そのミドルウェアで使用されているインストール済みのヘルパーを読みます。
- 成功時の型、失敗の伝達経路、ログ記録の責任者を確認します。
- 仕様、参照コード、現在のプロジェクトの間に重要な違いがあれば明記します。
サーバー関数、HTTP、Durable Object RPC、WebSocket については、トランスポート契約を読みます。 失敗時の動作または外部クライアントを変更する場合は、エラー経路とリトライを読みます。 既存の境界で Result エンベロープを使用している場合に限り、RPC の互換性を読みます。
現在のサーバー関数は値をそのまま返します。共有ミドルウェアがスローされた失敗を一度だけプレーンなタグ付きペイロードに変換します。 内部 Result はアダプターで成功または型付きの例外に変換します。ワイヤーエンベロープは不要です。 グローバルミドルウェアの登録を再利用してください。各関数に別のエラーバウンダリーを追加してはいけません。 既存のミドルウェアを通じて Router のリダイレクトと Not Found の制御フローを維持してください。
スタブには、プロジェクトの未実装ヘルパーを使用し、意図する戻り値の型を宣言してください。 必須の検証と認証を維持してください。成功を偽装したり、依頼された範囲外の本番動作を追加したりしてはいけません。
エントリーポイントとアプリケーションのオーケストレーション
エントリーポイント、アプリケーションハンドラー、ユースケース、サービスアダプターを作成またはレビューするすべてのタスクでは、コードを提案または編集する前に references/entrypoints-and-application.md を読んでください。
役に立つ順序で各レイヤーを調べてください。設計を受け入れる前に、エントリーポイント、アプリケーション、ドメイン、インフラストラクチャの境界をまたぐ完全なフローを追跡してください。
アプリケーションのフローは一目でわかるようにしてください。一般的なハンドラーは、データを読み込み、準備または判定を行い、機能を呼び出し、永続化し、結果を返します。これは構造の例であり、必須の処理数ではありません。1 ステップの読み取りも有効です。深いネスト、繰り返される分岐、またはすべてをひとつの広範なサービスに転送するハンドラーは、責任の所在が不明確である兆候です。
エントリーポイントのルール
運用上のエントリーポイントが担うのは、境界に関する処理だけです。この役割を割り当てる前に、上記の境界テストを適用してください。
- メッセージバスアーキテクチャ: 必要に応じて検証と認可を行い、
Command | Query | Eventを構築または解析し、bus.handle(message)を待機してから、結果を変換します。 - 直接ハンドラーアーキテクチャ: 必要に応じて検証と認可を行い、型付きの入力を構築し、アプリケーションハンドラーを1つだけ待機してから、結果を変換します。
- 各プラットフォーム境界は、それぞれの配信セマンティクスを担います。HTTP レスポンス、キューの確認応答、またはスケジュールされた呼び出しの失敗などです。ワークフローのオーケストレーションは、永続化されるステップの設定を担います。
アプリケーションのルール
アプリケーションハンドラーは、主要なオーケストレーターです。ユースケースのポリシー、計画、順序付け、ポート間の調整、トランザクション境界、クリーンアップや補償処理、有用なエラー動作を担います。
トランスポート変換、フレームワークのコード、プロバイダーのコマンド、SQL、SDK 呼び出し、ドメイン不変条件は担いません。
転送ラッパーのテストを使ってください。ハンドラーを削除しても同じ大まかなサービス呼び出しと結果が残る場合は、そのサービスを調べてください。複数の外部機能を調整している場合は、それらの機能をポートに分割し、処理の順序をハンドラーに移してください。本当に1つのポートを読み取るだけの場合は、処理手順を新たに作らないでください。
ビジネスロジックの配置
| レイヤー | ビジネスロジック? |
|---|---|
| エントリーポイント | ❌ 解析、認可、ディスパッチ、応答 |
| ハンドラー | オーケストレーションポリシーのみ |
| ドメインモデル | ✅ 不変条件、状態遷移 |
| ドメインサービス | ✅ エンティティをまたぐアルゴリズム |
| リポジトリ | ❌ 永続化のみ |
例外(「2つのゲート」): 操作がトランスポートの調整である場合、エントリーポイントは専用のサービスクライアントを直接呼び出せます。クライアントが公開する機能は、限定された1つのものにしてください。エントリーポイント内にユースケースのロジックを記述することは、いかなる場合も認められません。
サーバーのメンタルモデル — すべてのエントリーポイントが同じ役割を持つ
Message bus: Entrypoint → Command | Query | Event → Bus → Handler → Domain + Ports
Direct handler: Entrypoint → Typed input → Handler → Domain + Ports
識別された運用上のエントリーポイントは、アプリケーションにディスパッチし、その外部契約に合わせて結果を変換します。トランスポートは本質的な要素ではありません。
ルール:
- エントリーポイントは薄く保ちます。必要に応じて検証 → 必要に応じて認可 → ディスパッチ → 変換。
- メッセージは純粋なデータです。メソッドは持ちません。
- ハンドラーはオーケストレーションを担います。ハンドラーにビジネスロジックを含めてはいけません。
- ドメインは純粋に保ちます。フレームワークやインフラストラクチャをインポートしません。
- 依存関係は内側に向かいます。逆方向には決して流れません。
- リポジトリパターン:インターフェースはドメインに、実装はインフラストラクチャに配置します。
メッセージ — Command / Event / Query
| 種類 | ハンドラー | 冪等性 | セマンティクス |
|---|---|---|---|
| Command | 1つだけ | id は UoW で取得 | 変更 |
| Event | 0..N 個のサブスクライバー | サブスクライバーごとに取得 | ドメイン上の事実、ファンアウト |
| Query | 1つだけ | なし — id なし | 読み取り、UoW を迂回 |
ベースメッセージの特性:
type: 'command' | 'event' | 'query'の判別子 + リテラルのname+ 任意のuserId(可観測性用)。idはuuid | externalMessageIdです。外部 ID は名前空間付きのプロバイダーキーであり、stripe:event:evt_123のように指定します。これにより、Webhook でプロバイダーキーに基づく冪等性が得られます。ファクトリー(createCommand(...))では、UUID がデフォルトで設定されます。[base.messages.tsを検出]:現在の生成ではz.uuidv7()(時系列順)を使用し、以前の生成ではz.uuidv4()を使用します。- [
detect] メッセージの配置場所:クライアントと共有する場合 →packages/core/src/domain/**、サーバー専用の場合 →apps/<app>/src/server/domain/messages/。 - ペイロードの ID には、ブランド付きスキーマ(
UserIdSchema、ProductIdSchema)を使います。Zod v4 のヘルパー(z.uuid()、z.uuidv7()、z.iso.datetime())も使用します。
export const createProductSchema = baseCommandSchema.extend({
name: z.literal('CreateProduct'),
productName: z.string(),
userId: UserIdSchema,
})
レジストリ
1つのレジストリファイル(application/registry/registry.ts または application/handlers/registry.ts)で名前とハンドラーを結び付けます。satisfies CommandRegistry / QueryRegistry を使うと、コンパイラーが網羅性を保証します。メッセージを追加してもコンパイラーがエラーを出さない場合は、そのメッセージが接続されていません。
export const COMMAND_HANDLERS = {
CreateProduct: createProduct,
} satisfies CommandRegistry
export const EVENT_HANDLERS: EventRegistry = {
ProductCreated: [
{ id: 'project-product-view', mode: 'state', handle: projectProductView },
{ id: 'track-product-created', mode: 'effect', handle: trackProductCreated },
],
}
state サブスクライバーと effect サブスクライバー — 基幹となるディスパッチルール
すべてのイベントサブスクライバーで判定する基準は、DB に書き込みを行うかどうかです。
| 種類 | 実行環境 | 冪等性 | 用途 |
|---|---|---|---|
state | サブスクライバーごとに独立した UoW | イベント ID(+サブスクライバー ID)をキーとするレシート | DB 書き込み:プロジェクション、非正規化 |
effect | トランザクションなし、クレームなし | 冪等性キーを対象システムに委譲 | 分析、メール、外部 API |
state サブスクライバーが失敗しても、ロールバックされるのはそのサブスクライバー自身の UoW だけです。effect サブスクライバーは再実行しても安全でなければなりません。effect を DB クレームで囲んではいけません(クレーム後に effect を実行するとクラッシュ時に effect が失われ、effect の後にクレームを行うと孤児状態になる時間帯が生じます)。両方を行うサブスクライバーは、分割してください。
[detect registry.ts] の配線:新しい世代ではサブスクライバーオブジェクト { id, mode, handle } を登録し、ディスパッチ機構が各 state サブスクライバーの UoW を開始します。古い世代では通常の (event, deps) 関数を登録し、各 state サブスクライバーがイベント ID をキーに自身の UoW を開始します。
メッセージバス
型付きメッセージからハンドラーへの単一の中継点です。バスはルーティングを行いますが、トランザクションは開始しません。イベントサブスクライバーは並列に実行されます(Promise.allSettled)。1 つが失敗しても、ほかのサブスクライバーは中断されません。重複処理については、イベントサブスクライバーからの DuplicateMessageError は成功として握りつぶされ、コマンドからのものは呼び出し元に再スローされます。キューコンシューマーは重複をスキップとして ACK します。
Unit of Work
UoW を開始するのはコマンドハンドラーです(バスではありません):deps.uow.run(messageId, (repos, emit) => …) — トランザクションスコープのリポジトリと、ドメインイベント用の emit を使用します。イベントの state サブスクライバーには、サブスクライバーごとの独立した UoW が割り当てられます(上記の世代ごとの配線に従って開始されます)。1 つのアトミックなスコープで、次の順に処理します。
- レシートをクレーム —
message.idをmessage_receiptsに挿入します。競合した場合はDuplicateMessageErrorを発生させます。 - ハンドラーによる書き込み。
- 出力イベントを
message_outboxに永続化 — 状態変更とイベントは一緒にコミットされるか、ロールバックされます。 - コミット。
[detect unit-of-work implementation]:
- D1:インタラクティブトランザクションはありません。レシート、書き込み、アウトボックスを 1 つの
db.batch()にまとめ、リトライ付きでコミットします。レシートの UNIQUE 制約違反はDuplicateMessageErrorになります。 - Postgres:実際のトランザクションを使用します。Result ベースで、ロールバックブリッジ(
Result.errを保存 →tx.rollback()→ 保存した err を返す)、ブランド付きのTx型、AsyncLocalStorage を介したイベント収集を採用します。
トランザクションのルール:
- トランザクションの境界をまたぐ I/O は行わない — 接続を保持したまま fetch/キュー/R2 を使用しないでください(Hyperdrive プールが枯渇します)。
- 1 トランザクションにつき集約は 1 つです。呼び出し元の境界で UoW 全体をリトライします。UoW をネストしてはいけません。
- Postgres:呼び出しごとに接続は 1 つです。 並列に実行される作業単位は、互いのトランザクションが終わるまで待機します。作業単位の内部でトランザクション外の読み取り(
readDb)を行うと、自身の接続を待つことになります。アダプターは停止する代わりに例外をスローします。 - クエリは UoW を完全に経由しません。読み取りパスではリポジトリを省略でき、クエリハンドラーはインラインの Drizzle
select({...})→ DTO を実行できます。DTO はプロダクトのリソースであり、行ではありません。ストレージ上の都合を漏らさないでください。呼び出し元から求められない限り、コストの高い関連付けは含めないでください。
アウトボックスリレー
message_outbox 内のイベントはリクエストからではなく、リレーによって配信されます。定期スイープ(cron */1 * * * *、または DO アラームでリレーが Queues に排出 — [detect wrangler.jsonc])を使用します。コミット後の deps.hooks.onEventsCommitted() は waitUntil を介してリレーを起動しますが、これはレイテンシ最適化に限られます。スイープが信頼できる唯一の情報源であり、起動通知が失われてもイベントは失われません。
冪等性
状態を変更するものはすべて冪等でなければなりません。次の 3 層で対応します。
- メッセージレシート — UoW により自動処理されます(上記参照)。コマンドの再試行やイベントの再配信は安全です。
- 外部副作用 — 安定した操作プロパティから導出した冪等性キーを渡します。
Webhook 起点のメッセージでは、プロバイダーのキーをメッセージ ID として再利用します(await stripe.charges.create({ amount }, { idempotencyKey: `${runId}-${nodeId}` })stripe:event:evt_123)。 - DB レベル — 確認してから挿入するのではなく、アトミックに挿入します:
.onConflictDoNothing()/.onConflictDoUpdate()。TOCTOU の時間帯を作らないでください。
リポジトリ
書き込みとエンティティの読み取りはリポジトリを経由します。
- メソッド一覧は固定です:
byId/findByX/insert/save/delete。汎用的なupdate(id, fields)は使いません。 insertとsaveは異なります。既存の場合、insertは失敗します(AlreadyExists)。saveはバージョンをガードし、古いバージョンではRevisionConflictErrorが発生します。- ハンドラーを書く前に、各集約の挿入タイプを選択してください。確認してから挿入してはいけません。2つの並行リクエストが、どちらも確認を通過する可能性があります。
- 一度だけ作成する場合: 再実行は誤りです。
insertはAlreadyExistsで失敗し、呼び出し元がそのエラーを報告します。 - 自然キーがあり、到着を繰り返せる場合: 同じ入力には、ハッシュで識別されるファイルのように、同じ集約を返す必要があります。
insertはON CONFLICT (key) DO NOTHING RETURNING idを実行し、その後既存の ID を読み取ります。新しい行の場合に限り、ID を返してイベントをディスパッチします。ハンドラーは ID を返し、「新規」か「既存」かは報告しません。
- 一度だけ作成する場合: 再実行は誤りです。
- 各リポジトリは、永続化されたエンティティ用に
toDomain(row: DbRow): Entityマッパーを1つ定義します。 toDomainはエンティティのプレーンデータを構築し、Entity.restore(data)に渡します。toDomainはフィールド名とフラットなデータベース行を、ネストされたプレーンデータにマッピングします。値の検証やパースは行いません。- フラットなデータベースの NULL 許容性がドメインのユニオン型と矛盾する場合は、狭い範囲のキャストを
restoreの前に使います。コンストラクターがドメインの不変条件を適用します。 - 書き込み時は、リポジトリ内で
entity.toPlainObject()をデータベースの挿入または更新用の形にマッピングします。 - 古いデータベース値は移行してください。移行後も
toDomainで古い値の変換を続けてはいけません。 - Drizzle を使用し、ハンドラーで生の SQL を使わないでください。 Drizzle でクエリを表現できない場合に限り、生の SQL を使用し、リポジトリに記録します。
export class ProductRepository implements IProductRepository {
async byId(id: ProductId, userId: UserId) {
const row = await this.db.query.product.findFirst({
where: and(eq(product.id, id), eq(product.userId, userId)),
})
if (!row) return Result.err(new EntityNotFoundError('Product', id))
return Result.ok(this.toDomain(row))
}
private toDomain(row: DbProduct): Product {
return Product.restore({
id: row.id,
name: row.name,
state: { status: row.status },
})
}
}
Drizzle のテーブル定義では、インデックスに 配列 の戻り値を使用します。
sqliteTable('t', { ... }, (t) => [index('idx').on(t.col)])
ドメインエンティティ
エンティティパターンは、ID・状態遷移・不変条件がある場合にのみ使用し、それ以外ではプレーンな型を使います。
- 基底
Entity:addEvent/collectEvents/clearEvents。シリアライズにはtoPlainObject()を使用します。 - 永続化されたエンティティは
toPlainObject(): EntityDataとstatic restore(data: EntityData): Entityを公開します。 restoreはtoPlainObjectが返す型と正確に一致する型を受け取ります。- プライベートコンストラクターを使用します。主要なビジネス不変条件は、コンストラクター内、またはコンストラクターが呼び出す共通のアサーションで検証します。
- HTTP や I/O の境界で行われた Zod 検証を、ドメインモデルで繰り返してはいけません。
- データベースの型、NULL 許容性、制約のチェックをドメインモデルで繰り返してはいけません。
- ドメインアサーションは、型とデータベーススキーマでは表現できないルールによって、境界での検証を補います。
create、restore、状態遷移では、同じ不変条件検証を使用する必要があります。restoreはドメインイベントを発生させずに、ドメインの状態を再構築します。- ドメインエンティティはデータベースの型をインポートしてはならず、
fromPersistenceおよびtoPersistenceメソッドを公開してはなりません。 static create(...)— 現行世代ではResult<Entity, ValidationError>を返します(例外のスローはバグの場合のみ)。旧世代では不変条件違反時に例外をスローします。
export class Product extends Entity {
private constructor(private readonly data: ProductData) {
super()
assertValidProduct(data)
}
static restore(data: ProductData): Product {
return new Product(data)
}
toPlainObject(): ProductData {
return { ...this.data }
}
}
ミューテーションでは遷移をガードします。
markLive(): Result<void, PlaybookEditError> {
if (this.status === 'archived') return Result.err(new PlaybookEditError('archived'))
this.status = 'live'
this.updatedAt = new Date()
return Result.ok()
}
/core パッケージの境界
packages/core には2つのエントリーポイントがあります。index.ts(完全版、サーバー用)と client.ts(ブラウザーセーフ)です。ブラウザーコードでは @<scope>/core/client のみをインポートしてください。このサブパスが、サーバー専用モジュールがバンドルに取り込まれるのを防ぐファイアウォールになります。
Core には、ブラウザーセーフなコントラクトとドメイン型に加え、プロジェクトで使用する場合は、明確に分離されたサーバーインフラストラクチャを含めます。ワイヤーエンベロープモジュールが必要なのは、既存のエンベロープコントラクトがある場合に限られます。DB スキーマとマイグレーションはアプリ内の apps/<app>/src/server/infrastructure/{db|persistence}/ に置き、core には置きません。
公開コントラクト
形状を変更する前に、対象のインターフェースを分類します。
| インターフェース | 例 | 破壊的変更のルール |
|---|---|---|
| 内部 | Web クライアントに同梱される TanStack サーバー関数 | 同じデプロイで調整して破壊的変更を許可 |
| 公開 | MCP ツールの入力/出力、拡張機能のコントラクト、HTTP /api、送信 Webhook ペイロード、他のクライアントがインポートする @<scope>/core/client スキーマ | 新旧を併存させ、警告してから削除 |
公開済みの形状は変更できます。ただし、置き換える変更と同時に消してはいけません。
- 新しいフィールド、ツール、または形状を古いものと併せて提供します。
- 機械から確認できる形で古いものに非推奨の印を付けます。HTTP では
DeprecationとSunset、MCP ツールまたは引数ではdeprecated: true、エクスポートされたシンボルでは@deprecatedを指定します。 - 削除時期(日付、リリース、または N 日間トラフィックがないこと)を明示します。
- その期間が過ぎたら、古いものを独立した変更として削除します。
ビジネスロジックの中核は一つに保ちます。同じハンドラーから両方の形状を生成します。/v1/ ハンドラーを分岐させてはいけません。二重のフィールドを永久に残してはいけません。
告知なしの名前変更は禁止です。連絡が取れず、依存し続けているクライアントがある場合に限り、古いパスを維持します。
公開ルール:
- 利用側は未知のフィールドを無視する必要があります。
- DTO はストレージ上の都合を隠します。永続化の形状を公開してはいけません。
- コストの高い関連データはデフォルトで無効にします。呼び出し側が明示的に選択します。
- コードから公開される操作(MCP、HTTP、拡張機能)にはレート制限が必要です。コストの高い処理には、より厳しい制限を設定します。429 の場合は
Retry-Afterを返します。顧客ごとのキルスイッチを維持します。
ページネーション
- ユーザー単位の小規模なリスト:
?page=1&limit=20(上限 100)→{ items, page, limit, total, hasNextPage, hasPrevPage }。 - 無制限のコレクション(ジョブ履歴、監査ログ、MCP のリストツール):カーソルと
nextトークンを使用します。クエリはWHERE id > cursor ORDER BY id LIMIT nです。これらのリストではオフセットを使用しないでください。
プラットフォームのパターン
キューのコンシューマー
entrypoints/queue/ にクラスベースのコンシューマーを置きます。
- メッセージごとに Sentry の分離スコープを設定します。
classifyForRetry— 型付きエラーで再試行するか保留するかを判定し、上限付きの指数バックオフを明示的に設定します。max_retriesと DLQ は wrangler.jsonc で設定します。処理不能なメッセージは保留し、重複メッセージはスキップとして ACK します。
Cron/スケジュール実行
- Cron レジストリ(
entrypoints/scheduled/cron-registry.ts)が式とハンドラーを対応付けます。アウトボックスリレーのスイープも含みます。 - Cron は再試行しません。 各作業単位で独自にエラーを捕捉します。一つの不正な項目によってスイープ全体を停止させてはいけません。
- ローカルでのトリガー:
curl "http://localhost:8787/cdn-cgi/handler/scheduled"。
Cloudflare Workflows
- ステップごとに一つの作業単位を実行します。ステップが再試行の境界になります。
- 依存関係は各
step.do()の内部で構築し、コンストラクター内では構築しないでください。Hyperdrive 接続をステップ間で維持してはいけません。また、エンジンはステップ間で休止することがあります。 - 取得してから記録:外部呼び出しを一つのステップで行い、その結果を次のステップで記録します。ステップが再試行されても、確定済みの処理を決して再実行してはいけません。
- 意図 → 効果 → 結果:外部処理を実行する前に、永続的な意図(DB の行)を作成します。
- 再試行不可能な型付きエラーは
NonRetryableErrorに変換します。内部ステップと外部ステップの設定で、それぞれの再試行ポリシーを指定します。
Durable Objects
使用中:レート制限(RateLimiterDO)、アウトボックスリレー、エージェント/ゲート(Agents SDK)、コンテナスーパーバイザー。Sentry.instrumentDurableObjectWithSentry(...) でラップします。
コンストラクターはオブジェクトのメモリ内で一度だけ実行され、多数のリクエストとアラームを処理します。依存関係はコンストラクターではなく、メソッドまたはアラームごとに構築し、処理の最後にデータベースを閉じます。Hyperdrive は休止または追い出しのときにのみ Durable Object のソケットをクリーンアップするため、ソケットを開いたままにすると休止が妨げられます。
マイグレーション:変更ごとに新しいマイグレーションを追加し、一意のタグを付けて追記のみとします。出荷済みのエントリーを編集または削除してはいけません。新しい DO ではデフォルトで new_sqlite_classes を使用します。new_classes は、理由を文書化した場合に限り使用します(レガシー DO は存在します。依頼がない限り移行しないでください)。名前変更後はコードで新しいクラス名を使用します。名前変更/移行のために移行先クラスを手動で作成してはいけません。
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["SessionDO"] },
{ "tag": "v2", "renamed_classes": [{"from": "OldDO", "to": "NewDO"}] }
]
コンテナ
使用例:独立したアプリディレクトリにある Python サービス(uv/pyproject)。Start アプリの wrangler の containers エントリは、監視用 DO(class_name)を持つ Dockerfile を指定し、Sentry で計測されています。Linux/amd64 のみ対応しています。イメージは Cloudflare Registry にアップロードされます。
Workers KV
KV キーの追加、レビュー、調整を行う前に、Workers KV を読んでください。変更されないキーには長い cacheTtl を設定すること、バージョン付きキー、共有キー、リクエストごとに読み取りを 1 回にすること、公開時に書き込むことについて説明しています。
リモートバインディング
バインディングに "remote": true を設定すると、ローカル開発からデプロイ済みリソースに接続できます(課金が適用されます)。対応対象:AI、D1、KV、R2、Queues、Vectorize、Workflows。ローカルのみ:Durable Objects、Hyperdrive、Rate Limiting。Vitest は常にローカルシミュレーションを使用します。
型生成
設定またはバインディングを変更した後は、プロジェクトの cf-typegen スクリプトを実行してください。生成された型は編集しないでください。
通常の wrangler types は設定済みの環境を厳密なユニオンにまとめ、Cloudflare.<EnvName>Env を生成します。
意図的に 1 つの環境用に生成する場合に限り、--env を使用してください。--strict-vars=false で差異を隠さないでください。
Worker 間 RPC では、呼び出し元と、バインドされているすべての Worker の設定を、-c フラグを繰り返して渡してください。
各環境で必須のバインディング名を維持し、Wrangler がキャストなしで Service<typeof Entrypoint> を解決できるようにしてください。
ローカル Explorer API
Cloudflare Vite はローカルリソースを http://localhost:<project-port>/cdn-cgi/explorer/api で公開します。
設定またはプロセスの出力から、アクティブなプロジェクトの URL を確認してください。共通のポートを想定しないでください。
使用前に、その API ルートから OpenAPI スキーマを取得してください。ドキュメントに記載された操作のみを使用してください。
対象範囲のプラットフォーム変更については、cloudflare、durable-objects、wrangler、または workers-best-practices のスキルを読んでください。
デプロイ
Workers を使用し、Pages は使用しないでください。 TanStack Start アプリは @cloudflare/vite-plugin を使用します。CLOUDFLARE_ENV=<env> vite build は wrangler.jsonc から環境を解決し、計算済みの main/assets を含むフラット化された dist/server/wrangler.json を生成します。これらのフィールドを手動で記述しないでください。
環境名とデプロイスクリプトはプロジェクトごとに異なります。wrangler.jsonc と package.json を確認してください(例:dev/preview/prod、dev/staging/test/prod。pnpm deploy は本番前環境を対象とし、pnpm deploy:prod は本番を対象とします。どちらも先にマイグレーションを実行します)。
データベースのワークフロー
スキーマ変更、マイグレーション、データベースクエリの前に、データベースのワークフロー を読んでください。D1 と Postgres の判別、マイグレーションコマンド、マイグレーションのルール、制約の所有権について説明しています。
テスト
Worker、バインディング、データベースのテストを作成、レビュー、または高速化する前に、Workers のテスト を読んでください。
セキュリティと運用
Turnstile の設定、修復、CAPTCHA の移行には、まず公式の Turnstile Spin スキル を使用してください。 手動での設定やダッシュボードではなく、まずそのエージェントのワークフローに従ってください。
認証、認可、公開入力、アップロード、シークレットの取り扱いについては、セキュリティ を読んでください。 バックエンドのパフォーマンス、ログ、メトリクス、アラートについては、運用 を読んでください。 デプロイ済み Worker の速度や障害を測定するには、Worker の測定 を読んでください。
シークレット — dotenvx
- 暗号化された
.envファイルはコミットし、秘密鍵(.env.keys)は gitignore します。スクリプトはdotenvx run --を使って実行します。 - wrangler の
secrets.requiredマニフェストで、本番環境に必要なものを宣言します。 - これらのプロジェクトではこれがシークレットの仕組みです。
.dev.varsに代わるものです。
まだ実践されていない標準(新しいコードに適用し、既存コードをむやみに変更しない)
- リストのページネーションを新しいリストエンドポイントに適用してください。Published contracts のルールを使用します。ユーザー単位の小規模なリストにはオフセット付きエンベロープ、上限のないコレクションにはカーソルと
nextを使用してください。 - ドメイン内のリポジトリインターフェース。
- すべてのメッセージペイロードにブランド付き ID を使用(レガシーペイロードでは通常の文字列を使用)。
MCP サーバー設定
MCP ツールの入出力スキーマは公開済みの契約です。変更する際は、一定期間併存させて警告を出してから、削除してください。
claude mcp add --transport http <name> <url> --header "Authorization: Bearer <token>"
Windows の npx サーバーでは、cmd /c ラッパーが必要です。
{ "command": "cmd", "args": ["/c", "npx", "-y", "@package/name"] }