backend-architecture について
TypeScript と Node.js のバックエンド開発向けのフレームワーク非依存スキルです。ルート、サービス、リポジトリ、インテグレーションを責務ごとに整理します。
主な機能
- 01バックエンドのレイヤー境界
- 02ルートの検証と認可
- 03サービスのワークフロー指針
- 04リポジトリの永続化設計
- 05外部システム用アダプター
活用シーン
- 01新しいバックエンドパッケージの構成
- 02ルートとサービスの責務の見直し
- 03データベースアクセスと外部連携の分離
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ we-are-singular/ skills --skill backend-architectureインストール後、Claude Code で/backend-architectureを入力して読み込まれたか確認します。
詳細
バックエンドアーキテクチャ
TypeScript と Node.js のバックエンド開発では、このスキルを使用してください。フレームワークには依存しません。同じ責務を各フレームワークに合わせて割り当てることで、Fastify、Express、Hono、Workers、キュー、CLI、SDK、データベースパッケージ、統合モジュール、サービスパッケージに適用できます。
最初に行うこと
コードを変更する前に、次のことを行ってください。
- まずリポジトリのガイダンスを読みます。
AGENTS.md、パッケージの README、既存の規約、ローカルのテスト設定、近くにあるコードを確認してください。 - 現在のパッケージ構成、インポート、命名規則、サーバーのエントリーポイント、DB/リポジトリのコード、ヘルパー、テストを調べます。
- リポジトリ内の既存の規約が明確であれば、それを優先してください。このスキルは、新しいコード、判断が難しいコード、アーキテクチャの整理が必要なコードでのデフォルトとして使用します。
- 新しいヘルパー、サービス、スキーマ、リポジトリ、ミドルウェア、共有型を作成する前に検索してください。似たコードを追加するのではなく、まとめてください。
- スキーマや DTO を編集する前に、契約と生成ファイルの正式な情報源を特定してください。
基本方針:境界を早めに定め、実装は簡潔に保ちます。
アプリケーションまたはパッケージのレイヤー構造を早めに決定し、一貫して従ってください。ユースケースごとに独自のオニオン構造を作らせないでください。各境界内のコードは、その責務の範囲でできるだけ直接的に記述してください。
基本原則
- バックエンドコードには TypeScript を使用します。曖昧なオブジェクトより、厳密で明示的なドメインモデルを優先してください。
- 段階的開示を活用します。トップレベルのユースケースのオーケストレーションはドメイン用語で直線的に読めるようにし、正常系を見やすく保ち、解析、プロセス、プロトコル、ストレージの仕組みは、それぞれを担う境界に任せてください。
- 再利用が実際に必要になるまでは、シンプルに、局所的に保ってください。
MailboxRepository、ProvisioningService、createRepository、WebhookCryptoServiceのように、ドメイン上の責務に基づいて命名してください。- フレームワークやドメインの役割が本当に曖昧である場合を除き、
Manager、Handler、Processor、Utilのような曖昧な名前は避けてください。 - 状態とその不変条件は、一つの所有者に集約してください。呼び出し側に状態を公開して調整させるのではなく、その所有者にドメイン操作を実行させます。
- リポジトリが対応している場合は、ESM と Node の組み込みモジュール向けの
node:インポートを使用してください。 - 生成ファイルには明確な印を付け、リポジトリが明示的に求めていない限り、手動で編集しないでください。
- 意味のある複雑さを隠す深いモジュールを優先し、浅いヘルパー、バレル、単一用途の間接化は避けてください。
- 「スパゲッティ DRY」は避けてください。集約が適切なのは、呼び出し箇所と所有関係を単純化できる場合だけです。
- 根拠のない将来の状態や防御的な仕組みを作り込まないでください。未実装の動作には、明確な TODO を残してください。ただし、セキュリティ、データ整合性、クリーンアップ、文書化された実行時契約は必須です。
- サーバー専用パッケージをブラウザのバンドルに含めないでください。誤ってブラウザからインポートすると危険な場合は、明示的な実行時ガードを追加してください。
- 個人の好みより、リポジトリ固有のフォーマット、リンティング、インポート順序を優先してください。
アーキテクチャの境界
機能コア/命令型シェルを考え方のモデルとして使用してください。ドメイン上の判断は I/O の仕組みから独立させ、アプリケーションのワークフローでリポジトリや統合を調整します。コードを書く前に、これらの責務を割り当ててください。
ルート、コントローラー、ミドルウェア
ルート/コントローラーは、トランスポートに関する処理を担います。
- HTTP メソッド、パス、ステータスコード、リクエスト/レスポンスの形式。
- 呼び出し元とリクエストの状態、認証、ガードの設定。
- リクエストの解析とスキーマ検証。
- ユースケースを開始できるか、またどのように開始するかを決める、リクエスト固有のポリシー。
- 信頼できる入力をサービスまたはリポジトリに委譲すること。
- 想定されるドメイン上の結果をトランスポートのレスポンスにマッピングすること。
フレームワークが対応している場合は、設定駆動型のルート宣言を優先してください。ルート設定で認証、ガード、入出力スキーマ、レート制限を宣言すると、ハンドラーが簡潔になり、認可の内容もひと目で確認できます。以下の Fastify スタイルの例は説明用です。他のフレームワークでも、API をそのままコピーするのではなく、同じ構成を維持してください。
fastify.post("/mailboxes", {
config: {
auth: ["bearer"],
input: { schema: CreateMailboxRequest },
output: { schema: MailboxDTO },
guards: [hasActiveOrganization(), scopeAllows("mailbox:create")],
},
handler: async (request, reply) => {
const input = CreateMailboxRequest.parse(request.body)
const mailbox = await fastify.provisioning.createMailbox(input)
return reply.code(201).send(mailbox)
},
})
フレームワークが設定駆動型ルートに対応していない場合は、ハンドラーの先頭でガードとバリデーターを呼び出してからサービスに委譲し、同じ分離を維持してください。
async function provisionMailboxController(mailbox: MailboxDraft) {
await userGuard(mailbox.user.id, "mailbox.create")
const provisioned = await provisionMailbox(mailbox)
return MailboxSchema.parse(provisioned)
}
2 つ目の例では、MailboxDraft はすでに解析済みの、信頼できるリクエスト入力であることを前提としています。ルート DSL を使わずに同じ境界を表現しており、コントローラーが ACL と公開 DTO の解析を担う一方で、provisionMailbox は信頼できる入力を受け取り、トランスポートの状態を認識しません。
トランスポートのレスポンスとエラーエンベロープは、アプリまたはフレームワークの境界ごとに一度だけ標準化してください。ルートごとに場当たり的なレスポンス形式を作らないでください。
フロントエンドアプリにも、サーバーアクション、ミドルウェア、インテグレーション、スケジュールタスク、ルートハンドラー、サーバー専用パッケージなどのバックエンド領域があります。独立した API と同じ境界の規律に従ってください。
ミドルウェア/プラグイン/フックは、薄いコンポジション層にしてください。
- リクエストコンテキストを構築する。
- 共有依存関係を設定する。
- 認証、ガード、検証、レート制限、CORS、セキュリティヘッダー、トレーシング、ログ記録を適用する。
- アプリ/サーバーに、サービス、リポジトリ、クライアント、またはリクエストヘルパーを追加する。
長いビジネスワークフローをミドルウェアに入れないでください。ワークフローはサービスに移し、ミドルウェアはコンポジションと横断的な処理に専念させてください。
ガードと認可
ガードは、ルートハンドラーの前に実行される、組み合わせ可能な認可チェックです。「誰がこれを実行できるか」と「これが何をするか」を分離します。
- 各ガードは、メンバーシップ、ロール、スコープ、プランの上限、リソースの所有権など、1 つの認可上の関心事に絞ります。
- 一般的なガードの種類には、ID、権限、リソースの所有権、プラン/上限、条件付きガードの名前付きファクトリー関数を使用します。
- 可能であれば、ルート/設定レベルでのガード宣言を優先してください。それ以外の場合は、ビジネス処理を開始する前に、ハンドラーの先頭でガードを実行してください。
- フェイルクローズにしてください。ガード付きルートでは認証も宣言し、可能であれば起動時またはルート登録時に認証の欠落を検出してください。
- フレームワークで一般的な拒否方法を一貫して使用してください。すべてのコードベースに単一の
throwまたはreplyのパターンを強制しないでください。 - テナントをまたぐリソースの存在を明らかにすると情報が漏れる場合は、
403ではなく404を返してください。
プラグインとモジュールのライフサイクル
プラグインとモジュールは、インフラストラクチャをサーバーに組み込みます。薄く保ち、依存関係を宣言し、予測可能な順序で登録してください。
- コンポジションルート(サーバーのブートストラップ)でプラグインを順番に登録してください。順序は重要です。データベース、リポジトリ、サービス、認証、ガードの順にします。
- フレームワークが対応している場合は、依存関係を明示的に宣言してください。
- 各プラグインは、自身が所有するサービスまたはクライアントをサーバー/アプリに追加したら、それ以上の処理を行わないでください。プラグイン登録の中でビジネスロジックを連鎖させないでください。
- フレームワークの型拡張、または型付けされたローカル変数/コンテキストを使用し、追加したサービスを呼び出し元で安全に扱えるようにしてください。
- テストでは、必須ではないインフラストラクチャを意図的に置き換えるか、スキップしてください。CORS、レート制限、スケジューラー、リスナーによってバックエンドテストが煩雑にならないようにしてください。
統合とランタイムコンテキスト
フレームワークとの統合では、ランタイムコンテキストとライフサイクルを設定します。
- ミドルウェア/フックを登録します。
- 型付きのローカル変数/コンテキストを注入します。
- 長時間稼働するクライアントとリポジトリプロバイダーを作成します。
- フレームワークで必要な場合は、生成されたアンビエント型を注入します。
- 失敗しても安全で、ログに記録される場合に限り、重要でないマイグレーションや起動チェックを実行します。
統合モジュールは冪等にしてください。開発サーバーでは、HMR やモジュールの再評価によって、スケジューラー、クライアント、リスナーが重複して作成されることがあります。明示的なガードを使用し、ランタイムが対応している場合は破棄/クリーンアップ用のフックを使用してください。
サービス
サービスはアプリケーションのワークフローを担います。
- リポジトリ、外部クライアント、ワーカー、キュー、メーラー、SDK を連携させます。
- 永続化処理と外部への作用にまたがるビジネス上の処理順序を適用します。
- 外部の障害からどのように復旧するかを決定します。
- 重要なライフサイクルの段階、遷移、障害についてログを出力します。
サービスは単一目的の操作でも、オーケストレーターでもかまいません。信頼できる入力を受け取り、内部向けまたはドメインの出力を返し、永続的なビジネスルールとワークフローの処理順序を担います。呼び出し元の状態、トランスポートポリシー、公開レスポンスのフォーマットは担いません。
意図が伝わる操作を組み合わせて、オーケストレーターを簡潔で読みやすく保ちます。
async function provisionMailbox(mailbox: MailboxDraft) {
const reserved = await mailboxes.reserve(mailbox)
const provisioned = await mailServer.provision(reserved)
await mailboxes.activate(reserved.id, provisioned)
return provisioned
}
サービスは外部システムを呼び出す場合があります。リポジトリからは呼び出さないでください。
サービスが永続化処理と外部システムを連携させる場合は、信頼できる情報源に基づいて操作順序を決め、明示的なクリーンアップで障害に対処します。
- まずローカルで予約し、その後、外部でプロビジョニングします。 外部呼び出しに失敗した場合は、ローカルレコードをクリーンアップするか、失敗としてマークします。これにより、外部リソースの孤立を防げます。
- ローテーション/更新では、まず信頼できる情報源を更新します。 トークンをローテーションする際は、新しいハッシュをローカルに永続化する前に、外部プロバイダーを更新します。外部の更新に失敗した場合、古いローカル値が引き続き有効です。
- 重要でない処理には、ブロッキングしない副作用を使用します。 歓迎メール、分析、通知など、メインのワークフローをブロックすべきでない処理には、catch-and-warn パターンを使った fire-and-forget を使用します。
副作用を意図的にブロッキングしない場合は、そのことがコードから分かるようにし、必ず catch-and-warn のログ記録を追加します。
状態を持つサービス、注入された依存関係を持つサービス、ライフサイクルやログコンテキストを扱うサービス、複数の公開ワークフローメソッドを持つサービスには、クラスを使用します。純粋な変換や単発の動作には、関数を使用します。
リポジトリ
リポジトリには、その存在意義があるだけの機能を持たせます。db.table.create(data) をラップするだけのリポジトリは、通常、余計なものです。
データベースクライアントは、Prisma、Drizzle、SQL ビルダーなどの生のインフラストラクチャとして扱います。リポジトリは、そのクライアントを包む、永続化処理を担う層として扱います。テストでは個々のリポジトリをクライアントで直接構築できますが、アプリでは通常、リポジトリファクトリーのグラフを受け取ります。
リポジトリは永続化の動作を担います。
- クエリとクエリの組み立て。
- デフォルト値とストレージレベルのバリデーション。
- エンティティの状態遷移。
- 永続化ワークフローのデータベーストランザクション。
- 1 つのストレージ操作として複数のデータベースレコードを作成、更新、削除する処理。
- データベースを基盤とする監査/アウトボックスの行と親子レコードの更新。
- ページネーションと並べ替えのデフォルト値。
- 生の行を、返す前に永続的な DTO やスキーマに解析する処理。
リポジトリの副作用はデータベース内にとどめます。リポジトリから外部 API、キュー、メーラー、SDK、HTTP クライアント、ファイルシステムを呼び出さないでください。
トランザクションは通常、リポジトリの詳細です。リポジトリをまたぐ実際のワークフローでは、サービスレベルのトランザクションも有効ですが、すべてのメソッドにデフォルトで tx パラメーターを追加しないでください。トランザクションのサポートは、ワークフローで必要な場合にのみ追加します。
ページネーション、findOrThrow、行の解析、監査のデフォルト値、スラッグのヘルパー、共通のトランザクションヘルパーなど、実際の重複を減らせる場合は BaseRepo または共通のリポジトリヘルパーを使用します。単純でストレージ指向のものにしてください。汎用的なビジネスフレームワークにしないでください。
外部システム
サードパーティ API、SDK、キュー、メールプロバイダー、決済プロバイダー、インフラストラクチャクライアントは、自社のサービスまたはアダプターでラップしてください。
ルートと中核となるビジネスロジックは、ベンダーの SDK を直接呼び出すのではなく、アプリケーションの境界を呼び出すようにしてください。これにより、設定、ログ記録、リトライ、エラーのマッピング、テスト用の代替実装、将来的な置き換えを一元管理できます。
SDK スタイルのパッケージの場合:
- 生成されたクライアントやプロトコルクライアントを、小規模なファサードでラップします。
- プロトコルのコールバック API を、Promise ベースのアプリケーションメソッドに変換します。
- クライアント境界でデッドラインやタイムアウトを設定します。
- 想定されるリモートでの結果に対して、型付きのドメインレスポンスを返します。
- 適切な場合は、内部のトランスポートエラーをユーザー向けレスポンスに含めないようにします。
- クライアントを依存性注入できるようにし、テストで型付きのモックを使用できるようにします。
- クライアントがソケットやプロセスを所有する場合は、明示的な
disconnect/クリーンアップを公開します。
呼び出し側で分岐する必要がある想定内の失敗モードが外部操作にある場合は、判別可能な結果型を使用してください。
type ProvisionResult<T> = { ok: true; data: T; warnings?: string[] } | { ok: false; error: string }
これにより、想定内の結果に対する try/catch を避け、呼び出し側の分岐を明示できます。予期しないインフラストラクチャ障害には例外を使用します。
外部クライアントやサービスにまとめられるサブドメインがある場合は、共通のトランスポートまたはランタイムコンテキストを共有する、型付きサブモジュールを備えたファサードクラスを使用してください。サブモジュールは直接単体テストし、アプリの他の部分からはファサードを介して操作するようにします。
class MailServer {
readonly mailboxes: MailServerMailbox
readonly domains: MailServerDomain
constructor(config: MailServerConfig) {
const ctx = createSharedContext(config)
this.mailboxes = new MailServerMailbox(ctx, config.mailboxDefaults)
this.domains = new MailServerDomain(ctx)
}
}
ファイルと名前
基本的には厳格な命名規則を使用してください。
- ファイル名にはケバブケースを使用します:
webhook-dispatch.service.ts、organization-domain.repo.ts。 - 役割がわかりやすくなる場合は、ファイル名にアーキタイプを示すサフィックスを付けます:
.service.ts、.repo.ts、.plugin.ts、.module.ts、.schema.ts、.dto.ts、.test.ts。 routes/api/mailbox.tsのように、ディレクトリ構成やリポジトリの慣習から役割が明らかな場合は、サフィックスを省略します。- 技術的な分類ごとの雑多なディレクトリより、機能やドメインごとのグループ化を優先します。
- 各階層が実際の所有関係を表していない限り、深いネストは避けます。
一般的な構成には、routes/services/plugins/lib/schemas、domains/mailbox などのドメインフォルダー、spec/db/api/utils などのパッケージ境界があります。まずはリポジトリの構成に従ってください。重要なのは所有関係であり、フォルダー名そのものではありません。
型、スキーマ、コントラクト
信頼境界ではランタイムスキーマを使用してください。
- HTTP リクエストボディ、パラメーター、クエリ文字列、レスポンス。
- Webhook と外部イベント。
- 環境変数/設定値。
- 公開 DTO に渡されるデータベース行。
- キュー/ジョブのペイロード。
スキーマの配置:
- ルート固有のリクエストスキーマは、ルート/モジュールの近くに置きます。
- 永続的なドメインコントラクト、DTO、データベーステーブル定義、認証/設定の仕様、共有イベントペイロード、公開 API の構造は、早い段階で一元化します。
- アプリ全体に関わる型、セキュリティ上重要な型、設定の一部となる型、複数のパッケージで使用する型は、一元化します。
- 1 つの機能/ドメインが所有する型は、その機能/ドメイン内に置きます。
- 1 つの関数またはファイル内でのみ使う小さな型は、インラインで記述します。
- ソースファイルが読みづらくなった場合に限り、ローカルの
types.tsやヘルパーファイルを作成します。
データを所有する境界で解析します。リクエストと公開レスポンスはコントローラー、永続化された行はリポジトリ、プロバイダーのペイロードはインテグレーションで解析します。以降は生の値をシステム内に渡し続けず、信頼できる値に変換してから内側へ渡します。
const CreateMailboxRequest = z.object({
localPart: z.string().optional(),
name: z.string().optional(),
})
route.post("/mailboxes", async (request) => {
const input = CreateMailboxRequest.parse(request.body)
return mailboxService.create(input)
})
可能な場合、リポジトリは解析済みの永続的な形状を返すようにします。
class MailboxRepository {
async getById(id: string) {
const row = await db.query.mailbox.findFirst({ where: { id } })
return row ? MailboxDTO.parse(row) : null
}
}
フィールドが相互排他的な状態を表す場合は、判別可能なユニオンを使用します。null 許容フィールドの慣習として文書化するのではなく、不正な組み合わせを構築しにくくします。
通常は戻り値の型推論を使用します。公開コントラクト、エクスポートされるファクトリー、公開クラスメソッド、非同期境界の動作が明確になる場合や、リポジトリ内の lint ルールで求められる場合は、明示的な戻り値の型を追加します。
単一の信頼できる情報源
永続的な構造には、正規の契約ソースを1つ使用します。適切なパターンは次のとおりです。
- データベースのテーブル定義から、挿入/選択スキーマと生のDB型を生成します。
- DTOスキーマを、生成されたテーブルスキーマの拡張または絞り込みとして定義します。
- コントローラーは、サービスの結果を公開DTOで解析してから応答します。
- SDKクライアントは、生成されたプロトコル型をアプリ所有のレスポンス型に正規化します。
避けるべきパターンは次のとおりです。
- 同じAPI構造をYAML、JSON Schema、Zod、TypeScriptで手作業により維持すること。
- リクエスト/レスポンス型を導出またはインポートせずに、各レイヤーへコピーすること。
- ドキュメント化された管理元なしに、競合する複数のソースからコードを生成すること。
複数の契約形式が必要な場合は、正規のソースから二次形式を生成します。並行する複数の情報を手作業で維持しないでください。
コード生成
コードベースでビルド手順によりスキーマ、リポジトリファクトリー、バレルエクスポート、ルートツリーを生成する場合、生成パイプラインをインフラストラクチャとして扱います。
- 生成ファイルには、
.gen.tsサフィックスまたはヘッダーコメントを付けて明確に示します。パイプラインで明示的に想定されている場合を除き、生成された出力を手作業で編集しないでください。 - ジェネレータースクリプトはシンプルな逐次処理にします。ログ付きの名前付きステップを使うと、失敗箇所を簡単に追跡できます。
- 二次成果物は、1つの正規ソースから生成します。たとえば、テーブル定義からスキーマを、DTOスキーマから公開型を、実装済みモジュールからファクトリーインデックスを、ルートファイルからルートツリーを生成します。
- 古い出力が利用側の問題になる場合は、通常のビルド/テストパイプラインの一部として生成を実行します。
- ジェネレーターを管理するパッケージの近くに、再生成コマンドを記載します。
- エンティティを追加する場合は、まず手書きのソースを更新し、その後、派生ファイルを再生成します。
クラス、依存性注入、ファクトリー
オブジェクトが依存関係、状態、ライフサイクル、ログコンテキスト、または一貫性のある一連の公開メソッドを所有する場合は、クラスを使用します。サービスは、選択したアーキテクチャに関わるというだけでクラスにする必要はありません。
オブジェクトによる所有が適している場合は、バックエンドサービス、リポジトリ、アダプター、ファサード、モジュールを合成時に一度だけ構築します。プロセス、テストアプリ、ワーカー、またはリクエストスコープごとに1つのアプリグラフを使用することを推奨します。ローカルのコードベースでそのスタイルが明確に採用されている場合を除き、各データベース行が振る舞いを持つオブジェクトになるような、モデル主導のクラスは避けます。継承は原則として避け、実際の重複を解消する安定したインフラストラクチャの仕組みに限って使用します。
振る舞いが純粋である場合、ステートレスである場合、またはガード/ヘルパー/変換処理として表すほうが適切な場合は、関数を使用します。
状態遷移は、不変条件を所有するクラス、サービス、またはモジュールの内部に留めます。呼び出し側は内部の変更を調整するのではなく、ドメイン操作を要求する必要があります。
具体的な依存関係をデフォルトにします。
class BillingService {
constructor(
private readonly users: UserRepository,
private readonly payments: PaymentService,
private readonly logger: Logger,
) {}
}
依存関係が意図的に小さなものである場合に限り、限定的な依存関係の形状を使用します。
type TokenSigner = {
sign(payload: TokenPayload): string
}
class SessionService {
constructor(private readonly tokenSigner: TokenSigner) {}
}
名前付きのポート/インターフェースは、原則として避けます。契約が増えることで、ずれが生じる可能性があります。複数の実装がある実際の境界、パッケージの独立性、または具体的な注入ではすっきり解決できないテスト容易性の問題がある場合に限り、導入します。
ファクトリーは、単純なオブジェクト生成ではなく、標準的な配線に使用します。リポジトリファクトリーは、生のデータベースクライアントを受け取り、機能を備えたリポジトリインスタンスを返すことができます。グラフの型は手作業で重複させず、ReturnTypeで導出します。
ファクトリーが役立つのは、次のような場合です。
- 多数の依存関係を一貫した方法で構築する必要がある場合。
- テストで再現可能なアプリ/リポジトリ/サービスのグラフが必要な場合。
- フレームワークのプラグイン/モジュールが、共有オブジェクトでランタイムを拡張する場合。
- 設定とライフサイクルを一元管理する必要がある場合。
設定 DSL、レジストリ、高水準の抽象化
設定、実行コンテキスト、複数のバリエーションが繰り返し現れるドメインでは、高水準の抽象化を設計します。抽象化は、呼び出し側のコードが直接記述するよりも簡潔になるよう、具体性を保ちます。
適した用途:
- エージェント/ツール/プロバイダーのレジストリ。
- チャートやレポートの設定 DSL。
- 型付きステップを持つジョブパイプライン。
- シーダーとフィクスチャローダー。
- 生成されたクライアントをラップする SDK ファサード。
- ミドルウェアと型付きローカルを組み込むフレームワーク統合。
ルール:
- 強い型推論とすっきりした記述方法が必要な場合は、
defineX()ヘルパーを優先します。 - 設定オブジェクトは宣言的に保ち、実行ラッパーは小さくします。
- 静的な設定と実行時コンテキストを分離します。
- 見つけやすさと型安全な検索には、レジストリを使用します。
- 未知のキーに対して、レジストリが明確なエラーをスローするようにします。
- 呼び出し側のコードが簡潔になる場合にのみ、カテゴリやグループをサポートします。
- 各バリエーションだけでなく、リゾルバー/ファクトリーのテストも追加します。
例:
export function defineTool<const T extends ToolConfig>(config: T): T {
return config
}
const TOOL_CONFIG = {
catalog: {
search: defineTool({ name: "Catalog search", input, output }),
},
chart: {
cartesian: defineTool({ name: "Cartesian chart", input, output }),
},
} as const
export function getTools(keys: Array<ToolKey | ToolCategory>) {
const selected = resolveToolConfigs(TOOL_CONFIG, keys)
return (context: ToolContext) => Object.fromEntries(selected.map((tool) => [tool.id, tool.withContext(context)]))
}
次の場合は DSL を避けます:
- バリエーションが 1 つか 2 つしかない。
- 型の複雑さによって、単純なコードが分かりにくくなっている。
- 他の場所にある振る舞いの設定が、新たな信頼できる情報源になっている。
- 解決後も、各バリエーションに大規模な独自分岐が必要になる。
ワークフローパイプライン
複数ステップのワークフローでは、順序、共有状態、中断動作、ストリーミング/出力制御が重要な場合、明示的なパイプラインを優先します。
使用するもの:
- 蓄積されたワークフローデータを保持する、型付きの可変実行状態。
- 共通のシグネチャを持つ、小さなステップ関数。
- オーケストレーターに集約したエラー処理。
- ステップがパイプラインを停止できる場合の、明示的な
next()/abort()または同等のフロー制御。 - ストリームライター、ロック、トランザクションなど、排他的なリソース専用の制御オブジェクト。
各ステップがそれぞれ独自の try/catch 方針を考案するべきではありません。予期しないエラーはオーケストレーターが処理し、ステップは型付きの結果を返すか、予期される失敗に対して中断します。
ヘルパーと共有ユーティリティ
実際の概念に名前を付ける場合、意味のある複雑さを隠す場合、または安定した繰り返しが見られるようになった場合に、ヘルパーを切り出します。浅いヘルパーを連ねるより、処理を深く担う 1 つの操作を優先します。まず検索してください。
ヘルパーを追加する前に:
- 挙動が重複する既存のヘルパーを探します。
- 新しいヘルパーによって、過去と現在の類似するユースケースを統合できるか確認します。
- 具体的な名前を付けます。無関係な関数が
utils.tsに増えていくのは避けます。 - パース、正規化、セキュリティ、暗号、日付、ID、エッジケースに関わる場合は、対象を絞ったテストを追加します。
小さな関数ごとにファイルを作成したり、1 行の重複をなくすためだけにヘルパーを切り出したりしないでください。切り出すことで呼び出し側が明確になるか、意味のある重複を防げるようになるまでは、小さな一度限りのロジックをインラインに保ちます。
環境と設定
環境設定はシンプルに保ち、型を付けて一元管理します。大文字の環境変数キー、明示的なデフォルト値、派生する実行時フラグ用の計算済みゲッターを優先します。
export const env = {
NODE_ENV: process.env.NODE_ENV ?? "development",
PORT: Number(process.env.PORT) || 3000,
DATABASE_URL: process.env.DATABASE_URL ?? "",
get isDev() {
return this.NODE_ENV === "development"
},
get isTest() {
return this.NODE_ENV === "test" || process.env.VITEST === "true"
},
} as const
小規模な範囲であれば、単純な設定オブジェクトで十分です。対象範囲が広い場合、値の欠落によってデバッグが難しい障害が発生する場合、値がパッケージ境界を越える場合、またはフレームワークが検証済み設定を想定している場合は、実行時のスキーマ検証を使用します。明らかな環境変数が少数しかないのに、環境変数全体の検証レイヤーを追加しないでください。
シーダーとテストデータ
シーダーはバックエンドのインフラストラクチャです。一時的なスクリプトではなく、コードとして扱います。
適切なシーダーは次のとおりです:
- 外部キーや依存関係が重要な場合、テーブルの順序を明示します。
- リセット時の動作が異なる場合、自動採番テーブルと ID のないテーブル/中間テーブルを分けます。
- シリアライズされた JSON 値を
Dateなどの実行時の型に戻します。 seed()とclear()のエントリーポイントを提供します。- 対象を絞ったテストのために、一部のテーブル/ファイルだけをシードできるようにします。
- データベース固有のリセット処理を各テストではなく、シーダー内に保持します。
何も知らせずに代替処理へ切り替える場合は注意してください。ファイルがないときに空のシードを返すのは便利なことがありますが、意図的なテストデータ方針として選ぶべきであり、偶発的にエラーを隠してはなりません。
コメントとドキュメント
コメントでは、意図、不変条件、処理順序、セキュリティ、外部システムの動作を説明します。
以下には JSDoc を使用します。
- 公開サービス、リポジトリ、ファクトリー、モジュール、複雑なヘルパー。
- 順序や失敗時の意味が自明でない公開メソッド。
- セキュリティ上重要なコード。
- 外部システムの動作。
- データベースの状態遷移と整合性ルール。
以下にはインラインコメントを控えめに使用します。
- 複雑なパース、偽の URL、認証/セッションの動作、プロバイダーの配線、フォールバック、一時的な回避策、意図的なハック。
- 順序が重要な理由。
- 処理を意図的にノンブロッキングにしている理由。
- フォールバックが安全な理由。
- 一見意外な分岐が存在する理由。
コードの内容を言い換えるだけのコメントは避けます。
良い例:
/**
* Rotate the token by updating the external provider before storing the new hash.
* If the provider update fails, the old token remains valid locally.
*/
async rotateToken(id: string) {
// External provider is source of truth for live auth, so update it first.
}
悪い例:
// Set the name field to the name from the input.
user.name = input.name
エラー処理
ガード節を使って、正常系の処理に入る前に無効な条件を拒否します。現在のレイヤーが復旧方針を担う場合にのみローカルの try/catch を追加し、それ以外の場合は既存の境界までエラーを伝播させます。
想定されるドメイン上の結果は明示します。
- 不在が正常な場合は
nullを返します。 - 呼び出し元が分岐する必要がある場合は、型付きの結果/ステータスオブジェクトを返します。
- 想定される失敗モードがある外部操作には、判別可能な
{ ok: true; data } | { ok: false; error }の結果を使用します。 - 永続的なワークフローの状態には、DTO のステータスフィールドを使用します。
- 想定される失敗は、ルート/コントローラー境界でトランスポートエラーに変換します。
以下の場合は例外をスローします。
- プログラマーのエラー。
- 発生し得ない状態。
- 呼び出し元が通常の不在として扱うべきではない、インフラストラクチャや外部システムの操作失敗。
- 続行すると状態を破損させる可能性がある永続化の失敗。
型付きの戻り値のほうが明確な場合、想定されるビジネス上の結果を汎用例外に埋もれさせないでください。
不正な項目を除外することがプロダクト上の判断であり、十分なコンテキストとともに失敗がログに記録される、意図的に部分的なベストエフォートのリスト表示に限り、寛容なパースを使用します。正規のデータベース読み取り、管理者向けビュー、セキュリティ上重要なデータ、永続的な契約では、不正なデータを隠さず、明確に失敗させます。
トランスポート、アクション、またはコントローラーの境界では、共有の getErrorMessage 形式のユーティリティを使って、不明なエラーをユーザー向けメッセージに変換します。明示的なフレームワーク/ドメインエラーは、汎用メッセージでラップせずに保持します。
ログ記録と可観測性
ロガーの一貫性は、バックエンドが適切に保守されていることを示します。プロジェクトのロガーパッケージまたはグローバルロガーのパターンを使用します。どちらも存在しない場合は、独自のロギングスタイルを考案する前に、導入を提案します。
ガイドライン:
- サービスがリクエスト/モジュールのコンテキストを引き継げるよう、注入可能な共有ロガーを優先します。
- 詳細なワークフローのステップには
traceとdebugを使用します。 - ライフサイクルイベントと重要な状態変更に限り、最小限に
infoを使用します。 - 復旧可能な予期しない状況には
warnを使用します。 - 対応が必要な操作失敗には
errorを使用します。 - 生のシークレットや大きなリクエスト本文ではなく、有用な ID とコンテキストを含めます。
- 詳細度はログレベルで調整します。実装を静かに保つためだけに、有用なデバッグ/トレースログを削除しないでください。
テスト
デフォルトで、テスト支援開発を行います。
- バックエンドの変更では、テストを追加または更新します。
- バグ、リグレッション、リスクの高い分岐、契約、セキュリティ、暗号処理、認証/ガード、永続化の遷移では、まずテストを書きます。
- 実装の形がまだ変化している探索的な作業では、頻繁な変更に合わせてテストを固定することは避けます。形が固まったら、最終的な分岐、契約、障害モードを対象とした振る舞いテストを追加または更新します。
- TypeScript がすでに保証していることはテストしません。
- 振る舞いを所有する安定した境界を通して、観測可能な振る舞いをテストします。1 行だけのヘルパーや、実装上の偶発的な順序はテストしません。
バックエンドテストのデフォルトの配置:
tests/
unit/
integration/
helpers/
seed/
__snapshots__/
リポジトリですでにテストを同じ場所に配置するなど、明確な規約を採用している場合は、それに合わせます。
テストの選択:
- 純粋なヘルパー、ガード、暗号処理、バリデーション、サービスロジックは、依存関係をフェイクにしてユニットテストします。
- フレームワークのインジェクション/テストクライアントを使ってルートを統合テストし、ACL、委譲、DTO の解析、エラーマッピングも含めます。
- 信頼できる入力と観測可能な出力を通じてサービスオーケストレーターをテストします。処理順序が契約の一部である場合にのみ、その順序を検証します。
- シードデータと実際の DTO 解析を使って、永続化の振る舞いをリポジトリテストします。
- スナップショットは、安定したレスポンス、DTO、エラーの契約にのみ使用します。
- 生成された ID、タイムスタンプ、ランダム値、外部値は、明示的なマッチャーで正規化します。
- リポジトリに別の規約がない限り、ネストした
describeブロックは浅く保ちます。
テストのモックパターン:
- ファクトリー(
fakeCtx()、fakeRequest()、createTestApp())を作成し、妥当なデフォルト値を持つ型付きテストダブルを返します。各テストでは、必要な部分だけを上書きします。 - すでに DI に対応している SDK やサービスには、コンストラクターで注入するモッククライアントを優先します。
- モジュールレベルのモックは、コードベースですでに使用されている場合、またはコンストラクターインジェクションによって本番コードが歪む場合にのみ使います。
- モックの状態は最小限に保ち、テスト間でリセットします。
- 入力/出力の組み合わせが多い純粋関数には、テーブル駆動テスト(
it.eachまたは小さなヘルパー)を使います。
推奨事項と禁止事項
推奨事項:
- アプリの境界を早い段階で選び、ユースケース全体で一貫して従います。
- コントローラーは簡潔に保ちます。リクエストの状態とガードを解決し、信頼できる入力を委譲してから、公開 DTO を解析します。
- サービスは、入力、出力、永続的なビジネスルール、名前の付いたドメイン操作に集中させます。
- 所有権、ライフサイクル、構築を明確にできる場合は、クラス、依存性注入、ファクトリーを使います。
- 扱いにくい処理の仕組みは、意図が伝わる深いインターフェースの背後に配置します。
禁止事項:
- クラス、継承、依存性注入、ファクトリーそのものをアーキテクチャとして扱わないでください。
- 呼び出しを転送するだけのコントローラー、サービス、リポジトリ、ヘルパーの連鎖を作らないでください。
- トランスポート層の認証情報やリクエストの状態を再利用可能なサービスに渡したり、サービスから公開 DTO を返したりしないでください。
- 復旧を担う責任もないのに、通常のフローをネストした分岐やローカルの
try/catchブロックの下に埋め込まないでください。 - 個々のユースケースが、アプリで選択したオニオン構造を迂回したり、独自に作り直したりしないようにしてください。
レビューチェックリスト
バックエンドのコードをレビューする際は、次を確認してください。
- トランスポート、ワークフロー、永続化、外部システムの責務は分離されており、通常の処理経路はコントローラーとサービスの境界で把握できますか?
- コントローラーは呼び出し元の状態、ACL、リクエスト/レスポンスの解析、トランスポートのマッピングを担っていますか?
- サービスは信頼できる入力を受け取り、再利用可能な操作、永続的なビジネスルール、ワークフローの順序を担っていますか?
- ガードは組み合わせ可能で、ルート境界に接続され、フェイルクローズになっていますか?
- 永続的な契約は一元管理され、エンドポイント固有のスキーマはローカルに保たれていますか?
- 契約の信頼できる情報源は一つですか、それとも構造が手作業で重複定義されていますか?
- 生成ファイルは明確に示され、手書きのソースから生成されていますか?
- リポジトリは十分な機能を備え、データベースへの副作用に限定されていますか?
- 外部システムは、判別可能な結果型を持つサービス/アダプターでラップされていますか?
- 生成されたクライアント/プロトコルクライアントは、アプリケーション所有のファサードの背後に隠されていますか?
- プラグインは依存関係の順序に従って、明示的に宣言したうえで登録されていますか?
- レジストリ/ファクトリーは有用でテストされていますか、それとも抽象化すること自体が目的になっていますか?
- 名前はドメインに即しており、ファイルは将来の読者が探す場所に配置されていますか?
- ログ出力はコンテキストを含み、レベルに応じて制御され、プロジェクト内で一貫していますか?
- 想定される失敗は明示され、想定外の失敗は表面化するようになっていますか?
- テストは動作、契約、永続化の遷移、リスクの高い分岐をカバーしていますか?
- テストでは、場当たり的なオブジェクトリテラルではなく、コンテキストファクトリーを使ってモックを構築していますか?