コンテンツにスキップ

コーディングルール

命名規則

対象 規則 例
変数・関数 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
  • レスポンスは必ず次の形に統一する
// 成功
{ "ok": true, "data": { /* ... */ } }

// 失敗
{ "ok": false, "error": "Survey not found" }
  • 認証は 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  ─►  routes/  ─►  repositories/  ─►  db/
              │               │
              └──►  lib/  ◄───┘
ルール 内容
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 → frontend bun 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 の権限判定を必ず通す)