Note
このドキュメントは開発におけるアーキテクチャの指針をまとめたものです。
ドキュメントは人間とLLMの双方にとって分かりやすく、意図や設計方針が正確に伝わるよう記述してください。
- Runtime: Node.js, Bun
- Meta-framework: HonoX
- Routing: Hono
- Build: Vite
- Database: PostgreSQL, PGlite
- ORM: Drizzle ORM
- UI: Hono JSX
- Styling: Tailwind CSS, Tailwind Variants
- Testing: Bun Test, Vitest, Playwright
- Linting: Biome, Prettier, tsgo, dependency-cruiser
- Nodeモジュールの依存関係は最小限にする
- フルスクラッチよりも導入意義が十分大きい場合のみ採用する
- アプリケーションのソースコードが直接依存しない開発ツールはこの限りではない
- 方針に悩んだときはHonoやHonoXでの無難なやり方に従う
- 不合理だと思う場合は、改善案とそうあるべき理由をHonoのコミュニティに提案する
app/domain/- ドメインロジックの定義
libs/- 外部のライブラリを含んだ大きなロジック
routes/- アプリケーションのルーティング定義
-components/- 特定のルート配下でのみ再利用されるコンポーネント
-helpers/- 特定のルート配下でのみ再利用される補助関数
-hooks/- 特定のルート配下でのみ再利用されるフック
usecases/- ユースケースの実装
utils/- 小さな汎用関数やどこでも使えるシンプルな処理
- 関連性の高いもの(一緒に変化するもの)は近くのディレクトリに配置する
- ディレクトリ構成は、技術的詳細よりもドメインを浅い階層に配置する
- 叫ぶアーキテクチャ
- ドメインに依存しない要素も存在するため、type-basedな構成と併用する
- ユニットテストのコードは実装と同じ階層に配置する
- 変更頻度の高いものを変更頻度の低いものに依存させる
- UIはドメインに比べて変化しやすい
/app/routes/以下にcreateRoute()をexportしたindex.tsxを配置してルーティングを定義する- File-based routingを利用する
- コンポーネントは共通化が必要になるまでは分割せずにルーティング定義に直接記述する
- コロケーションに基づき、再利用箇所が限られているコンポーネントは
-components/、関数は-helpers/のディレクトリに配置する-から始まるディレクトリはルーティングから除外される
- コンポーネントのファイル名は、サーバーサイドの場合は
camelCase.tsx、アイランドの場合は$camelCase.tsxとし、exportする名前はPascalCaseとする- HonoXのドキュメントの例に従う
$から始まる名前は、HonoXがコンポーネントをアイランドとして認識するための特別な接頭辞
- Hono JSXを使用してUIを構築する
- Reactと異なり、ループ処理に
key属性は不要 classNameではなくclassを使用する- Hono JSXでは
classとclassNameの両方が動作するが、新規実装ではclassを優先する
- Hono JSXでは
- Reactと異なり、ループ処理に
- 条件分岐を含むスタイルは
tailwind-variantsで管理する- コンポーネントが
classを受け取ってスタイルを合成することは認めない - 単一の静的なスタイルの場合は、
tvを使用せず直接classを指定する
- コンポーネントが
- 色は
app/style.cssに定義されたdesign tokensを参照する
- 原則として、HTML/CSSのみで実現できることはクライアントサイドのJavaScriptで実装しない
- ただしユーザー体験(UX)が大きく向上し、静的なHTML/CSSだけでは実現しにくい振る舞いがある場合はクライアントコンポーネントを導入する
- クライアントコンポーネントが利用するAPIは
app/routes/api/に定義する- 命名やレスポンスの形式は、コンポーネントやユースケースに特化したものとする
- RESTfulである必要はない
- クライアントではHono clientを使用して型安全にAPIと通信する
- ヘルパー関数
createHonoClient()を利用する- 実体は
app/routes/-helpers/api/hono-client.ts AppTypeはapp/routes/api/index.tsxからimportする
- 実体は
Date型は受信時にstring型から変換する必要があるため注意する
- ヘルパー関数
- 目的
- フレームワークに依存しない
- UIに依存しない
- 外部エージェントに依存しない
- データの永続化手段に依存しない
- テストが容易
- 原則
- 読み取りと書き込みは分離する
- 依存性は具体から抽象に向け、制御の流れと依存関係を分離する
- インターフェース層
- 外部から入力手段を定義する
- UIとの結合度が高いため、クライアントの実装に近い場所で管理する
- クライアントのためのバリデーションと、ユースケース層のユースケース関数の呼び出しを行う
- ルート層では
app/routes/_middleware.tsでDBクライアントをコンテキストに注入(c.set("dbClient", ...))し、このdbClientを起点にユースケースを呼び出す
- 外部から入力手段を定義する
- ユースケース層
- アプリケーション固有のビジネスロジックの実装を定義する
- トランザクションはユースケース層で開始・管理し、他のレイヤーではトランザクションを管理しない
- ドメイン層の抽象リポジトリを利用して、ビジネスロジックをユースケース関数として実装する
- ドメインを跨いだ値の整合性の担保や、クライアントを意識した変換・集計を行う
- ユースケース固有の設定値を管理する
- リポジトリ関数に渡すページネーションのデフォルト値はここで定義する
- 異なるユースケースで値が一致していても、独立したユースケースであれば個別に定義する
- 過度な一般化(複数のユースケースで共有する定数ファイルの作成など)は避ける
- ドメインのリポジトリ関数が返したエラーメッセージのうち、クライアントに返す必要があるものを選択して返却する
- ドメイン層
- エンティティ、値オブジェクト、抽象リポジトリを定義する
- 抽象リポジトリでは、型・値の制約、不変条件などの単体で完結するデータの整合性に加え、同じドメイン内の他リソースとの関係の制約を他の抽象リポジトリを利用して担保する
- 複数のリポジトリが類似した意味論を持つ場合でも、汎用的な抽象化を避け、独立した関数として定義する
- 例えば、フィルターやオーダーのみが違う場合でも、独立した抽象リポジトリ関数として定義する
- 各リポジトリ関数の独立性により、実装の変更や拡張を容易にするため
- 複数のリポジトリが類似した意味論を持つ場合でも、汎用的な抽象化を避け、独立した関数として定義する
- 抽象リポジトリ関数はドメイン単位でfactoryパターンのオブジェクトとして定義し、リポジトリの実装(adapters)を引数で受け取る形で依存性を注入する
- リポジトリの利用時には、
app/usecases/repositories-provider.tsで各ドメインのadapterを注入したrepositoryをユースケース層に提供する
- リポジトリの利用時には、
- 抽象リポジトリでの取得や操作の結果は
Result<T, E>型で返却される- 型引数
Eにはとりうるドメインロジックに関するエラーメッセージをUnion型で定義する
- 型引数
- 抽象リポジトリでは、型・値の制約、不変条件などの単体で完結するデータの整合性に加え、同じドメイン内の他リソースとの関係の制約を他の抽象リポジトリを利用して担保する
- エンティティ、値オブジェクト、抽象リポジトリを定義する
- インフラストラクチャ層
- データベースや外部APIとの通信方法を定義する
- ドメイン層で定義される抽象リポジトリの実装を提供する
- 異なるリポジトリは独立した実装を持つ
- 複数のリポジトリが類似した処理を持つ場合でも、汎用ヘルパー関数で統合しない
- 各リポジトリの実装の独立性を保ち、将来の変更対応を容易にするため
- ただし定数抽出や共通変換関数など、非侵襲的な冗長性削減は推奨される
- 異なるリポジトリは独立した実装を持つ
- 共有ライブラリ(
app/libs/dbなど)とドメイン配下のadapters.tsがインフラ層を構成し、ドメインが要求する戻り値の整形や例外戦略を満たす- DBや外部APIなどの具体的な実装の詳細を扱うadapterは抽象リポジトリ関数と変更頻度が近いため、
app/domain/${domain-name}/adapters.tsにコロケーションするが、ドメインの他のファイルからは参照されない
- DBや外部APIなどの具体的な実装の詳細を扱うadapterは抽象リポジトリ関数と変更頻度が近いため、
- ドメイン層で定義される抽象リポジトリの実装を提供する
- 削除操作はべき等性を保つ
- 削除対象のリソースが存在しない場合でもエラーにしない
- 削除命令を受け取ったら「このリソースは削除すべき」という意図が満たされたと見なす
- 存在確認が必要な場合は、ユースケース層でQuery系リポジトリを使用して明示的に行う
- データベースや外部APIとの通信方法を定義する
flowchart LR
Interface["Interface layer"] --> Usecase["Use-case layer"]
Usecase --> Domain["Domain layer"]
Infrastructure["Infrastructure layer"] --> Domain
Usecase -. Receives adapters .-> Infrastructure
- バリデーションの違い
- クライアント側
- ユーザーフレンドリーな検証とフィードバックはHTMLのバリデーション属性やクライアントコンポーネントで提供する
- サーバー側
- インターフェース層は入力のパースと型の正規化を担う
- ここでは文字列から数値への変換などのユースケース層に渡すための形式を保証する
- 不正な形式のリクエストには汎用的なエラーを返す
- ユースケース層やドメイン層では、セキュリティとデータの整合性を保つための厳格なバリデーションを行う
- 不正なデータや攻撃からシステムを守るために、あらゆる入力を検証する必要がある
- ユーザーへのフィードバックは原則意識しない
- 一意性や外部リソースの照会などのクライアントだけで検証できない事項は、サーバー側での検証結果を基にユーザーにフィードバックを提供する場合もある
- インターフェース層は入力のパースと型の正規化を担う
- クライアント側
- 単体テスト(unit test)はBun testを利用する
- 統合テスト(integration test)はVitestを利用する
- HonoXがViteの拡張仕様に依存しているため
- E2Eテスト(end-to-end test)はPlaywrightを利用する