コーディングルール
命名規則
| 対象 | 規則 | 例 |
|---|---|---|
| 変数・関数 | camelCase | getLatestVersionId |
| 型・クラス・コンポーネント | PascalCase | SurveyStore, AppShell |
| ファイル名 | kebab-case | survey-store.ts, question-editor-panel.tsx |
| 定数 | UPPER_SNAKE_CASE | VERB_NOT_INCLUDES |
| DB のカラム名 | snake_case | latest_version_no |
| API の URI・JSON フィールド | camelCase | /api/surveys/{surveyId}, latestVersionNo |
DB のカラム名だけ snake_case で、TypeScript 側のプロパティは camelCase。変換は Drizzle のスキーマ定義で行う。
フォーマッター・リンター
# frontend
bun run lint # ESLint
bun run lint:fix
bun run format # Prettier (チェック)
bun run format:write
# 型チェック (全リポジトリ)
bun run typecheck # backend
tsc --noEmit # frontend / 拡張
設定ファイル: eslint.config.mjs / .prettierrc(frontend)、tsconfig.json(各リポジトリ)
frontend は husky + lint-staged により、コミット時に ESLint と Prettier が自動実行される。
API 設計規約
- すべての API は
/api配下に置く(ヘルスチェックの/と/openapi.json//swaggerを除く) - リクエスト / レスポンスボディは JSON
- レスポンスは必ず次の形に統一する
- 認証は frontend が付与する
x-user-email/x-user-name/x-user-imageヘッダ。取り込み API のみx-api-keyも受け付ける
エラーレスポンスの使い分け
| 状況 | ステータス |
|---|---|
| リクエストの形が不正 | 400(Zod バリデーションが自動で返す) |
| ビジネスルール違反(分岐条件に使えない設問の指定など) | 400 |
| 認証情報がない | 401 |
| 権限がない(削除操作) | 403 |
| 対象が存在しない、または権限がない | 404 |
権限不足は原則 404 に丸める(案件の存在自体を秘匿するため)。削除操作のみ、意図を明示するため 403 を返す。
レイヤ構成(backend)
| ルール | 内容 |
|---|---|
app.ts |
認証ミドルウェアの設定と registerXxxRoutes() の呼び出しのみ |
routes |
repositories のみ を呼び出す。ハンドラは store の呼び出しと HTTP ステータスの決定のみ |
repositories |
db と lib を参照してよい。DB 操作と権限判定をここに閉じ込める |
| 同レイヤ間 | 互いに呼び出さない |
| 下位 → 上位 | 呼び出し禁止 |
| from \ to | routes | repositories | db | lib | types |
|---|---|---|---|---|---|
| app.ts | ◯ | ◯(注入用) | ✕ | ◯ | ✕ |
| routes | ✕ | ◯ | ✕ | ◯ | ◯ |
| repositories | ✕ | ✕ | ◯ | ◯ | ◯ |
| lib | ✕ | △(auth のみ) | ◯ | ✕ | ◯ |
| db | ✕ | ✕ | ✕ | ✕ | ✕ |
lib/auth.tsはユーザーの自動作成のためにsurvey-storeを参照する。この 1 か所を除き、libからrepositoriesは呼ばない。
routes の書き方
- ファイルはリソース単位。
registerXxxRoutes(app, store)を named export する - ルート定義は
createRoute()でモジュールスコープに宣言し、ハンドラはRouteHandler<typeof route, AppBindings>で型付けする - リクエスト値は必ず
c.req.valid("json" | "param" | "query")から取り出す(生のc.req.json()を使わない) storeは引数で受け取り、既定値に本番実装を置く(テストで fake を差し込むため)
export function registerQuestionRoutes(
app: OpenAPIHono<AppBindings>,
store: SurveyStore = surveyStore,
) {
const deleteQuestionHandler: RouteHandler<typeof deleteQuestionRoute, AppBindings> = async (c) => {
const { questionId } = c.req.valid("param");
const currentUser = c.get("currentUser");
const deleted = await store.deleteQuestion(questionId, currentUser);
if (!deleted) return c.json({ ok: false, error: "Question not found" }, 404);
return c.json({ ok: true, data: { deleted: true } }, 200);
};
app.openapi(deleteQuestionRoute, deleteQuestionHandler);
}
repositories の書き方
- メソッドは第 1 引数に対象 ID、最後の引数に
actor: AuthenticatedUserを取る - 権限がないときは例外ではなく
nullを返し、routesが 404 に変換する - 複数テーブルにまたがる書き込みは
db.transaction()で囲む createdAt/updatedAt/deletedAtの代入は repository の責務
スキーマ定義
- リクエスト / レスポンスの Zod スキーマは
src/lib/openapi-schemas.tsに集約し、すべてに.openapi("SchemaName")を付ける - ドメイン型は
src/types/domain.tsに定義し、Zod 側でz.ZodType<Xxx>として突き合わせる
| 用途 | 命名 |
|---|---|
| ドメインの型 | {Resource}Schema |
| 作成リクエスト | Create{Resource}InputSchema |
| 更新リクエスト | Update{Resource}InputSchema |
| 単一レスポンス | ApiResponse{Resource}Schema |
| 一覧レスポンス | ApiResponse{Resource}ListSchema |
DB スキーマの規約
- PostgreSQL の enum は
pgEnum()で定義する - enum への値追加は必ず末尾に行う。中間挿入は drizzle-kit の
ADD VALUE BEFORE生成が不安定なため
export const questionTypeEnum = pgEnum("question_type", [
"single",
"multi",
"free_text",
"matrix",
"intro",
// 末尾追加が必須 (中間挿入は drizzle-kit の ADD VALUE BEFORE 生成が不安定)
"pulldown",
]);
- 並び順を持つテーブルには
(親ID, sortOrder)の unique index を張る。並べ替え時は一時的な値を経由して衝突を避ける - 親子関係のあるテーブルには
onDelete: "cascade"を設定する - 論理削除は
surveysのみ。参照系クエリではdeletedAt IS NULLを条件に含める
frontend の規約
- 1 コンポーネント 1 ディレクトリ。
index.tsx+index.module.scssの組で作る(雛形はbun run new:component) - クライアントコンポーネントの先頭に
"use client";を書く - スタイルは CSS Modules(SCSS)。クラス名の結合は
mergeClassNames()を使う - backend の呼び出しは orval が生成したフックのみ を使い、
fetchを直接書かない - 共通の fetch 処理は
lib/orval/client.tsのcustomFetchに集約する - UI に依存しないロジックは
lib/surveys/に純粋関数として切り出し、Vitest でテストする
Chrome 拡張の規約
- CS への変換ロジックは
plan/build-import-plan.tsに純粋関数として集約し、API 呼び出し(execute-import-plan.ts)と分離する - CS の
verbなどのマジックナンバーは名前付き定数として定義し、由来(実機ログの参照先)をコメントに残す - 変換ロジックの変更は必ず
build-import-plan.test.tsにテストを追加する
テスト方針
- backend は
createApp()に fake store と fake auth resolver を注入 し、DB を使わない API テストを書く - API テストで振る舞いを先に固定してから repository 実装に落とす
- 詳細は テストケース を参照
const app = createApp({ store: fakeStore, resolveUser: async () => fakeUser });
const res = await app.request("/api/surveys");
PR とレビューの基準
- 1PR は 1 つの目的に絞る
- レビュワーが理解できるよう説明を PR 本文に書く
- セルフレビューをしてからレビュー依頼する
- backend の API を変更したら、必ず
bun run export:openapi→ frontendbun run generate:apiまで実施して同一 PR に含める - DB スキーマを変更したら
bun run db:generateで生成したマイグレーションを同一 PR に含める mainへの push は dev 環境へ自動デプロイされるため、動作確認をしてからマージする
アンチパターン
routesにビジネスロジックを書く(repositoriesに置く)routesから Drizzle を直接触る(DB 操作はrepositoriesに閉じる)lib/api/generated.tsを手編集する(clean: trueで毎回作り直されるため消える)- enum の中間に値を挿入する(マイグレーション生成が不安定になる。必ず末尾へ)
- 分岐条件で選択肢を ID 参照する(更新のたびに ID が変わる。ラベル文字列で参照する)
process.envをroutes/repositoriesから直接読む(lib層に集約する)any型の多用(Zod スキーマから型を導く)- 権限チェックを迂回するデータ取得(
repositoriesの権限判定を必ず通す)